forked from enviPath/enviPy
Compare commits
53 Commits
6499a0c659
...
develop-ba
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b5d299128 | |||
| 38e901a51e | |||
| c92fccaf8e | |||
| 5eb3ebac89 | |||
| d9530ce755 | |||
| 1e43c298d2 | |||
| b39fc7eaf8 | |||
| a2fc9f72cb | |||
| 734b02767e | |||
| 9d70db2ca2 | |||
| fec26d0233 | |||
| 689f7998eb | |||
| 8498e59fa1 | |||
| b508511cd6 | |||
| 877804c0ff | |||
| 964574c700 | |||
| 5029a8cda5 | |||
| d06bd0d4fd | |||
| f7c45b8015 | |||
| 68aea97013 | |||
| 3cc7fa9e8b | |||
| 21f3390a43 | |||
| 8cdf91c8fb | |||
| bafbf11322 | |||
| f1a9456d1d | |||
| e0764126e3 | |||
| ef0c45b203 | |||
| b737fc93eb | |||
| d4295c9349 | |||
| c6ff97694d | |||
| 6e00926371 | |||
| 81cc612e69 | |||
| cc9598775c | |||
| d2c2e643cb | |||
| 0ff046363c | |||
| 5150027f0d | |||
| 58ab5b33e3 | |||
| 73f0202267 | |||
| 27c5bad9c5 | |||
| 5789f20e7f | |||
| c0cfdb9255 | |||
| 5da8dbc191 | |||
| dc18b73e08 | |||
| d80dfb5ee3 | |||
| 9f63a9d4de | |||
| 5565b9cb9e | |||
| ab0b5a5186 | |||
| f905bf21cf | |||
| 1fd993927c | |||
| 2a2fe4f147 | |||
| 5f5ae76182 | |||
| 1c2f70b3b9 | |||
| 54f8302104 |
93
.dockerignore
Normal file
93
.dockerignore
Normal file
@ -0,0 +1,93 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# uv / virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# uv cache
|
||||
.uv/
|
||||
uv.lock.bak
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Test / coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache/
|
||||
pytest_cache/
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
|
||||
# Type checkers
|
||||
.mypy_cache/
|
||||
.pyre/
|
||||
.pytype/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.*
|
||||
*.env
|
||||
|
||||
# IDEs / editors
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
.gitea/
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
docker-compose.yaml
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# Frontend stuff
|
||||
node_modules/
|
||||
@ -3,10 +3,20 @@ EP_DATA_DIR=
|
||||
ALLOWED_HOSTS=
|
||||
DEBUG=
|
||||
LOG_LEVEL=
|
||||
MODEL_BUILDING_ENABLED=
|
||||
APPLICABILITY_DOMAIN_ENABLED=
|
||||
ENVIFORMER_PRESENT=
|
||||
FLAG_CELERY_PRESENT=
|
||||
SERVER_URL=
|
||||
ENVIFORMER_DEVICE=
|
||||
PLUGINS_ENABLED=
|
||||
SERVER_URL=
|
||||
SERVER_PATH=
|
||||
ADMIN_APPROVAL_REQUIRED=
|
||||
REGISTRATION_MANDATORY=
|
||||
LOG_DIR=
|
||||
# Celery
|
||||
FLAG_CELERY_PRESENT=
|
||||
CELERY_BROKER_URL=
|
||||
CELERY_RESULT_BACKEND=
|
||||
# DB
|
||||
POSTGRES_SERVICE_NAME=
|
||||
POSTGRES_DB=
|
||||
@ -16,5 +26,30 @@ POSTGRES_PORT=
|
||||
# MAIL
|
||||
EMAIL_HOST_USER=
|
||||
EMAIL_HOST_PASSWORD=
|
||||
# MATOMO
|
||||
MATOMO_SITE_ID
|
||||
DEFAULT_FROM_EMAIL=
|
||||
SERVER_EMAIL=
|
||||
# SENTRY
|
||||
SENTRY_ENABLED=
|
||||
SENTRY_DSN=
|
||||
SENTRY_ENVIRONMENT=
|
||||
# MS ENTRA
|
||||
MS_ENTRA_ENABLED=
|
||||
MS_CLIENT_ID=
|
||||
MS_CLIENT_SECRET=
|
||||
MS_TENANT_ID=
|
||||
MS_REDIRECT_URI=
|
||||
MS_SCOPES=
|
||||
# Tenant
|
||||
TENANT=
|
||||
EPDB_PACKAGE_MODEL=
|
||||
# Captcha
|
||||
CAP_ENABLED=
|
||||
CAP_API_BASE=
|
||||
CAP_SITE_KEY=
|
||||
CAP_SECRET_KEY=
|
||||
# QUARKUS (JAVA)
|
||||
ENVIRULE_ENABLED=
|
||||
FINGERPRINT_URL=
|
||||
# Biotransformer
|
||||
BIOTRANSFORMER_ENABLED=
|
||||
BIOTRANSFORMER_URL=
|
||||
|
||||
@ -48,11 +48,6 @@ runs:
|
||||
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
|
||||
|
||||
@ -5,10 +5,12 @@ repos:
|
||||
rev: v3.2.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
exclude: epiuclid/schemas/
|
||||
- id: end-of-file-fixer
|
||||
exclude: epiuclid/schemas/
|
||||
- id: check-yaml
|
||||
- id: check-added-large-files
|
||||
exclude: ^static/images/
|
||||
exclude: ^static/images/|^epiuclid/schemas/|^fixtures/
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.13.3
|
||||
|
||||
100
Dockerfile
Normal file
100
Dockerfile
Normal file
@ -0,0 +1,100 @@
|
||||
FROM python:3.12-slim AS builder
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
UV_LINK_MODE=copy
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libpq-dev \
|
||||
curl \
|
||||
openssh-client \
|
||||
git \
|
||||
nodejs \
|
||||
npm \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm
|
||||
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
ENV PATH="/root/.local/bin:${PATH}"
|
||||
|
||||
# Install dependencies first (cached layer — only invalidated when lockfile changes)
|
||||
COPY pyproject.toml uv.lock ./
|
||||
|
||||
# Add key from git.envipath.com to known_hosts
|
||||
RUN mkdir -p -m 0700 /root/.ssh \
|
||||
&& ssh-keyscan git.envipath.com >> /root/.ssh/known_hosts
|
||||
|
||||
# We'll need access to private repos, let docker make use of host ssh agent and use it like:
|
||||
# docker build --ssh default -t envipath/envipy-bayer:1.0 .
|
||||
RUN --mount=type=ssh \
|
||||
uv sync --locked --extra ms-login --extra pepper-plugin
|
||||
|
||||
# Now copy source and do a final sync to install the project itself
|
||||
# Ensure .dockerignore is reasonable
|
||||
COPY bb4g bb4g
|
||||
COPY biotransformer biotransformer
|
||||
COPY bayer bayer
|
||||
COPY bridge bridge
|
||||
COPY envipath envipath
|
||||
COPY epapi epapi
|
||||
COPY epauth epauth
|
||||
COPY epdb epdb
|
||||
COPY epiuclid epiuclid
|
||||
COPY fixtures fixtures
|
||||
COPY migration migration
|
||||
COPY pepper pepper
|
||||
COPY scripts scripts
|
||||
COPY static static
|
||||
COPY templates templates
|
||||
COPY tests tests
|
||||
COPY utilities utilities
|
||||
COPY manage.py .
|
||||
|
||||
# Install frontend deps
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
|
||||
# Build frontend assets
|
||||
RUN uv run python scripts/pnpm_wrapper.py install
|
||||
RUN uv run python scripts/pnpm_wrapper.py run build
|
||||
|
||||
FROM python:3.12-slim AS production
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 \
|
||||
libxrender1 \
|
||||
libxext6 \
|
||||
libfontconfig1 \
|
||||
nano \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd -ms /bin/bash django
|
||||
|
||||
# Create directories in /opt and set ownership
|
||||
RUN mkdir -p /opt/enviPy \
|
||||
&& mkdir -p /opt/enviPy/celery \
|
||||
&& mkdir -p /opt/enviPy/log \
|
||||
&& mkdir -p /opt/enviPy/models \
|
||||
&& mkdir -p /opt/enviPy/plugins \
|
||||
&& mkdir -p /opt/enviPy/static \
|
||||
&& chown -R django:django /opt/enviPy
|
||||
|
||||
COPY --from=builder --chown=django:django /app /app
|
||||
|
||||
RUN touch /app/.env && chown -R django:django /app/.env
|
||||
|
||||
USER django
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["gunicorn", "envipath.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]
|
||||
56
README.md
56
README.md
@ -8,13 +8,12 @@ These instructions will guide you through setting up the project for local devel
|
||||
|
||||
- Python 3.11 or later
|
||||
- [uv](https://github.com/astral-sh/uv) - Python package manager
|
||||
- **Docker and Docker Compose** - Required for running PostgreSQL database
|
||||
- **Docker and Docker Compose** - Required for running PostgreSQL database and Redis (for async Celery tasks)
|
||||
- Git
|
||||
- Make
|
||||
|
||||
> **Note:** This application requires PostgreSQL (uses `ArrayField`). Docker is the easiest way to run PostgreSQL locally.
|
||||
|
||||
|
||||
### 1. Install Dependencies
|
||||
|
||||
This project uses `uv` to manage dependencies and `poe-the-poet` for task running. First, [install `uv` if you don't have it yet](https://docs.astral.sh/uv/guides/install-python/).
|
||||
@ -79,25 +78,48 @@ uv run poe bootstrap # Bootstrap data only
|
||||
uv run poe shell # Open the Django shell
|
||||
uv run poe build # Build frontend assets and collect static files
|
||||
uv run poe clean # Remove database volumes (WARNING: destroys all data)
|
||||
uv run poe celery # Start Celery worker for async task processing
|
||||
uv run poe celery-dev # Start database and Celery worker
|
||||
```
|
||||
|
||||
### 4. Async Celery Setup (Optional)
|
||||
|
||||
By default, Celery tasks run synchronously (`CELERY_TASK_ALWAYS_EAGER = True`), which means prediction tasks block the HTTP request until completion. To enable asynchronous task processing with live status updates on pathway pages:
|
||||
|
||||
1. **Set the Celery flag in your `.env` file:**
|
||||
|
||||
```bash
|
||||
FLAG_CELERY_PRESENT=True
|
||||
```
|
||||
|
||||
2. **Start Redis and Celery worker:**
|
||||
|
||||
```bash
|
||||
uv run poe celery-dev
|
||||
```
|
||||
|
||||
3. **Start the development server** (in another terminal):
|
||||
```bash
|
||||
uv run poe dev
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
* **Docker Connection Error:** If you see an error like `open //./pipe/dockerDesktopLinuxEngine: The system cannot find the file specified` (on Windows), it likely means your Docker Desktop application is not running. Please start Docker Desktop and try the command again.
|
||||
- **Docker Connection Error:** If you see an error like `open //./pipe/dockerDesktopLinuxEngine: The system cannot find the file specified` (on Windows), it likely means your Docker Desktop application is not running. Please start Docker Desktop and try the command again.
|
||||
|
||||
* **SSH Keys for Git Dependencies:** Some dependencies are installed from private git repositories and require SSH authentication. Ensure your SSH keys are configured correctly for Git.
|
||||
* For a general guide, see [GitHub's official documentation](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent).
|
||||
* **Windows Users:** If `uv sync` hangs while fetching git dependencies, you may need to explicitly configure Git to use the Windows OpenSSH client and use the `ssh-agent` to manage your key's passphrase.
|
||||
- **SSH Keys for Git Dependencies:** Some dependencies are installed from private git repositories and require SSH authentication. Ensure your SSH keys are configured correctly for Git.
|
||||
- For a general guide, see [GitHub's official documentation](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent).
|
||||
- **Windows Users:** If `uv sync` hangs while fetching git dependencies, you may need to explicitly configure Git to use the Windows OpenSSH client and use the `ssh-agent` to manage your key's passphrase.
|
||||
1. **Point Git to the correct SSH executable:**
|
||||
```powershell
|
||||
git config --global core.sshCommand "C:/Windows/System32/OpenSSH/ssh.exe"
|
||||
```
|
||||
2. **Enable and use the SSH agent:**
|
||||
|
||||
1. **Point Git to the correct SSH executable:**
|
||||
```powershell
|
||||
git config --global core.sshCommand "C:/Windows/System32/OpenSSH/ssh.exe"
|
||||
```
|
||||
2. **Enable and use the SSH agent:**
|
||||
```powershell
|
||||
# Run these commands in an administrator PowerShell
|
||||
Get-Service ssh-agent | Set-Service -StartupType Automatic -PassThru | Start-Service
|
||||
```powershell
|
||||
# Run these commands in an administrator PowerShell
|
||||
Get-Service ssh-agent | Set-Service -StartupType Automatic -PassThru | Start-Service
|
||||
|
||||
# Add your key to the agent. It will prompt for the passphrase once.
|
||||
ssh-add
|
||||
```
|
||||
# Add your key to the agent. It will prompt for the passphrase once.
|
||||
ssh-add
|
||||
```
|
||||
|
||||
0
bayer/__init__.py
Normal file
0
bayer/__init__.py
Normal file
19
bayer/admin.py
Normal file
19
bayer/admin.py
Normal file
@ -0,0 +1,19 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
from .models import (
|
||||
PESCompound,
|
||||
PESStructure
|
||||
)
|
||||
|
||||
|
||||
class PESCompoundAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
class PESStructureAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
admin.site.register(PESCompound, PESCompoundAdmin)
|
||||
admin.site.register(PESStructure, PESStructureAdmin)
|
||||
6
bayer/apps.py
Normal file
6
bayer/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BayerConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'bayer'
|
||||
39
bayer/epdb_hooks.py
Normal file
39
bayer/epdb_hooks.py
Normal file
@ -0,0 +1,39 @@
|
||||
import logging
|
||||
|
||||
from epdb.template_registry import register_template
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# PES Create
|
||||
register_template(
|
||||
"epdb.actions.collections.compound",
|
||||
"actions/collections/new_pes.html",
|
||||
)
|
||||
register_template(
|
||||
"modals.collections.compound",
|
||||
"modals/collections/new_pes_modal.html",
|
||||
)
|
||||
register_template(
|
||||
"epdb.actions.objects.pathway.add",
|
||||
"actions/objects/pathway_add_pes.html",
|
||||
)
|
||||
register_template(
|
||||
"epdb.modals.objects.pathway.add",
|
||||
"modals/objects/add_pathway_pes_node_modal.html"
|
||||
)
|
||||
|
||||
# PES Viz
|
||||
register_template(
|
||||
"epdb.objects.compound.viz",
|
||||
"objects/compound_viz.html",
|
||||
)
|
||||
|
||||
register_template(
|
||||
"epdb.objects.compound_structure.viz",
|
||||
"objects/compound_structure_viz.html",
|
||||
)
|
||||
|
||||
register_template(
|
||||
"epdb.objects.node.viz",
|
||||
"objects/node_viz.html",
|
||||
)
|
||||
35
bayer/migrations/0001_initial.py
Normal file
35
bayer/migrations/0001_initial.py
Normal file
@ -0,0 +1,35 @@
|
||||
# Generated by Django 5.2.7 on 2026-03-06 10:51
|
||||
|
||||
import django.utils.timezone
|
||||
import model_utils.fields
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Package',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
|
||||
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
|
||||
('name', models.TextField(default='no name', verbose_name='Name')),
|
||||
('description', models.TextField(default='no description', verbose_name='Descriptions')),
|
||||
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
|
||||
('kv', models.JSONField(blank=True, default=dict, null=True)),
|
||||
('reviewed', models.BooleanField(default=False, verbose_name='Reviewstatus')),
|
||||
('classification_level', models.IntegerField(choices=[(0, 'Internal'), (10, 'Restricted'), (20, 'Secret')], default=10)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'epdb_package',
|
||||
},
|
||||
),
|
||||
]
|
||||
22
bayer/migrations/0002_initial.py
Normal file
22
bayer/migrations/0002_initial.py
Normal file
@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.7 on 2026-03-06 10:51
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('bayer', '0001_initial'),
|
||||
('epdb', '0019_remove_scenario_additional_information_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='package',
|
||||
name='license',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.license', verbose_name='License'),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,41 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-17 21:22
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bayer', '0002_initial'),
|
||||
('epdb', '0023_alter_compoundstructure_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PESCompound',
|
||||
fields=[
|
||||
('compound_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.compound')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=('epdb.compound',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PESStructure',
|
||||
fields=[
|
||||
('compoundstructure_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.compoundstructure')),
|
||||
('pes_link', models.URLField(verbose_name='PES Link')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=('epdb.compoundstructure',),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='package',
|
||||
name='data_pool',
|
||||
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.group', verbose_name='Data pool'),
|
||||
),
|
||||
]
|
||||
0
bayer/migrations/__init__.py
Normal file
0
bayer/migrations/__init__.py
Normal file
236
bayer/models.py
Normal file
236
bayer/models.py
Normal file
@ -0,0 +1,236 @@
|
||||
from typing import List
|
||||
import urllib.parse
|
||||
import nh3
|
||||
from django.conf import settings as s
|
||||
from django.db import models, transaction
|
||||
from django.db.models import QuerySet
|
||||
from django.urls import reverse
|
||||
|
||||
from epdb.models import (
|
||||
EnviPathModel,
|
||||
Compound,
|
||||
CompoundStructure,
|
||||
ParallelRule,
|
||||
SequentialRule,
|
||||
SimpleAmbitRule,
|
||||
SimpleRDKitRule,
|
||||
)
|
||||
from utilities.chem import FormatConverter
|
||||
|
||||
|
||||
class Package(EnviPathModel):
|
||||
reviewed = models.BooleanField(verbose_name="Reviewstatus", default=False)
|
||||
license = models.ForeignKey(
|
||||
"epdb.License", on_delete=models.SET_NULL, blank=True, null=True, verbose_name="License"
|
||||
)
|
||||
|
||||
class Classification(models.IntegerChoices):
|
||||
INTERNAL = 0, "Internal"
|
||||
RESTRICTED = 10 , "Restricted"
|
||||
SECRET = 20, "Secret"
|
||||
|
||||
classification_level = models.IntegerField(
|
||||
choices=Classification,
|
||||
default=Classification.RESTRICTED,
|
||||
)
|
||||
|
||||
data_pool = models.ForeignKey("epdb.Group", on_delete=models.SET_NULL, blank=True, null=True,
|
||||
verbose_name="Data pool", default=None)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
# explicitly handle related Rules
|
||||
for r in self.rules.all():
|
||||
r.delete()
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} (pk={self.pk})"
|
||||
|
||||
@property
|
||||
def compounds(self) -> QuerySet:
|
||||
return self.compound_set.all()
|
||||
|
||||
@property
|
||||
def rules(self) -> QuerySet:
|
||||
return self.rule_set.all()
|
||||
|
||||
@property
|
||||
def reactions(self) -> QuerySet:
|
||||
return self.reaction_set.all()
|
||||
|
||||
@property
|
||||
def pathways(self) -> QuerySet:
|
||||
return self.pathway_set.all()
|
||||
|
||||
@property
|
||||
def scenarios(self) -> QuerySet:
|
||||
return self.scenario_set.all()
|
||||
|
||||
@property
|
||||
def models(self) -> QuerySet:
|
||||
return self.epmodel_set.all()
|
||||
|
||||
def _url(self):
|
||||
return "{}/package/{}".format(s.SERVER_URL, self.uuid)
|
||||
|
||||
def get_applicable_rules(self) -> List["Rule"]:
|
||||
"""
|
||||
Returns a ordered set of rules where the following applies:
|
||||
1. All Composite will be added to result
|
||||
2. All SimpleRules will be added if theres no CompositeRule present using the SimpleRule
|
||||
Ordering is based on "url" field.
|
||||
"""
|
||||
rules = []
|
||||
rule_qs = self.rules
|
||||
|
||||
reflected_simple_rules = set()
|
||||
|
||||
for r in rule_qs:
|
||||
if isinstance(r, ParallelRule) or isinstance(r, SequentialRule):
|
||||
rules.append(r)
|
||||
for sr in r.simple_rules.all():
|
||||
reflected_simple_rules.add(sr)
|
||||
|
||||
for r in rule_qs:
|
||||
if isinstance(r, SimpleAmbitRule) or isinstance(r, SimpleRDKitRule):
|
||||
if r not in reflected_simple_rules:
|
||||
rules.append(r)
|
||||
|
||||
rules = sorted(rules, key=lambda x: x.url)
|
||||
return rules
|
||||
|
||||
class Meta:
|
||||
db_table = "epdb_package"
|
||||
|
||||
|
||||
class PESCompound(Compound):
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def create(
|
||||
package: "Package", pes_data: dict, name: str = None, description: str = None, *args, **kwargs
|
||||
) -> "Compound":
|
||||
|
||||
pes_url = pes_data["pes_url"]
|
||||
|
||||
# Check if we find a direct match for a given pes_link
|
||||
if PESStructure.objects.filter(pes_link=pes_url, compound__package=package).exists():
|
||||
# Due to normalization we might end up in having multiple structures
|
||||
# All of them point to the same compound -> pick any
|
||||
return PESStructure.objects.filter(pes_link=pes_url, compound__package=package).first().compound
|
||||
|
||||
# Generate Compound
|
||||
c = PESCompound()
|
||||
c.package = package
|
||||
|
||||
if name is not None:
|
||||
# Clean for potential XSS
|
||||
name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
|
||||
|
||||
if name is None or name == "":
|
||||
name = f"Compound {Compound.objects.filter(package=package).count() + 1}"
|
||||
|
||||
c.name = name
|
||||
|
||||
# We have a default here only set the value if it carries some payload
|
||||
if description is not None and description.strip() != "":
|
||||
c.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
|
||||
|
||||
c.save()
|
||||
|
||||
molfile = pes_data.get("representativeStructures", [{}])[0].get("ctab")
|
||||
|
||||
if molfile is None:
|
||||
raise ValueError("PES data does not contain a valid mol file!")
|
||||
|
||||
smiles = FormatConverter.to_smiles(FormatConverter.from_molfile(molfile))
|
||||
|
||||
standardized_smiles = FormatConverter.standardize(smiles, remove_stereo=True)
|
||||
|
||||
is_standardized = standardized_smiles == smiles
|
||||
|
||||
if not is_standardized:
|
||||
_ = PESStructure.create(
|
||||
c,
|
||||
pes_url,
|
||||
molfile,
|
||||
standardized_smiles,
|
||||
name="Normalized structure of {}".format(name),
|
||||
description="{} (in its normalized form)".format(description),
|
||||
normalized_structure=True,
|
||||
)
|
||||
|
||||
|
||||
cs = PESStructure.create(
|
||||
c,
|
||||
pes_url,
|
||||
molfile,
|
||||
smiles,
|
||||
name=name,
|
||||
description=description,
|
||||
normalized_structure=is_standardized
|
||||
)
|
||||
|
||||
c.default_structure = cs
|
||||
c.save()
|
||||
|
||||
return c
|
||||
|
||||
|
||||
class PESStructure(CompoundStructure):
|
||||
pes_link = models.URLField(blank=False, null=False, verbose_name="PES Link")
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def create(
|
||||
compound: Compound,
|
||||
pes_link: str,
|
||||
mol_file: str,
|
||||
smiles: str,
|
||||
name: str = None,
|
||||
description: str = None,
|
||||
*args,
|
||||
**kwargs
|
||||
):
|
||||
if compound.pk is None:
|
||||
raise ValueError("Unpersisted Compound! Persist compound first!")
|
||||
|
||||
cs = PESStructure()
|
||||
# Clean for potential XSS
|
||||
if name is not None:
|
||||
cs.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
|
||||
|
||||
if description is not None:
|
||||
cs.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
|
||||
|
||||
cs.smiles = smiles
|
||||
cs.mol_file = mol_file
|
||||
cs.pes_link = pes_link
|
||||
cs.compound = compound
|
||||
|
||||
if "normalized_structure" in kwargs:
|
||||
cs.normalized_structure = kwargs["normalized_structure"]
|
||||
|
||||
cs.save()
|
||||
|
||||
return cs
|
||||
|
||||
@transaction.atomic
|
||||
def add_structure(
|
||||
self,
|
||||
smiles: str,
|
||||
name: str = None,
|
||||
description: str = None,
|
||||
default_structure: bool = False,
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> "CompoundStructure":
|
||||
raise ValueError("Not supported!")
|
||||
|
||||
def d3_json(self):
|
||||
return {
|
||||
"is_pes": True,
|
||||
"pes_link": self.pes_link,
|
||||
# Will overwrite image from Node
|
||||
"image": f"{reverse("depict_pes")}?pesLink={urllib.parse.quote(self.pes_link)}"
|
||||
}
|
||||
9
bayer/templates/actions/collections/new_pes.html
Normal file
9
bayer/templates/actions/collections/new_pes.html
Normal file
@ -0,0 +1,9 @@
|
||||
{% if meta.can_edit %}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
onclick="document.getElementById('new_pes_modal').showModal(); return false;"
|
||||
>
|
||||
New PES
|
||||
</button>
|
||||
{% endif %}
|
||||
8
bayer/templates/actions/objects/pathway_add_pes.html
Normal file
8
bayer/templates/actions/objects/pathway_add_pes.html
Normal file
@ -0,0 +1,8 @@
|
||||
<li>
|
||||
<a
|
||||
class="button"
|
||||
onclick="document.getElementById('add_pathway_pes_node_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-plus"></i> Add PES</a
|
||||
>
|
||||
</li>
|
||||
175
bayer/templates/modals/collections/new_package_modal.html
Normal file
175
bayer/templates/modals/collections/new_package_modal.html
Normal file
@ -0,0 +1,175 @@
|
||||
{% load static %}
|
||||
|
||||
<dialog
|
||||
id="new_package_modal"
|
||||
class="modal"
|
||||
x-data="{
|
||||
isSubmitting: false,
|
||||
packageClassification: null,
|
||||
|
||||
reset() {
|
||||
this.isSubmitting = false;
|
||||
this.packageClassification = null;
|
||||
},
|
||||
|
||||
setFormData(data) {
|
||||
this.formData = data;
|
||||
},
|
||||
|
||||
get isSecret() {
|
||||
return this.packageClassification === '20';
|
||||
},
|
||||
|
||||
submit(formId) {
|
||||
const form = document.getElementById(formId);
|
||||
|
||||
// Remove previously injected inputs
|
||||
form.querySelectorAll('.dynamic-param').forEach(el => el.remove());
|
||||
|
||||
// Add values from dynamic form into the html form
|
||||
if (this.formData) {
|
||||
Object.entries(this.formData).forEach(([key, value]) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = key;
|
||||
input.value = value;
|
||||
input.classList.add('dynamic-param');
|
||||
|
||||
form.appendChild(input);
|
||||
});
|
||||
}
|
||||
|
||||
if (form && form.checkValidity()) {
|
||||
this.isSubmitting = true;
|
||||
form.submit();
|
||||
} else if (form) {
|
||||
form.reportValidity();
|
||||
}
|
||||
}
|
||||
|
||||
}"
|
||||
@close="reset()"
|
||||
>
|
||||
<div class="modal-box max-w-3xl">
|
||||
<!-- Header -->
|
||||
<h3 class="text-lg font-bold">New Package</h3>
|
||||
|
||||
<!-- Close button (X) -->
|
||||
<form method="dialog">
|
||||
<button
|
||||
class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="py-4">
|
||||
<form
|
||||
id="new_package_form"
|
||||
accept-charset="UTF-8"
|
||||
action=""
|
||||
method="post"
|
||||
>
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Name -->
|
||||
<div class="form-control mb-3">
|
||||
<label class="label" for="package-name">
|
||||
<span class="label-text">Name</span>
|
||||
</label>
|
||||
<input
|
||||
id="package-name"
|
||||
class="input input-bordered w-full"
|
||||
name="package-name"
|
||||
placeholder="Name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="form-control mb-3">
|
||||
<label class="label" for="package-description">
|
||||
<span class="label-text">Description</span>
|
||||
</label>
|
||||
<input
|
||||
id="package-description"
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="Description..."
|
||||
name="package-description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Classification Level -->
|
||||
<div class="form-control mb-3">
|
||||
<label class="label" for="package-classification">
|
||||
<span class="label-text">Package Classification</span>
|
||||
</label>
|
||||
<select
|
||||
id="package-classification"
|
||||
name="package-classification"
|
||||
class="select select-bordered w-full"
|
||||
x-model="packageClassification"
|
||||
required
|
||||
>
|
||||
<option value="null" disabled selected>Select Classification</option>
|
||||
<option value="0">Internal</option>
|
||||
<option value="10">Restricted</option>
|
||||
<option value="20">Secret</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Secret Groups -->
|
||||
<div class="form-control mb-3" x-show="isSecret" x-cloak>
|
||||
<label class="label" for="package-data-pool">
|
||||
<span class="label-text">Data Pool for SECRET Package</span>
|
||||
</label>
|
||||
<p>Only users with this role can be granted access to this package</p>
|
||||
<select
|
||||
id="package-data-pool"
|
||||
name="package-data-pool"
|
||||
class="select select-bordered w-full"
|
||||
>
|
||||
<option value="" disabled selected>Select Data Pool</option>
|
||||
{% for obj in meta.secret_groups %}
|
||||
<option value="{{ obj.url }}">{{ obj.name|safe }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="modal-action">
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
onclick="this.closest('dialog').close()"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="submit('new_package_form')"
|
||||
:disabled="isSubmitting || !selectedType || loadingSchemas"
|
||||
>
|
||||
<span x-show="!isSubmitting">Submit</span>
|
||||
<span
|
||||
x-show="isSubmitting"
|
||||
class="loading loading-spinner loading-sm"
|
||||
></span>
|
||||
<span x-show="isSubmitting">Creating...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backdrop -->
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button :disabled="isSubmitting">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
174
bayer/templates/modals/collections/new_pes_modal.html
Normal file
174
bayer/templates/modals/collections/new_pes_modal.html
Normal file
@ -0,0 +1,174 @@
|
||||
{% load static %}
|
||||
|
||||
<dialog
|
||||
id="new_pes_modal"
|
||||
class="modal"
|
||||
x-data="{
|
||||
isSubmitting: false,
|
||||
pesLink: null,
|
||||
pesVizHtml: '',
|
||||
|
||||
reset() {
|
||||
this.isSubmitting = false;
|
||||
},
|
||||
|
||||
get isPESSet() {
|
||||
console.log(this.pesLink);
|
||||
return this.pesLink !== null;
|
||||
},
|
||||
|
||||
updatePesViz() {
|
||||
if (!this.isPESSet) {
|
||||
this.pesVizHtml = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
img.src = '{% url 'depict_pes' %}?pesLink=' + encodeURIComponent(this.pesLink);
|
||||
img.style.width = '100%';
|
||||
img.style.height = '100%';
|
||||
img.style.objectFit = 'cover';
|
||||
|
||||
img.onload = () => {
|
||||
this.pesVizHtml = img.outerHTML;
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
this.pesVizHtml = `
|
||||
<div class='alert alert-error' role='alert'>
|
||||
<h4 class='alert-heading'>Could not render PES!</h4>
|
||||
<p>Could not render PES - Do you have access?</p>
|
||||
</div>`;
|
||||
};
|
||||
},
|
||||
|
||||
submit(formId) {
|
||||
const form = document.getElementById(formId);
|
||||
|
||||
// Remove previously injected inputs
|
||||
form.querySelectorAll('.dynamic-param').forEach(el => el.remove());
|
||||
|
||||
// Add values from dynamic form into the html form
|
||||
if (this.formData) {
|
||||
Object.entries(this.formData).forEach(([key, value]) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = key;
|
||||
input.value = value;
|
||||
input.classList.add('dynamic-param');
|
||||
|
||||
form.appendChild(input);
|
||||
});
|
||||
}
|
||||
|
||||
if (form && form.checkValidity()) {
|
||||
this.isSubmitting = true;
|
||||
form.submit();
|
||||
} else if (form) {
|
||||
form.reportValidity();
|
||||
}
|
||||
}
|
||||
}"
|
||||
@close="reset()"
|
||||
>
|
||||
<div class="modal-box max-w-3xl">
|
||||
<!-- Header -->
|
||||
<h3 class="text-lg font-bold">New PES</h3>
|
||||
|
||||
<!-- Close button (X) -->
|
||||
<form method="dialog">
|
||||
<button
|
||||
class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="py-4">
|
||||
<form
|
||||
id="new-pes-modal-form"
|
||||
accept-charset="UTF-8"
|
||||
action="{% url 'create pes' meta.current_package.uuid %}"
|
||||
method="post"
|
||||
>
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="form-control mb-3">
|
||||
<label class="label" for="compound-name">
|
||||
<span class="label-text">Name</span>
|
||||
</label>
|
||||
<input
|
||||
id="compound-name"
|
||||
class="input input-bordered w-full"
|
||||
name="compound-name"
|
||||
placeholder="Name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-3">
|
||||
<label class="label" for="compound-description">
|
||||
<span class="label-text">Description</span>
|
||||
</label>
|
||||
<input
|
||||
id="compound-description"
|
||||
class="input input-bordered w-full"
|
||||
name="compound-description"
|
||||
placeholder="Description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-3">
|
||||
<label class="label" for="pes-link">
|
||||
<span class="label-text">Link to PES</span>
|
||||
</label>
|
||||
<input
|
||||
id="pes-link"
|
||||
name="pes-link"
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="Link to PES e.g. https://pesregapp-test.cropkey-np.ag/entities/PES-000126"
|
||||
x-model="pesLink"
|
||||
@input="updatePesViz()"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="pes-viz" class="mb-3" x-html="pesVizHtml"></div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="modal-action">
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
onclick="this.closest('dialog').close()"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="submit('new-pes-modal-form')"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
<span x-show="!isSubmitting">Submit</span>
|
||||
<span
|
||||
x-show="isSubmitting"
|
||||
class="loading loading-spinner loading-sm"
|
||||
></span>
|
||||
<span x-show="isSubmitting">Creating...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backdrop -->
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button :disabled="isSubmitting">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
174
bayer/templates/modals/objects/add_pathway_pes_node_modal.html
Normal file
174
bayer/templates/modals/objects/add_pathway_pes_node_modal.html
Normal file
@ -0,0 +1,174 @@
|
||||
{% load static %}
|
||||
|
||||
<dialog
|
||||
id="add_pathway_pes_node_modal"
|
||||
class="modal"
|
||||
x-data="{
|
||||
isSubmitting: false,
|
||||
pesLink: null,
|
||||
pesVizHtml: '',
|
||||
|
||||
reset() {
|
||||
this.isSubmitting = false;
|
||||
},
|
||||
|
||||
get isPESSet() {
|
||||
console.log(this.pesLink);
|
||||
return this.pesLink !== null;
|
||||
},
|
||||
|
||||
updatePesViz() {
|
||||
if (!this.isPESSet) {
|
||||
this.pesVizHtml = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
img.src = '{% url 'depict_pes' %}?pesLink=' + encodeURIComponent(this.pesLink);
|
||||
img.style.width = '100%';
|
||||
img.style.height = '100%';
|
||||
img.style.objectFit = 'cover';
|
||||
|
||||
img.onload = () => {
|
||||
this.pesVizHtml = img.outerHTML;
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
this.pesVizHtml = `
|
||||
<div class='alert alert-error' role='alert'>
|
||||
<h4 class='alert-heading'>Could not render PES!</h4>
|
||||
<p>Could not render PES - Do you have access?</p>
|
||||
</div>`;
|
||||
};
|
||||
},
|
||||
|
||||
submit(formId) {
|
||||
const form = document.getElementById(formId);
|
||||
|
||||
// Remove previously injected inputs
|
||||
form.querySelectorAll('.dynamic-param').forEach(el => el.remove());
|
||||
|
||||
// Add values from dynamic form into the html form
|
||||
if (this.formData) {
|
||||
Object.entries(this.formData).forEach(([key, value]) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = key;
|
||||
input.value = value;
|
||||
input.classList.add('dynamic-param');
|
||||
|
||||
form.appendChild(input);
|
||||
});
|
||||
}
|
||||
|
||||
if (form && form.checkValidity()) {
|
||||
this.isSubmitting = true;
|
||||
form.submit();
|
||||
} else if (form) {
|
||||
form.reportValidity();
|
||||
}
|
||||
}
|
||||
}"
|
||||
@close="reset()"
|
||||
>
|
||||
<div class="modal-box max-w-3xl">
|
||||
<!-- Header -->
|
||||
<h3 class="text-lg font-bold">New PES</h3>
|
||||
|
||||
<!-- Close button (X) -->
|
||||
<form method="dialog">
|
||||
<button
|
||||
class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="py-4">
|
||||
<form
|
||||
id="new-pes-node-modal-form"
|
||||
accept-charset="UTF-8"
|
||||
action="{% url 'create pes node' current_object.package.uuid current_object.uuid %}"
|
||||
method="post"
|
||||
>
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="form-control mb-3">
|
||||
<label class="label" for="compound-name">
|
||||
<span class="label-text">Name</span>
|
||||
</label>
|
||||
<input
|
||||
id="compound-name"
|
||||
class="input input-bordered w-full"
|
||||
name="compound-name"
|
||||
placeholder="Name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-3">
|
||||
<label class="label" for="compound-description">
|
||||
<span class="label-text">Description</span>
|
||||
</label>
|
||||
<input
|
||||
id="compound-description"
|
||||
class="input input-bordered w-full"
|
||||
name="compound-description"
|
||||
placeholder="Description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-3">
|
||||
<label class="label" for="pes-link">
|
||||
<span class="label-text">Link to PES</span>
|
||||
</label>
|
||||
<input
|
||||
id="pes-link"
|
||||
name="pes-link"
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="Link to PES e.g. https://pesregapp-test.cropkey-np.ag/entities/PES-000126"
|
||||
x-model="pesLink"
|
||||
@input="updatePesViz()"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="pes-viz" class="mb-3" x-html="pesVizHtml"></div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="modal-action">
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
onclick="this.closest('dialog').close()"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="submit('new-pes-node-modal-form')"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
<span x-show="!isSubmitting">Submit</span>
|
||||
<span
|
||||
x-show="isSubmitting"
|
||||
class="loading loading-spinner loading-sm"
|
||||
></span>
|
||||
<span x-show="isSubmitting">Creating...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backdrop -->
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button :disabled="isSubmitting">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
19
bayer/templates/objects/compound_structure_viz.html
Normal file
19
bayer/templates/objects/compound_structure_viz.html
Normal file
@ -0,0 +1,19 @@
|
||||
{% if compound_structure.pes_link %}
|
||||
<!-- PES -->
|
||||
<div class="collapse-arrow bg-base-200 collapse">
|
||||
<input type="checkbox" checked />
|
||||
<div class="collapse-title text-xl font-medium">Link to PES</div>
|
||||
<div class="collapse-content">{{ compound_structure.pes_link }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Representation -->
|
||||
<div class="collapse-arrow bg-base-200 collapse">
|
||||
<input type="checkbox" checked />
|
||||
<div class="collapse-title text-xl font-medium">PES Image Representation</div>
|
||||
<div class="collapse-content">
|
||||
<div class="flex justify-center">
|
||||
<img src='{% url 'depict_pes' %}?pesLink={{ compound_structure.pes_link|urlencode }}'/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
19
bayer/templates/objects/compound_viz.html
Normal file
19
bayer/templates/objects/compound_viz.html
Normal file
@ -0,0 +1,19 @@
|
||||
{% if compound.default_structure.pes_link %}
|
||||
<!-- PES -->
|
||||
<div class="collapse-arrow bg-base-200 collapse">
|
||||
<input type="checkbox" checked />
|
||||
<div class="collapse-title text-xl font-medium">Link to PES</div>
|
||||
<div class="collapse-content">{{ compound.default_structure.pes_link }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Representation -->
|
||||
<div class="collapse-arrow bg-base-200 collapse">
|
||||
<input type="checkbox" checked />
|
||||
<div class="collapse-title text-xl font-medium">PES Image Representation</div>
|
||||
<div class="collapse-content">
|
||||
<div class="flex justify-center">
|
||||
<img src='{% url 'depict_pes' %}?pesLink={{ compound.default_structure.pes_link|urlencode }}'/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
19
bayer/templates/objects/node_viz.html
Normal file
19
bayer/templates/objects/node_viz.html
Normal file
@ -0,0 +1,19 @@
|
||||
{% if node.default_node_label.pes_link %}
|
||||
<!-- PES -->
|
||||
<div class="collapse-arrow bg-base-200 collapse">
|
||||
<input type="checkbox" checked />
|
||||
<div class="collapse-title text-xl font-medium">Link to PES</div>
|
||||
<div class="collapse-content">{{ node.default_node_label.pes_link }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Representation -->
|
||||
<div class="collapse-arrow bg-base-200 collapse">
|
||||
<input type="checkbox" checked />
|
||||
<div class="collapse-title text-xl font-medium">PES Image Representation</div>
|
||||
<div class="collapse-content">
|
||||
<div class="flex justify-center">
|
||||
<img src='{% url 'depict_pes' %}?pesLink={{ node.default_node_label.pes_link|urlencode }}'/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
97
bayer/templates/objects/package.html
Normal file
97
bayer/templates/objects/package.html
Normal file
@ -0,0 +1,97 @@
|
||||
{% extends "framework_modern.html" %}
|
||||
{% load static %}
|
||||
{% block content %}
|
||||
|
||||
{% block action_modals %}
|
||||
{% include "modals/objects/edit_package_modal.html" %}
|
||||
{% include "modals/objects/edit_package_permissions_modal.html" %}
|
||||
{% include "modals/objects/publish_package_modal.html" %}
|
||||
{% include "modals/objects/set_license_modal.html" %}
|
||||
{% include "modals/objects/export_package_modal.html" %}
|
||||
{% include "modals/objects/generic_delete_modal.html" %}
|
||||
{% endblock action_modals %}
|
||||
|
||||
<div class="space-y-2 p-4">
|
||||
<!-- Header Section -->
|
||||
<div class="card bg-base-100">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="card-title text-2xl">{{ package.name }} {% if meta.url_contains_package and meta.current_package.get_classification_level_display == "Restricted" %}<img src="{% static 'images/restricted_mid.png' %}" width="100">{% elif meta.url_contains_package and meta.current_package.get_classification_level_display == "Secret" %}<img src="{% static 'images/secret_mid.png' %}" width="60">{% endif %}</h2>
|
||||
<div id="actionsButton" class="dropdown dropdown-e nd hidden">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost btn-sm">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-wrench"
|
||||
>
|
||||
<path
|
||||
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
|
||||
/>
|
||||
</svg>
|
||||
Actions
|
||||
</div>
|
||||
<ul
|
||||
tabindex="-1"
|
||||
class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2"
|
||||
>
|
||||
{% block actions %}
|
||||
{% include "actions/objects/package.html" %}
|
||||
{% endblock %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2">{{ package.description|safe }}</p>
|
||||
<ul class="menu bg-base-200 rounded-box mt-4 w-full">
|
||||
<li>
|
||||
<a href="{{ package.url }}/pathway" class="hover:bg-base-300"
|
||||
>Pathways ({{ package.pathways.count }})</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ package.url }}/rule" class="hover:bg-base-300"
|
||||
>Rules ({{ package.rules.count }})</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ package.url }}/compound" class="hover:bg-base-300"
|
||||
>Compounds ({{ package.compounds.count }})</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ package.url }}/reaction" class="hover:bg-base-300"
|
||||
>Reactions ({{ package.reactions.count }})</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ package.url }}/model" class="hover:bg-base-300"
|
||||
>Models ({{ package.models.count }})</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ package.url }}/scenario" class="hover:bg-base-300"
|
||||
>Scenarios ({{ package.scenarios.count }})</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Show actions button if there are actions
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const actionsButton = document.getElementById("actionsButton");
|
||||
const actionsList = actionsButton?.querySelector("ul");
|
||||
if (actionsList && actionsList.children.length > 0) {
|
||||
actionsButton?.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock content %}
|
||||
154
bayer/templates/static/login.html
Normal file
154
bayer/templates/static/login.html
Normal file
@ -0,0 +1,154 @@
|
||||
{% extends "static/login_base.html" %}
|
||||
{% load static %}
|
||||
{% block title %}enviPath - Sign In{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
<style>
|
||||
/* Tab styling */
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
input[type="radio"].tab-radio {
|
||||
display: none;
|
||||
}
|
||||
.tab-label {
|
||||
cursor: pointer;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.tab-label:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
input[type="radio"].tab-radio:checked + .tab-label {
|
||||
border-bottom-color: #3b82f6;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div>
|
||||
<img src="{% static 'images/bayer-logo.svg' %}">
|
||||
|
||||
</div>
|
||||
<div class="flex flex-col space-y-4 ...">
|
||||
<div><p></p></div>
|
||||
<div><p></p></div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="border-base-300 mb-6 border-b" hidden>
|
||||
<div class="flex justify-start">
|
||||
<input
|
||||
type="radio"
|
||||
name="auth-tab"
|
||||
id="tab-sso"
|
||||
class="tab-radio"
|
||||
checked
|
||||
/>
|
||||
<label for="tab-sso" class="tab-label">SSO</label>
|
||||
|
||||
<input
|
||||
type="radio"
|
||||
name="auth-tab"
|
||||
id="tab-signin"
|
||||
class="tab-radio"
|
||||
/>
|
||||
<label for="tab-signin" class="tab-label">Local User</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSO Tab -->
|
||||
<div id="content-sso" class="tab-content active">
|
||||
<button role="link" onclick="window.location.href='/entra/login'" name="sso" class="btn btn-primary w-full">
|
||||
Login with Microsoft
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Sign In Tab -->
|
||||
<div id="content-signin" class="tab-content">
|
||||
<form method="post" action="{% url 'login' %}" class="space-y-4">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="login" value="true" />
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="username">
|
||||
<span class="label-text">Account</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
placeholder="Username or Email"
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
autocomplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="passwordinput">
|
||||
<span class="label-text">Password</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="passwordinput"
|
||||
name="password"
|
||||
placeholder="••••••••"
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="text-right">
|
||||
<a href="{% url 'password_reset' %}" class="link link-primary text-sm"
|
||||
>Forgot password?</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="next" value="{{ next }}" />
|
||||
|
||||
<button type="submit" name="signin" class="btn btn-primary w-full">
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
// Tab switching functionality
|
||||
document.querySelectorAll('input[name="auth-tab"]').forEach((radio) => {
|
||||
radio.addEventListener("change", function () {
|
||||
// Hide all content
|
||||
document.querySelectorAll(".tab-content").forEach((content) => {
|
||||
content.classList.remove("active");
|
||||
});
|
||||
|
||||
// Show selected content
|
||||
const contentId = "content-" + this.id.replace("tab-", "");
|
||||
document.getElementById(contentId).classList.add("active");
|
||||
});
|
||||
});
|
||||
|
||||
// Check for hash in URL to auto-select tab
|
||||
window.addEventListener("DOMContentLoaded", function () {
|
||||
const hash = window.location.hash.substring(1); // Remove the # symbol
|
||||
if (hash === "signup" || hash === "signin") {
|
||||
const tabRadio = document.getElementById("tab-" + hash);
|
||||
if (tabRadio) {
|
||||
tabRadio.checked = true;
|
||||
// Trigger change event to show correct content
|
||||
tabRadio.dispatchEvent(new Event("change"));
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
3
bayer/tests.py
Normal file
3
bayer/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
0
bayer/tests/__init__.py
Normal file
0
bayer/tests/__init__.py
Normal file
0
bayer/tests/pes/__init__.py
Normal file
0
bayer/tests/pes/__init__.py
Normal file
174
bayer/tests/pes/test_pes.py
Normal file
174
bayer/tests/pes/test_pes.py
Normal file
File diff suppressed because one or more lines are too long
19
bayer/urls.py
Normal file
19
bayer/urls.py
Normal file
@ -0,0 +1,19 @@
|
||||
from django.urls import re_path
|
||||
|
||||
from . import views as v
|
||||
|
||||
UUID = "[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}"
|
||||
|
||||
urlpatterns = [
|
||||
re_path(r"^depict_pes$", v.visualize_pes, name="depict_pes"),
|
||||
re_path(
|
||||
rf"^package/(?P<package_uuid>{UUID})/pes$",
|
||||
v.create_pes,
|
||||
name="create pes",
|
||||
),
|
||||
re_path(
|
||||
rf"^package/(?P<package_uuid>{UUID})/pathway/(?P<pathway_uuid>{UUID})/pes$",
|
||||
v.create_pes_node,
|
||||
name="create pes node",
|
||||
),
|
||||
]
|
||||
155
bayer/views.py
Normal file
155
bayer/views.py
Normal file
@ -0,0 +1,155 @@
|
||||
import base64
|
||||
|
||||
import requests
|
||||
from django.conf import settings as s
|
||||
from django.core.exceptions import BadRequest
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
|
||||
from bayer.models import PESCompound
|
||||
from epdb.logic import PackageManager
|
||||
from epdb.models import Pathway, Node
|
||||
from epdb.views import _anonymous_or_real
|
||||
from utilities.decorators import package_permission_required
|
||||
|
||||
Package = s.GET_PACKAGE_MODEL()
|
||||
|
||||
|
||||
@package_permission_required()
|
||||
def create_pes(request, package_uuid):
|
||||
current_user = _anonymous_or_real(request)
|
||||
current_package = PackageManager.get_package_by_id(current_user, package_uuid)
|
||||
|
||||
if request.method == "POST":
|
||||
|
||||
if current_package.classification_level == Package.Classification.INTERNAL:
|
||||
raise BadRequest("Cannot create PESs for internal packages.")
|
||||
|
||||
compound_name = request.POST.get('compound-name')
|
||||
compound_description = request.POST.get('compound-description')
|
||||
pes_link = request.POST.get('pes-link')
|
||||
|
||||
if pes_link:
|
||||
try:
|
||||
pes_data = fetch_pes(request, pes_link)
|
||||
except ValueError as e:
|
||||
return BadRequest(f"Could not fetch PES data for {pes_link}")
|
||||
|
||||
classification = pes_data.get("classificationLevel", "")
|
||||
if "secret" == classification.lower():
|
||||
data_pools = pes_data.get("dataPools")
|
||||
if data_pools:
|
||||
if s.DATA_POOL_MAPPING[current_package.data_pool.name] not in data_pools:
|
||||
return BadRequest(
|
||||
f"PES data pool {s.DATA_POOL_MAPPING[current_package.data_pool.name]} not found in PES data")
|
||||
|
||||
pes = PESCompound.create(current_package, pes_data, compound_name, compound_description)
|
||||
|
||||
return redirect(pes.url)
|
||||
else:
|
||||
return BadRequest("Please provide a PES link.")
|
||||
else:
|
||||
pass
|
||||
|
||||
|
||||
@package_permission_required()
|
||||
def create_pes_node(request, package_uuid, pathway_uuid):
|
||||
current_user = _anonymous_or_real(request)
|
||||
current_package = PackageManager.get_package_by_id(current_user, package_uuid)
|
||||
current_pathway = Pathway.objects.get(package=current_package, uuid=pathway_uuid)
|
||||
|
||||
if request.method == "POST":
|
||||
|
||||
if current_package.classification_level == Package.Classification.INTERNAL:
|
||||
raise BadRequest("Cannot create PESs for internal packages.")
|
||||
|
||||
compound_name = request.POST.get('compound-name')
|
||||
compound_description = request.POST.get('compound-description')
|
||||
pes_link = request.POST.get('pes-link')
|
||||
|
||||
if pes_link:
|
||||
try:
|
||||
pes_data = fetch_pes(request, pes_link)
|
||||
except ValueError as e:
|
||||
return BadRequest(f"Could not fetch PES data for {pes_link}")
|
||||
|
||||
classification = pes_data.get("classificationLevel", "")
|
||||
if "secret" == classification.lower():
|
||||
data_pools = pes_data.get("dataPools")
|
||||
if data_pools:
|
||||
if s.DATA_POOL_MAPPING[current_package.data_pool.name] not in data_pools:
|
||||
return BadRequest(
|
||||
f"PES data pool {s.DATA_POOL_MAPPING[current_package.data_pool.name]} not found in PES data")
|
||||
|
||||
pes = PESCompound.create(current_package, pes_data, compound_name, compound_description)
|
||||
|
||||
n = Node()
|
||||
n.stereo_removed = False
|
||||
n.pathway = current_pathway
|
||||
n.depth = 0
|
||||
|
||||
n.default_node_label = pes.default_structure
|
||||
n.save()
|
||||
|
||||
n.node_labels.add(pes.default_structure)
|
||||
n.save()
|
||||
|
||||
return redirect(current_pathway.url)
|
||||
|
||||
else:
|
||||
return BadRequest("Please provide a PES link.")
|
||||
else:
|
||||
pass
|
||||
|
||||
|
||||
def fetch_pes(request, pes_url) -> dict:
|
||||
from epauth.views import get_access_token_from_request
|
||||
token = get_access_token_from_request(request)
|
||||
|
||||
if token:
|
||||
for k, v in s.PES_API_MAPPING.items():
|
||||
if pes_url.startswith(k):
|
||||
pes_id = pes_url.split('/')[-1]
|
||||
|
||||
if pes_id == 'dummy':
|
||||
import json
|
||||
res_data = json.load(open(s.BASE_DIR / "fixtures/pes.json"))
|
||||
res_data["pes_url"] = pes_url
|
||||
return res_data
|
||||
else:
|
||||
headers = {"Authorization": f"Bearer {token['access_token']}"}
|
||||
params = {"pes_reg_entity_corporate_id": pes_id}
|
||||
|
||||
res = requests.get(v, headers=headers, params=params, proxies=s.PROXIES or None)
|
||||
|
||||
try:
|
||||
res.raise_for_status()
|
||||
pes_data = res.json()
|
||||
|
||||
if len(pes_data) == 0:
|
||||
raise ValueError(f"PES with id {pes_id} not found")
|
||||
|
||||
res_data = pes_data[0]
|
||||
res_data["pes_url"] = pes_url
|
||||
return res_data
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
raise ValueError(f"Error fetching PES with id {pes_id}: {e}")
|
||||
else:
|
||||
raise ValueError(f"Unknown URL {pes_url}")
|
||||
else:
|
||||
raise ValueError("Could not fetch access token from request.")
|
||||
|
||||
|
||||
def visualize_pes(request):
|
||||
pes_link = request.GET.get('pesLink')
|
||||
|
||||
if pes_link:
|
||||
pes_data = fetch_pes(request, pes_link)
|
||||
|
||||
representations = pes_data.get('representations')
|
||||
|
||||
for rep in representations:
|
||||
if rep.get('type') == 'color':
|
||||
image_data = base64.b64decode(rep.get('base64').replace("data:image/png;base64,", ""))
|
||||
return HttpResponse(image_data, content_type="image/png")
|
||||
183
bb4g/__init__.py
Normal file
183
bb4g/__init__.py
Normal file
@ -0,0 +1,183 @@
|
||||
import json
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
import enum
|
||||
import requests
|
||||
from django.conf import settings as s
|
||||
from envipy_additional_information import EnviPyModel, UIConfig, WidgetType
|
||||
from envipy_additional_information import register
|
||||
|
||||
from bridge.contracts import Classifier # noqa: I001
|
||||
from bridge.dto import (
|
||||
BuildResult,
|
||||
EnviPyDTO,
|
||||
EvaluationResult,
|
||||
RunResult,
|
||||
TransformationProductPrediction,
|
||||
) # noqa: I001
|
||||
|
||||
class SamplingAlgorithm(enum.Enum):
|
||||
EXACT = "exact"
|
||||
|
||||
|
||||
@register("bb4gconfig")
|
||||
class BB4GConfig(EnviPyModel):
|
||||
sampling_algorithm: SamplingAlgorithm = SamplingAlgorithm.EXACT
|
||||
cutoff: int = -5
|
||||
|
||||
class UI:
|
||||
title = "BB4G Configuration"
|
||||
sampling_algorithm = UIConfig(
|
||||
widget=WidgetType.SELECT,
|
||||
label="BB4G Sampling Algorithm",
|
||||
order=1,
|
||||
placeholder="If unset defaults to 'exact'"
|
||||
)
|
||||
cutoff = UIConfig(
|
||||
widget=WidgetType.NUMBER,
|
||||
label="BB4G Cutoff",
|
||||
order=2,
|
||||
placeholder="If unset defaults to -5"
|
||||
)
|
||||
|
||||
|
||||
# Once stable these will be exposed by enviPy-plugins lib
|
||||
class BB4G(Classifier):
|
||||
Config = BB4GConfig
|
||||
|
||||
def __init__(self, config: BB4GConfig | None = None):
|
||||
super().__init__(config)
|
||||
self.url = f"{s.BB4G_URL}"
|
||||
|
||||
self.token = self.acquire_token()
|
||||
self.header = {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def acquire_token(self):
|
||||
BB4G_TENANT_ID = s.BB4G_TENANT_ID
|
||||
BB4G_CLIENT_ID = s.BB4G_CLIENT_ID
|
||||
BB4G_CLIENT_SECRET = s.BB4G_CLIENT_SECRET
|
||||
BB4G_SCOPE = s.BB4G_SCOPE
|
||||
|
||||
BB4G_TOKEN_URL = f"https://login.microsoftonline.com/{BB4G_TENANT_ID}/oauth2/v2.0/token"
|
||||
|
||||
payload = {
|
||||
"client_id": BB4G_CLIENT_ID,
|
||||
"client_secret": BB4G_CLIENT_SECRET,
|
||||
"scope": BB4G_SCOPE,
|
||||
"grant_type": "client_credentials"
|
||||
}
|
||||
|
||||
# No Proxy required, URL is whitelisted
|
||||
res = requests.post(BB4G_TOKEN_URL, data=payload)
|
||||
|
||||
res.raise_for_status()
|
||||
|
||||
return res.json()["access_token"]
|
||||
|
||||
def start(self):
|
||||
header = {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
started = False
|
||||
retries = 0
|
||||
while not started and retries < 5:
|
||||
res = requests.post(f"{self.url}/start", headers=header, data={}, proxies=s.PROXIES or None)
|
||||
|
||||
if res.status_code == 200:
|
||||
started = True
|
||||
elif res.status_code in [500, 502]:
|
||||
retries += 1
|
||||
import time
|
||||
time.sleep(5)
|
||||
else:
|
||||
raise ValueError(f"Unexpected status code: {res.status_code}")
|
||||
|
||||
@classmethod
|
||||
def requires_rule_packages(cls) -> bool:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def requires_data_packages(cls) -> bool:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def identifier(cls) -> str:
|
||||
return "bb4g"
|
||||
|
||||
@classmethod
|
||||
def name(cls) -> str:
|
||||
return "BB4G Template Free Model"
|
||||
|
||||
@classmethod
|
||||
def display(cls) -> str:
|
||||
return "BB4G Template Free Model"
|
||||
|
||||
def build(self, eP: EnviPyDTO, *args, **kwargs) -> BuildResult | None:
|
||||
return
|
||||
|
||||
def run(self, eP: EnviPyDTO, *args, **kwargs) -> RunResult:
|
||||
|
||||
# Ensure Service is running
|
||||
self.start()
|
||||
|
||||
smiles = [c.smiles for c in eP.get_compounds()]
|
||||
preds = self._post(smiles)
|
||||
|
||||
results = []
|
||||
|
||||
for substrate in preds.keys():
|
||||
results.append(
|
||||
TransformationProductPrediction(
|
||||
substrate=substrate,
|
||||
products=preds[substrate],
|
||||
)
|
||||
)
|
||||
|
||||
return RunResult(
|
||||
producer=eP.get_context().url,
|
||||
description=f"Generated at {datetime.now()}",
|
||||
result=results,
|
||||
)
|
||||
|
||||
def evaluate(self, eP: EnviPyDTO, *args, **kwargs) -> EvaluationResult:
|
||||
pass
|
||||
|
||||
def build_and_evaluate(self, eP: EnviPyDTO, *args, **kwargs) -> EvaluationResult:
|
||||
pass
|
||||
|
||||
def _post(self, smiles: List[str]) -> dict[str, dict[str, float]]:
|
||||
header = {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
result = {}
|
||||
|
||||
for smi in smiles:
|
||||
data = {
|
||||
"smiles": smi,
|
||||
"sampling_alg": self.config.sampling_algorithm.value,
|
||||
"cutoff": self.config.cutoff,
|
||||
}
|
||||
|
||||
resp = requests.post(f"{self.url}/compute", headers=header, data=json.dumps(data), proxies=s.PROXIES or None)
|
||||
|
||||
resp.raise_for_status()
|
||||
|
||||
for substrate, predictions in resp.json().items():
|
||||
preds = {}
|
||||
|
||||
for pred in predictions:
|
||||
prod = pred["prediction"]
|
||||
prob = math.exp(pred["log_likelihood"])
|
||||
preds[prod] = prob
|
||||
|
||||
result[substrate] = preds
|
||||
|
||||
return result
|
||||
112
biotransformer/__init__.py
Normal file
112
biotransformer/__init__.py
Normal file
@ -0,0 +1,112 @@
|
||||
import enum
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
|
||||
import requests
|
||||
from django.conf import settings as s
|
||||
|
||||
# Once stable these will be exposed by enviPy-plugins lib
|
||||
from envipy_additional_information import EnviPyModel, UIConfig, WidgetType # noqa: I001
|
||||
from envipy_additional_information import register # noqa: I001
|
||||
|
||||
from bridge.contracts import Classifier # noqa: I001
|
||||
from bridge.dto import (
|
||||
BuildResult,
|
||||
EnviPyDTO,
|
||||
EvaluationResult,
|
||||
RunResult,
|
||||
TransformationProductPrediction,
|
||||
) # noqa: I001
|
||||
|
||||
|
||||
class BiotransformerEnvType(enum.Enum):
|
||||
CYP450 = "CYP450"
|
||||
ALLHUMAN = "ALLHUMAN"
|
||||
ECBASED = "ECBASED"
|
||||
HGUT = "HGUT"
|
||||
PHASEII = "PHASEII"
|
||||
ENV = "ENV"
|
||||
|
||||
|
||||
@register("biotransformerconfig")
|
||||
class BiotransformerConfig(EnviPyModel):
|
||||
env_type: BiotransformerEnvType
|
||||
|
||||
class UI:
|
||||
title = "Biotransformer Type"
|
||||
env_type = UIConfig(widget=WidgetType.SELECT, label="Biotransformer Type", order=1)
|
||||
|
||||
|
||||
class Biotransformer(Classifier):
|
||||
Config = BiotransformerConfig
|
||||
|
||||
def __init__(self, config: BiotransformerConfig | None = None):
|
||||
super().__init__(config)
|
||||
self.url = f"{s.BIOTRANSFORMER_URL}/biotransformer"
|
||||
|
||||
@classmethod
|
||||
def requires_rule_packages(cls) -> bool:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def requires_data_packages(cls) -> bool:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def identifier(cls) -> str:
|
||||
return "biotransformer3"
|
||||
|
||||
@classmethod
|
||||
def name(cls) -> str:
|
||||
return "Biotransformer 3.0"
|
||||
|
||||
@classmethod
|
||||
def display(cls) -> str:
|
||||
return "Biotransformer 3.0"
|
||||
|
||||
def build(self, eP: EnviPyDTO, *args, **kwargs) -> BuildResult | None:
|
||||
return
|
||||
|
||||
def run(self, eP: EnviPyDTO, *args, **kwargs) -> RunResult:
|
||||
smiles = [c.smiles for c in eP.get_compounds()]
|
||||
preds = self._post(smiles)
|
||||
|
||||
results = []
|
||||
|
||||
for substrate in preds.keys():
|
||||
results.append(
|
||||
TransformationProductPrediction(
|
||||
substrate=substrate,
|
||||
products=preds[substrate],
|
||||
)
|
||||
)
|
||||
|
||||
return RunResult(
|
||||
producer=eP.get_context().url,
|
||||
description=f"Generated at {datetime.now()}",
|
||||
result=results,
|
||||
)
|
||||
|
||||
def evaluate(self, eP: EnviPyDTO, *args, **kwargs) -> EvaluationResult:
|
||||
pass
|
||||
|
||||
def build_and_evaluate(self, eP: EnviPyDTO, *args, **kwargs) -> EvaluationResult:
|
||||
pass
|
||||
|
||||
def _post(self, smiles: List[str]) -> dict[str, dict[str, float]]:
|
||||
data = {"substrates": smiles, "mode": self.config.env_type.value}
|
||||
res = requests.post(self.url, json=data)
|
||||
|
||||
res.raise_for_status()
|
||||
|
||||
# Example Response JSON:
|
||||
# {
|
||||
# 'products': {
|
||||
# 'CN1C=NC2=C1C(=O)N(C(=O)N2C)C': {
|
||||
# 'CN1C2=C(C(=O)N(C)C1=O)NC=N2': 0.5,
|
||||
# 'CN1C=NC2=C1C(=O)N(C)C(=O)N2.CN1C=NC2=C1C(=O)NC(=O)N2C.CO': 0.5
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
|
||||
return res.json()["products"]
|
||||
0
bridge/__init__.py
Normal file
0
bridge/__init__.py
Normal file
408
bridge/contracts.py
Normal file
408
bridge/contracts.py
Normal file
@ -0,0 +1,408 @@
|
||||
import enum
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from envipy_additional_information import EnviPyModel
|
||||
|
||||
from .dto import BuildResult, EnviPyDTO, EvaluationResult, RunResult
|
||||
|
||||
|
||||
class PropertyType(enum.Enum):
|
||||
"""
|
||||
Enumeration representing different types of properties.
|
||||
|
||||
PropertyType is an Enum class that defines categories or types of properties
|
||||
based on their weight or nature. It can typically be used when classifying
|
||||
objects or entities by their weight classification, such as lightweight or heavy.
|
||||
"""
|
||||
|
||||
LIGHTWEIGHT = "lightweight"
|
||||
HEAVY = "heavy"
|
||||
|
||||
|
||||
class Plugin(ABC):
|
||||
"""
|
||||
Defines an abstract base class Plugin to serve as a blueprint for plugins.
|
||||
|
||||
This class establishes the structure that all plugin implementations must
|
||||
follow. It enforces the presence of required methods to ensure consistent
|
||||
functionality across all derived classes.
|
||||
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def identifier(cls) -> str:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def name(cls) -> str:
|
||||
"""
|
||||
Represents an abstract method that provides a contract for implementing a method
|
||||
to return a name as a string. Must be implemented in subclasses.
|
||||
Name must be unique across all plugins.
|
||||
|
||||
Methods
|
||||
-------
|
||||
name() -> str
|
||||
Abstract method to be defined in subclasses, which returns a string
|
||||
representing a name.
|
||||
"""
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def display(cls) -> str:
|
||||
"""
|
||||
An abstract method that must be implemented by subclasses to display
|
||||
specific information or behavior. The method ensures that all subclasses
|
||||
provide their own implementation of the display functionality.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Raises this error when the method is not implemented
|
||||
in a subclass.
|
||||
|
||||
Returns:
|
||||
str: A string used in dropdown menus or other user interfaces to display
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class Property(Plugin):
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def requires_rule_packages(cls) -> bool:
|
||||
"""
|
||||
Defines an abstract method to determine whether rule packages are required.
|
||||
|
||||
This method should be implemented by subclasses to specify if they depend
|
||||
on rule packages for their functioning.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If the subclass has not implemented this method.
|
||||
|
||||
@return: A boolean indicating if rule packages are required.
|
||||
"""
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def requires_data_packages(cls) -> bool:
|
||||
"""
|
||||
Defines an abstract method to determine whether data packages are required.
|
||||
|
||||
This method should be implemented by subclasses to specify if they depend
|
||||
on data packages for their functioning.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If the subclass has not implemented this method.
|
||||
|
||||
Returns:
|
||||
bool: True if the service requires data packages, False otherwise.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_type(self) -> PropertyType:
|
||||
"""
|
||||
An abstract method that provides the type of property. This method must
|
||||
be implemented by subclasses to specify the appropriate property type.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If the method is not implemented by a subclass.
|
||||
|
||||
Returns:
|
||||
PropertyType: The type of the property associated with the implementation.
|
||||
"""
|
||||
pass
|
||||
|
||||
def is_heavy(self):
|
||||
"""
|
||||
Determines if the current property type is heavy.
|
||||
|
||||
This method evaluates whether the property type returned from the `get_type()`
|
||||
method is classified as `HEAVY`. It utilizes the `PropertyType.HEAVY` constant
|
||||
for this comparison.
|
||||
|
||||
Raises:
|
||||
AttributeError: If the `get_type()` method is not defined or does not return
|
||||
a valid value.
|
||||
|
||||
Returns:
|
||||
bool: True if the property type is `HEAVY`, otherwise False.
|
||||
"""
|
||||
return self.get_type() == PropertyType.HEAVY
|
||||
|
||||
@abstractmethod
|
||||
def build(self, eP: EnviPyDTO, *args, **kwargs) -> BuildResult | None:
|
||||
"""
|
||||
Abstract method to prepare and construct a specific build process based on the provided
|
||||
environment data transfer object (EnviPyDTO). This method should be implemented by
|
||||
subclasses to handle the particular requirements of the environment.
|
||||
|
||||
Parameters:
|
||||
eP : EnviPyDTO
|
||||
The data transfer object containing environment details for the build process.
|
||||
|
||||
*args :
|
||||
Additional positional arguments required for the build.
|
||||
|
||||
**kwargs :
|
||||
Additional keyword arguments to offer flexibility and customization for
|
||||
the build process.
|
||||
|
||||
Returns:
|
||||
BuildResult | None
|
||||
Returns a BuildResult instance if the build operation succeeds, else returns None.
|
||||
|
||||
Raises:
|
||||
NotImplementedError
|
||||
If the method is not implemented in a subclass.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def run(self, eP: EnviPyDTO, *args, **kwargs) -> RunResult:
|
||||
"""
|
||||
Represents an abstract base class for executing a generic process with
|
||||
provided parameters and returning a standardized result.
|
||||
|
||||
Attributes:
|
||||
None.
|
||||
|
||||
Methods:
|
||||
run(eP, *args, **kwargs):
|
||||
Executes a task with specified input parameters and optional
|
||||
arguments, returning the outcome in the form of a RunResult object.
|
||||
This is an abstract method and must be implemented in subclasses.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If the subclass does not implement the abstract
|
||||
method.
|
||||
|
||||
Parameters:
|
||||
eP (EnviPyDTO): The primary object containing information or data required
|
||||
for processing. Mandatory.
|
||||
*args: Variable length argument list for additional positional arguments.
|
||||
**kwargs: Arbitrary keyword arguments for additional options or settings.
|
||||
|
||||
Returns:
|
||||
RunResult: A result object encapsulating the status, output, or details
|
||||
of the process execution.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def evaluate(self, eP: EnviPyDTO, *args, **kwargs) -> EvaluationResult:
|
||||
"""
|
||||
Abstract method for evaluating data based on the given input and additional arguments.
|
||||
|
||||
This method is intended to be implemented by subclasses and provides
|
||||
a mechanism to perform an evaluation procedure based on input encapsulated
|
||||
in an EnviPyDTO object.
|
||||
|
||||
Parameters:
|
||||
eP : EnviPyDTO
|
||||
The data transfer object containing necessary input for evaluation.
|
||||
*args : tuple
|
||||
Additional positional arguments for the evaluation process.
|
||||
**kwargs : dict
|
||||
Additional keyword arguments for the evaluation process.
|
||||
|
||||
Returns:
|
||||
EvaluationResult
|
||||
The result of the evaluation performed by the method.
|
||||
|
||||
Raises:
|
||||
NotImplementedError
|
||||
If the method is not implemented in the subclass.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def build_and_evaluate(self, eP: EnviPyDTO, *args, **kwargs) -> EvaluationResult:
|
||||
"""
|
||||
An abstract method designed to build and evaluate a model or system using the provided
|
||||
environmental parameters and additional optional arguments.
|
||||
|
||||
Args:
|
||||
eP (EnviPyDTO): The environmental parameters required for building and evaluating.
|
||||
*args: Additional positional arguments.
|
||||
**kwargs: Additional keyword arguments.
|
||||
|
||||
Returns:
|
||||
EvaluationResult: The result of the evaluation process.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If the method is not implemented by a subclass.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class Classifier(Plugin):
|
||||
Config: type[EnviPyModel] | None = None
|
||||
|
||||
def __init__(self, config: EnviPyModel | None = None):
|
||||
self.config = config
|
||||
|
||||
@classmethod
|
||||
def has_config(cls) -> bool:
|
||||
return cls.Config is not None
|
||||
|
||||
@classmethod
|
||||
def parse_config(cls, data: dict | None = None) -> EnviPyModel | None:
|
||||
if cls.Config is None:
|
||||
return None
|
||||
|
||||
# remove empty strings a.k.a unset params to not overwrite defaults
|
||||
cpy = {}
|
||||
if data is not None:
|
||||
for k, v in data.items():
|
||||
if v != "":
|
||||
cpy[k] = v
|
||||
|
||||
return cls.Config(**cpy)
|
||||
|
||||
@classmethod
|
||||
def create(cls, data: dict | None = None):
|
||||
return cls(cls.parse_config(data))
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def requires_rule_packages(cls) -> bool:
|
||||
"""
|
||||
Defines an abstract method to determine whether rule packages are required.
|
||||
|
||||
This method should be implemented by subclasses to specify if they depend
|
||||
on rule packages for their functioning.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If the subclass has not implemented this method.
|
||||
|
||||
@return: A boolean indicating if rule packages are required.
|
||||
"""
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def requires_data_packages(cls) -> bool:
|
||||
"""
|
||||
Defines an abstract method to determine whether data packages are required.
|
||||
|
||||
This method should be implemented by subclasses to specify if they depend
|
||||
on data packages for their functioning.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If the subclass has not implemented this method.
|
||||
|
||||
Returns:
|
||||
bool: True if the service requires data packages, False otherwise.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def build(self, eP: EnviPyDTO, *args, **kwargs) -> BuildResult | None:
|
||||
"""
|
||||
Abstract method to prepare and construct a specific build process based on the provided
|
||||
environment data transfer object (EnviPyDTO). This method should be implemented by
|
||||
subclasses to handle the particular requirements of the environment.
|
||||
|
||||
Parameters:
|
||||
eP : EnviPyDTO
|
||||
The data transfer object containing environment details for the build process.
|
||||
|
||||
*args :
|
||||
Additional positional arguments required for the build.
|
||||
|
||||
**kwargs :
|
||||
Additional keyword arguments to offer flexibility and customization for
|
||||
the build process.
|
||||
|
||||
Returns:
|
||||
BuildResult | None
|
||||
Returns a BuildResult instance if the build operation succeeds, else returns None.
|
||||
|
||||
Raises:
|
||||
NotImplementedError
|
||||
If the method is not implemented in a subclass.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def run(self, eP: EnviPyDTO, *args, **kwargs) -> RunResult:
|
||||
"""
|
||||
Represents an abstract base class for executing a generic process with
|
||||
provided parameters and returning a standardized result.
|
||||
|
||||
Attributes:
|
||||
None.
|
||||
|
||||
Methods:
|
||||
run(eP, *args, **kwargs):
|
||||
Executes a task with specified input parameters and optional
|
||||
arguments, returning the outcome in the form of a RunResult object.
|
||||
This is an abstract method and must be implemented in subclasses.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If the subclass does not implement the abstract
|
||||
method.
|
||||
|
||||
Parameters:
|
||||
eP (EnviPyDTO): The primary object containing information or data required
|
||||
for processing. Mandatory.
|
||||
*args: Variable length argument list for additional positional arguments.
|
||||
**kwargs: Arbitrary keyword arguments for additional options or settings.
|
||||
|
||||
Returns:
|
||||
RunResult: A result object encapsulating the status, output, or details
|
||||
of the process execution.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def evaluate(self, eP: EnviPyDTO, *args, **kwargs) -> EvaluationResult | None:
|
||||
"""
|
||||
Abstract method for evaluating data based on the given input and additional arguments.
|
||||
|
||||
This method is intended to be implemented by subclasses and provides
|
||||
a mechanism to perform an evaluation procedure based on input encapsulated
|
||||
in an EnviPyDTO object.
|
||||
|
||||
Parameters:
|
||||
eP : EnviPyDTO
|
||||
The data transfer object containing necessary input for evaluation.
|
||||
*args : tuple
|
||||
Additional positional arguments for the evaluation process.
|
||||
**kwargs : dict
|
||||
Additional keyword arguments for the evaluation process.
|
||||
|
||||
Returns:
|
||||
EvaluationResult
|
||||
The result of the evaluation performed by the method.
|
||||
|
||||
Raises:
|
||||
NotImplementedError
|
||||
If the method is not implemented in the subclass.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def build_and_evaluate(self, eP: EnviPyDTO, *args, **kwargs) -> EvaluationResult | None:
|
||||
"""
|
||||
An abstract method designed to build and evaluate a model or system using the provided
|
||||
environmental parameters and additional optional arguments.
|
||||
|
||||
Args:
|
||||
eP (EnviPyDTO): The environmental parameters required for building and evaluating.
|
||||
*args: Additional positional arguments.
|
||||
**kwargs: Additional keyword arguments.
|
||||
|
||||
Returns:
|
||||
EvaluationResult: The result of the evaluation process.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If the method is not implemented by a subclass.
|
||||
"""
|
||||
pass
|
||||
149
bridge/dto.py
Normal file
149
bridge/dto.py
Normal file
@ -0,0 +1,149 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, List, Optional, Protocol
|
||||
|
||||
from envipy_additional_information import EnviPyModel, register
|
||||
from pydantic import HttpUrl
|
||||
|
||||
from utilities.chem import FormatConverter, ProductSet
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Context:
|
||||
uuid: str
|
||||
url: str
|
||||
work_dir: str
|
||||
|
||||
|
||||
class CompoundProto(Protocol):
|
||||
url: str | None
|
||||
name: str | None
|
||||
smiles: str
|
||||
|
||||
|
||||
class RuleProto(Protocol):
|
||||
url: str
|
||||
name: str
|
||||
|
||||
def apply(self, smiles, *args, **kwargs): ...
|
||||
|
||||
|
||||
class ReactionProto(Protocol):
|
||||
url: str
|
||||
name: str
|
||||
rules: List[RuleProto]
|
||||
|
||||
|
||||
class EnviPyDTO(Protocol):
|
||||
def get_context(self) -> Context: ...
|
||||
|
||||
def get_compounds(self) -> List[CompoundProto]: ...
|
||||
|
||||
def get_reactions(self) -> List[ReactionProto]: ...
|
||||
|
||||
def get_rules(self) -> List[RuleProto]: ...
|
||||
|
||||
@staticmethod
|
||||
def standardize(smiles, remove_stereo=False, canonicalize_tautomers=False): ...
|
||||
|
||||
@staticmethod
|
||||
def apply(
|
||||
smiles: str,
|
||||
smirks: str,
|
||||
preprocess_smiles: bool = True,
|
||||
bracketize: bool = True,
|
||||
standardize: bool = True,
|
||||
kekulize: bool = True,
|
||||
remove_stereo: bool = True,
|
||||
reactant_filter_smarts: str | None = None,
|
||||
product_filter_smarts: str | None = None,
|
||||
) -> List["ProductSet"]: ...
|
||||
|
||||
|
||||
class EnviPyPrediction(EnviPyModel):
|
||||
pass
|
||||
|
||||
|
||||
class PropertyPrediction(EnviPyPrediction):
|
||||
pass
|
||||
|
||||
|
||||
class TransformationProductPrediction(EnviPyPrediction):
|
||||
substrate: str
|
||||
products: dict[str, float]
|
||||
|
||||
|
||||
@register("buildresult")
|
||||
class BuildResult(EnviPyModel):
|
||||
data: dict[str, Any] | List[dict[str, Any]] | None
|
||||
|
||||
|
||||
@register("runresult")
|
||||
class RunResult(EnviPyModel):
|
||||
producer: HttpUrl
|
||||
description: Optional[str] = None
|
||||
result: EnviPyPrediction | List[EnviPyPrediction]
|
||||
|
||||
|
||||
@register("evaluationresult")
|
||||
class EvaluationResult(EnviPyModel):
|
||||
data: dict[str, Any] | List[dict[str, Any]] | None
|
||||
|
||||
|
||||
class BaseDTO(EnviPyDTO):
|
||||
def __init__(
|
||||
self,
|
||||
uuid: str,
|
||||
url: str,
|
||||
work_dir: str,
|
||||
compounds: List[CompoundProto],
|
||||
reactions: List[ReactionProto],
|
||||
rules: List[RuleProto],
|
||||
):
|
||||
self.uuid = uuid
|
||||
self.url = url
|
||||
self.work_dir = work_dir
|
||||
self.compounds = compounds
|
||||
self.reactions = reactions
|
||||
self.rules = rules
|
||||
|
||||
def get_context(self) -> Context:
|
||||
return Context(uuid=self.uuid, url=self.url, work_dir=self.work_dir)
|
||||
|
||||
def get_compounds(self) -> List[CompoundProto]:
|
||||
return self.compounds
|
||||
|
||||
def get_reactions(self) -> List[ReactionProto]:
|
||||
return self.reactions
|
||||
|
||||
def get_rules(self) -> List[RuleProto]:
|
||||
return self.rules
|
||||
|
||||
@staticmethod
|
||||
def standardize(smiles, remove_stereo=False, canonicalize_tautomers=False):
|
||||
return FormatConverter.standardize(
|
||||
smiles, remove_stereo=remove_stereo, canonicalize_tautomers=canonicalize_tautomers
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def apply(
|
||||
smiles: str,
|
||||
smirks: str,
|
||||
preprocess_smiles: bool = True,
|
||||
bracketize: bool = True,
|
||||
standardize: bool = True,
|
||||
kekulize: bool = True,
|
||||
remove_stereo: bool = True,
|
||||
reactant_filter_smarts: str | None = None,
|
||||
product_filter_smarts: str | None = None,
|
||||
) -> List["ProductSet"]:
|
||||
return FormatConverter.apply(
|
||||
smiles,
|
||||
smirks,
|
||||
preprocess_smiles,
|
||||
bracketize,
|
||||
standardize,
|
||||
kekulize,
|
||||
remove_stereo,
|
||||
reactant_filter_smarts,
|
||||
product_filter_smarts,
|
||||
)
|
||||
@ -1,20 +1,54 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:15
|
||||
container_name: envipath-postgres
|
||||
image: postgres:18
|
||||
container_name: eppostgres
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: envipath
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ep_bayer_postgres_data:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
test: [ "CMD-SHELL", "pg_isready -U postgres" ]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: epredis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- ep_bayer_redis_data:/data
|
||||
|
||||
biotransformer3:
|
||||
image: envipath/biotransformer3:1.0
|
||||
container_name: epbiotransformer3
|
||||
|
||||
# web:
|
||||
# image: envipath/envipy-bayer:1.0
|
||||
# container_name: epdjango
|
||||
# ports:
|
||||
# - "127.0.0.1:8000:8000"
|
||||
# env_file:
|
||||
# - .env
|
||||
# command: gunicorn envipath.wsgi:application --bind 0.0.0.0:8000 --workers 3
|
||||
# volumes:
|
||||
# - ep_bayer_data:/opt/enviPy/
|
||||
|
||||
celery_worker:
|
||||
image: envipath/envipy-bayer:1.0
|
||||
container_name: epcelery
|
||||
env_file:
|
||||
- .env.dev
|
||||
command: celery -A envipath worker --concurrency=6 -Q model,predict,background --pool threads
|
||||
volumes:
|
||||
- ep_bayer_data:/opt/enviPy/
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
ep_bayer_postgres_data:
|
||||
ep_bayer_redis_data:
|
||||
ep_bayer_data:
|
||||
|
||||
50
docker-compose.yml
Normal file
50
docker-compose.yml
Normal file
@ -0,0 +1,50 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:18
|
||||
container_name: eppostgres
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
volumes:
|
||||
- ep_bayer_postgres_data:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -U postgres" ]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: epredis
|
||||
volumes:
|
||||
- ep_bayer_redis_data:/data
|
||||
|
||||
biotransformer3:
|
||||
image: envipath/biotransformer3:1.0
|
||||
container_name: epbiotransformer3
|
||||
|
||||
web:
|
||||
image: envipath/envipy-bayer:1.0
|
||||
container_name: epdjango
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000"
|
||||
env_file:
|
||||
- .env
|
||||
command: gunicorn envipath.wsgi:application --bind 0.0.0.0:8000 --workers 3
|
||||
volumes:
|
||||
- ep_bayer_data:/opt/enviPy/
|
||||
|
||||
celery_worker:
|
||||
image: envipath/envipy-bayer:1.0
|
||||
container_name: epcelery
|
||||
env_file:
|
||||
- .env
|
||||
command: celery -A envipath worker --concurrency=6 -Q model,predict,background --pool threads
|
||||
volumes:
|
||||
- ep_bayer_data:/opt/enviPy/
|
||||
|
||||
volumes:
|
||||
ep_bayer_postgres_data:
|
||||
ep_bayer_redis_data:
|
||||
ep_bayer_data:
|
||||
@ -9,19 +9,20 @@ https://docs.djangoproject.com/en/4.2/topics/settings/
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/4.2/ref/settings/
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from envipy_plugins import Classifier, Property, Descriptor
|
||||
from sklearn.ensemble import RandomForestClassifier
|
||||
from sklearn.tree import DecisionTreeClassifier
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
load_dotenv(BASE_DIR / ".env", override=False)
|
||||
ENV_PATH = os.environ.get("ENV_PATH", BASE_DIR / ".env.dev")
|
||||
print(f"Loading env from {ENV_PATH}")
|
||||
load_dotenv(ENV_PATH, override=False)
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
|
||||
@ -36,7 +37,6 @@ ALLOWED_HOSTS = os.environ["ALLOWED_HOSTS"].split(",")
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
@ -44,13 +44,14 @@ INSTALLED_APPS = [
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"django.contrib.postgres",
|
||||
# 3rd party
|
||||
"django_extensions",
|
||||
"oauth2_provider",
|
||||
# Custom
|
||||
"epapi", # API endpoints (v1, etc.)
|
||||
"epdb",
|
||||
# "migration",
|
||||
"migration",
|
||||
]
|
||||
|
||||
TENANT = os.environ.get("TENANT", "public")
|
||||
@ -73,6 +74,7 @@ AUTHENTICATION_BACKENDS = [
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
@ -91,10 +93,19 @@ if os.environ.get("REGISTRATION_MANDATORY", False) == "True":
|
||||
|
||||
ROOT_URLCONF = "envipath.urls"
|
||||
|
||||
TEMPLATE_DIRS = [
|
||||
os.path.join(BASE_DIR, "templates"),
|
||||
]
|
||||
|
||||
# If we have a non-public tenant, we might need to overwrite some templates
|
||||
# search TENANT folder first...
|
||||
if TENANT != "public":
|
||||
TEMPLATE_DIRS.insert(0, os.path.join(BASE_DIR, TENANT, "templates"))
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": (os.path.join(BASE_DIR, "templates"),),
|
||||
"DIRS": TEMPLATE_DIRS,
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
@ -126,6 +137,19 @@ DATABASES = {
|
||||
}
|
||||
}
|
||||
|
||||
if os.environ.get("USE_TEMPLATE_DB", False) == "True":
|
||||
DATABASES["default"]["TEST"] = {
|
||||
"NAME": f"test_{os.environ['TEMPLATE_DB']}",
|
||||
"TEMPLATE": os.environ["TEMPLATE_DB"],
|
||||
}
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
"LOCATION": "unique-snowflake",
|
||||
}
|
||||
}
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
|
||||
|
||||
@ -173,11 +197,21 @@ ADMIN_APPROVAL_REQUIRED = os.environ.get("ADMIN_APPROVAL_REQUIRED", "False") ==
|
||||
# SESAME_MAX_AGE = 300
|
||||
# # TODO set to "home"
|
||||
# LOGIN_REDIRECT_URL = "/"
|
||||
|
||||
|
||||
SERVER_HOST = os.environ.get("SERVER_URL", "http://localhost:8000")
|
||||
SERVER_PATH = os.environ.get("SERVER_PATH", "")
|
||||
|
||||
SERVER_URL = SERVER_HOST
|
||||
if SERVER_PATH:
|
||||
SERVER_URL = os.path.join(SERVER_HOST, SERVER_PATH)
|
||||
|
||||
|
||||
LOGIN_URL = "/login/"
|
||||
if SERVER_PATH:
|
||||
LOGIN_URL = f"/{SERVER_PATH}/login/"
|
||||
|
||||
SERVER_URL = os.environ.get("SERVER_URL", "http://localhost:8000")
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = [SERVER_URL]
|
||||
CSRF_TRUSTED_ORIGINS = [SERVER_HOST]
|
||||
|
||||
AMBIT_URL = "http://localhost:9001"
|
||||
DEFAULT_VALUES = {"description": "no description"}
|
||||
@ -199,19 +233,20 @@ if not os.path.exists(LOG_DIR):
|
||||
os.mkdir(LOG_DIR)
|
||||
|
||||
PLUGIN_DIR = os.path.join(EP_DATA_DIR, "plugins")
|
||||
if not os.path.exists(PLUGIN_DIR):
|
||||
os.mkdir(PLUGIN_DIR)
|
||||
|
||||
API_PAGINATION_DEFAULT_PAGE_SIZE = int(os.environ.get("API_PAGINATION_DEFAULT_PAGE_SIZE", 50))
|
||||
PAGINATION_MAX_PER_PAGE_SIZE = int(
|
||||
os.environ.get("API_PAGINATION_MAX_PAGE_SIZE", 100)
|
||||
) # Ninja override
|
||||
|
||||
if not os.path.exists(PLUGIN_DIR):
|
||||
os.mkdir(PLUGIN_DIR)
|
||||
|
||||
# Set this as our static root dir
|
||||
STATIC_ROOT = STATIC_DIR
|
||||
|
||||
STATIC_URL = "/static/"
|
||||
if SERVER_PATH:
|
||||
STATIC_URL = f"/{SERVER_PATH}/static/"
|
||||
|
||||
# Where the sources are stored...
|
||||
STATICFILES_DIRS = (BASE_DIR / "static",)
|
||||
@ -275,9 +310,8 @@ if not FLAG_CELERY_PRESENT:
|
||||
|
||||
# Celery Configuration Options
|
||||
CELERY_TIMEZONE = "Europe/Berlin"
|
||||
# Celery Configuration
|
||||
CELERY_BROKER_URL = "redis://localhost:6379/0" # Use Redis as message broker
|
||||
CELERY_RESULT_BACKEND = "redis://localhost:6379/1"
|
||||
CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
||||
CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", "redis://localhost:6379/1")
|
||||
CELERY_ACCEPT_CONTENT = ["json"]
|
||||
CELERY_TASK_SERIALIZER = "json"
|
||||
|
||||
@ -309,22 +343,21 @@ DEFAULT_MODEL_PARAMS = {
|
||||
"num_chains": 10,
|
||||
}
|
||||
|
||||
DEFAULT_MAX_NUMBER_OF_NODES = 30
|
||||
DEFAULT_MAX_DEPTH = 5
|
||||
DEFAULT_MAX_NUMBER_OF_NODES = 50
|
||||
DEFAULT_MAX_DEPTH = 8
|
||||
DEFAULT_MODEL_THRESHOLD = 0.25
|
||||
|
||||
# Loading Plugins
|
||||
PLUGINS_ENABLED = os.environ.get("PLUGINS_ENABLED", "False") == "True"
|
||||
if PLUGINS_ENABLED:
|
||||
from utilities.plugin import discover_plugins
|
||||
|
||||
CLASSIFIER_PLUGINS = discover_plugins(_cls=Classifier)
|
||||
PROPERTY_PLUGINS = discover_plugins(_cls=Property)
|
||||
DESCRIPTOR_PLUGINS = discover_plugins(_cls=Descriptor)
|
||||
BASE_PLUGINS = os.environ.get("BASE_PLUGINS", None)
|
||||
if BASE_PLUGINS:
|
||||
BASE_PLUGINS = BASE_PLUGINS.split(",")
|
||||
else:
|
||||
CLASSIFIER_PLUGINS = {}
|
||||
PROPERTY_PLUGINS = {}
|
||||
DESCRIPTOR_PLUGINS = {}
|
||||
BASE_PLUGINS = []
|
||||
|
||||
CLASSIFIER_PLUGINS = {}
|
||||
PROPERTY_PLUGINS = {}
|
||||
DESCRIPTOR_PLUGINS = {}
|
||||
|
||||
SENTRY_ENABLED = os.environ.get("SENTRY_ENABLED", "False") == "True"
|
||||
if SENTRY_ENABLED:
|
||||
@ -348,6 +381,10 @@ if SENTRY_ENABLED:
|
||||
before_send=before_send,
|
||||
)
|
||||
|
||||
IUCLID_EXPORT_ENABLED = os.environ.get("IUCLID_EXPORT_ENABLED", "False") == "True"
|
||||
if IUCLID_EXPORT_ENABLED:
|
||||
INSTALLED_APPS.append("epiuclid")
|
||||
|
||||
# compile into digestible flags
|
||||
FLAGS = {
|
||||
"MODEL_BUILDING": MODEL_BUILDING_ENABLED,
|
||||
@ -356,6 +393,7 @@ FLAGS = {
|
||||
"SENTRY": SENTRY_ENABLED,
|
||||
"ENVIFORMER": ENVIFORMER_PRESENT,
|
||||
"APPLICABILITY_DOMAIN": APPLICABILITY_DOMAIN_ENABLED,
|
||||
"IUCLID_EXPORT": IUCLID_EXPORT_ENABLED,
|
||||
}
|
||||
|
||||
# path of the URL are checked via "startswith"
|
||||
@ -368,7 +406,6 @@ LOGIN_EXEMPT_URLS = [
|
||||
"/o/userinfo/",
|
||||
"/password_reset/",
|
||||
"/reset/",
|
||||
"/microsoft/",
|
||||
"/terms",
|
||||
"/privacy",
|
||||
"/cookie-policy",
|
||||
@ -377,8 +414,13 @@ LOGIN_EXEMPT_URLS = [
|
||||
"/careers",
|
||||
"/cite",
|
||||
"/legal",
|
||||
"/entra/",
|
||||
"/auth/",
|
||||
]
|
||||
|
||||
if SERVER_PATH:
|
||||
LOGIN_EXEMPT_URLS = [f"/{SERVER_PATH}{x}" for x in LOGIN_EXEMPT_URLS]
|
||||
|
||||
# MS AD/Entra
|
||||
MS_ENTRA_ENABLED = os.environ.get("MS_ENTRA_ENABLED", "False") == "True"
|
||||
if MS_ENTRA_ENABLED:
|
||||
@ -394,3 +436,58 @@ if MS_ENTRA_ENABLED:
|
||||
|
||||
# Site ID 10 -> beta.envipath.org
|
||||
MATOMO_SITE_ID = os.environ.get("MATOMO_SITE_ID", "10")
|
||||
|
||||
# CAP
|
||||
CAP_ENABLED = os.environ.get("CAP_ENABLED", "False") == "True"
|
||||
CAP_API_BASE = os.environ.get("CAP_API_BASE", None)
|
||||
CAP_SITE_KEY = os.environ.get("CAP_SITE_KEY", None)
|
||||
CAP_SECRET_KEY = os.environ.get("CAP_SECRET_KEY", None)
|
||||
|
||||
# Biotransformer
|
||||
BIOTRANSFORMER_ENABLED = os.environ.get("BIOTRANSFORMER_ENABLED", "False") == "True"
|
||||
FLAGS["BIOTRANSFORMER"] = BIOTRANSFORMER_ENABLED
|
||||
if BIOTRANSFORMER_ENABLED:
|
||||
BIOTRANSFORMER_URL = os.environ.get("BIOTRANSFORMER_URL", None)
|
||||
|
||||
# PES
|
||||
PES_API_MAPPING = os.environ.get("PES_API_MAPPING", None)
|
||||
if PES_API_MAPPING:
|
||||
import json
|
||||
PES_API_MAPPING = json.loads(PES_API_MAPPING)
|
||||
else:
|
||||
PES_API_MAPPING = {}
|
||||
|
||||
# Entra Groups
|
||||
ENTRA_GROUPS = os.environ.get("ENTRA_GROUPS", None)
|
||||
if ENTRA_GROUPS:
|
||||
import json
|
||||
ENTRA_GROUPS = json.loads(ENTRA_GROUPS)
|
||||
else:
|
||||
ENTRA_GROUPS = {}
|
||||
|
||||
ENTRA_SECRET_GROUPS = os.environ.get("ENTRA_SECRET_GROUPS", None)
|
||||
if ENTRA_SECRET_GROUPS:
|
||||
import json
|
||||
ENTRA_SECRET_GROUPS = json.loads(ENTRA_SECRET_GROUPS)
|
||||
else:
|
||||
ENTRA_SECRET_GROUPS = {}
|
||||
|
||||
# PES Data Pools vs Entra Mapping
|
||||
DATA_POOL_MAPPING = os.environ.get("DATA_POOL_MAPPING", None)
|
||||
if DATA_POOL_MAPPING:
|
||||
import json
|
||||
DATA_POOL_MAPPING = json.loads(DATA_POOL_MAPPING)
|
||||
else:
|
||||
DATA_POOL_MAPPING = {}
|
||||
|
||||
PROXIES = {}
|
||||
if os.environ.get("HTTP_PROXY"):
|
||||
PROXIES["http"] = os.environ.get("HTTP_PROXY")
|
||||
PROXIES["https"] = os.environ.get("HTTPS_PROXY")
|
||||
|
||||
# BB4g
|
||||
BB4G_URL = os.environ.get("BB4G_URL")
|
||||
BB4G_TENANT_ID = os.environ.get("BB4G_TENANT_ID")
|
||||
BB4G_CLIENT_ID = os.environ.get("BB4G_CLIENT_ID")
|
||||
BB4G_CLIENT_SECRET = os.environ.get("BB4G_CLIENT_SECRET")
|
||||
BB4G_SCOPE = os.environ.get("BB4G_SCOPE")
|
||||
|
||||
@ -21,19 +21,27 @@ from django.urls import include, path
|
||||
|
||||
from .api import api_v1, api_legacy
|
||||
|
||||
PATH_PREFIX = s.SERVER_PATH
|
||||
if PATH_PREFIX and not PATH_PREFIX.endswith("/"):
|
||||
PATH_PREFIX += "/"
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("", include("epdb.urls")),
|
||||
path("admin/", admin.site.urls),
|
||||
path("api/v1/", api_v1.urls),
|
||||
path("api/legacy/", api_legacy.urls),
|
||||
path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")),
|
||||
path(f"{PATH_PREFIX}", include("epdb.urls")),
|
||||
path(f"{PATH_PREFIX}admin/", admin.site.urls),
|
||||
path(f"{PATH_PREFIX}api/v1/", api_v1.urls),
|
||||
path(f"{PATH_PREFIX}api/legacy/", api_legacy.urls),
|
||||
path(f"{PATH_PREFIX}o/", include("oauth2_provider.urls", namespace="oauth2_provider")),
|
||||
]
|
||||
|
||||
if "migration" in s.INSTALLED_APPS:
|
||||
urlpatterns.append(path("", include("migration.urls")))
|
||||
urlpatterns.append(path(f"{PATH_PREFIX}", include("migration.urls")))
|
||||
|
||||
if s.MS_ENTRA_ENABLED:
|
||||
urlpatterns.append(path("", include("epauth.urls")))
|
||||
urlpatterns.append(path(f"{PATH_PREFIX}", include("epauth.urls")))
|
||||
|
||||
if s.TENANT != "public":
|
||||
urlpatterns.append(path(f"{PATH_PREFIX}", include(f"{s.TENANT}.urls")))
|
||||
|
||||
# Custom error handlers
|
||||
handler400 = "epdb.views.handler400"
|
||||
|
||||
1
epapi/tests/utils/__init__.py
Normal file
1
epapi/tests/utils/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for epapi utility modules."""
|
||||
218
epapi/tests/utils/test_validation_errors.py
Normal file
218
epapi/tests/utils/test_validation_errors.py
Normal file
@ -0,0 +1,218 @@
|
||||
"""
|
||||
Tests for validation error utilities.
|
||||
|
||||
Tests the format_validation_error() and handle_validation_error() functions
|
||||
that transform Pydantic validation errors into user-friendly messages.
|
||||
"""
|
||||
|
||||
from django.test import TestCase, tag
|
||||
import json
|
||||
from pydantic import BaseModel, ValidationError, field_validator
|
||||
from typing import Literal
|
||||
|
||||
from ninja.errors import HttpError
|
||||
from epapi.utils.validation_errors import format_validation_error, handle_validation_error
|
||||
|
||||
|
||||
@tag("api", "utils")
|
||||
class ValidationErrorUtilityTests(TestCase):
|
||||
"""Test validation error utility functions."""
|
||||
|
||||
def test_format_missing_field_error(self):
|
||||
"""Test formatting of missing required field error."""
|
||||
|
||||
# Create a model with required field
|
||||
class TestModel(BaseModel):
|
||||
required_field: str
|
||||
|
||||
# Trigger validation error
|
||||
try:
|
||||
TestModel()
|
||||
except ValidationError as e:
|
||||
errors = e.errors()
|
||||
self.assertEqual(len(errors), 1)
|
||||
formatted = format_validation_error(errors[0])
|
||||
self.assertEqual(formatted, "This field is required")
|
||||
|
||||
def test_format_enum_error(self):
|
||||
"""Test formatting of enum validation error."""
|
||||
|
||||
class TestModel(BaseModel):
|
||||
status: Literal["active", "inactive"]
|
||||
|
||||
try:
|
||||
TestModel(status="invalid")
|
||||
except ValidationError as e:
|
||||
errors = e.errors()
|
||||
self.assertEqual(len(errors), 1)
|
||||
formatted = format_validation_error(errors[0])
|
||||
# Literal errors get formatted as "Please enter ..." with the valid options
|
||||
self.assertIn("Please enter", formatted)
|
||||
self.assertIn("active", formatted)
|
||||
self.assertIn("inactive", formatted)
|
||||
|
||||
def test_format_type_errors(self):
|
||||
"""Test formatting of type validation errors (string, int, float)."""
|
||||
test_cases = [
|
||||
# (field_type, invalid_value, expected_message)
|
||||
# Note: We don't check exact error_type as Pydantic may use different types
|
||||
# (e.g., int_type vs int_parsing) but we verify the formatted message is correct
|
||||
(str, 123, "Please enter a valid string"),
|
||||
(int, "not_a_number", "Please enter a valid int"),
|
||||
(float, "not_a_float", "Please enter a valid float"),
|
||||
]
|
||||
|
||||
for field_type, invalid_value, expected_message in test_cases:
|
||||
with self.subTest(field_type=field_type.__name__):
|
||||
|
||||
class TestModel(BaseModel):
|
||||
field: field_type
|
||||
|
||||
try:
|
||||
TestModel(field=invalid_value)
|
||||
except ValidationError as e:
|
||||
errors = e.errors()
|
||||
self.assertEqual(len(errors), 1)
|
||||
formatted = format_validation_error(errors[0])
|
||||
self.assertEqual(formatted, expected_message)
|
||||
|
||||
def test_format_value_error(self):
|
||||
"""Test formatting of value error from custom validator."""
|
||||
|
||||
class TestModel(BaseModel):
|
||||
age: int
|
||||
|
||||
@field_validator("age")
|
||||
@classmethod
|
||||
def validate_age(cls, v):
|
||||
if v < 0:
|
||||
raise ValueError("Age must be positive")
|
||||
return v
|
||||
|
||||
try:
|
||||
TestModel(age=-5)
|
||||
except ValidationError as e:
|
||||
errors = e.errors()
|
||||
self.assertEqual(len(errors), 1)
|
||||
formatted = format_validation_error(errors[0])
|
||||
self.assertEqual(formatted, "Age must be positive")
|
||||
|
||||
def test_format_unknown_error_type_fallback(self):
|
||||
"""Test that unknown error types fall back to default formatting."""
|
||||
# Mock an error with an unknown type
|
||||
mock_error = {
|
||||
"type": "unknown_custom_type",
|
||||
"msg": "Input should be a valid email address",
|
||||
"ctx": {},
|
||||
}
|
||||
|
||||
formatted = format_validation_error(mock_error)
|
||||
# Should use the else branch which does replacements on the message
|
||||
self.assertEqual(formatted, "Please enter a valid email address")
|
||||
|
||||
def test_handle_validation_error_structure(self):
|
||||
"""Test that handle_validation_error raises HttpError with correct structure."""
|
||||
|
||||
class TestModel(BaseModel):
|
||||
name: str
|
||||
count: int
|
||||
|
||||
try:
|
||||
TestModel(name=123, count="invalid")
|
||||
except ValidationError as e:
|
||||
# handle_validation_error should raise HttpError
|
||||
with self.assertRaises(HttpError) as context:
|
||||
handle_validation_error(e)
|
||||
|
||||
http_error = context.exception
|
||||
self.assertEqual(http_error.status_code, 400)
|
||||
|
||||
# Parse the JSON from the error message
|
||||
error_data = json.loads(http_error.message)
|
||||
|
||||
# Check structure
|
||||
self.assertEqual(error_data["type"], "validation_error")
|
||||
self.assertIn("field_errors", error_data)
|
||||
self.assertIn("message", error_data)
|
||||
self.assertEqual(error_data["message"], "Please correct the errors below")
|
||||
|
||||
# Check that both fields have errors
|
||||
self.assertIn("name", error_data["field_errors"])
|
||||
self.assertIn("count", error_data["field_errors"])
|
||||
|
||||
def test_handle_validation_error_no_pydantic_internals(self):
|
||||
"""Test that handle_validation_error doesn't expose Pydantic internals."""
|
||||
|
||||
class TestModel(BaseModel):
|
||||
email: str
|
||||
|
||||
try:
|
||||
TestModel(email=123)
|
||||
except ValidationError as e:
|
||||
with self.assertRaises(HttpError) as context:
|
||||
handle_validation_error(e)
|
||||
|
||||
http_error = context.exception
|
||||
error_data = json.loads(http_error.message)
|
||||
error_str = json.dumps(error_data)
|
||||
|
||||
# Ensure no Pydantic internals are exposed
|
||||
self.assertNotIn("pydantic", error_str.lower())
|
||||
self.assertNotIn("https://errors.pydantic.dev", error_str)
|
||||
self.assertNotIn("loc", error_str)
|
||||
|
||||
def test_handle_validation_error_user_friendly_messages(self):
|
||||
"""Test that all error messages are user-friendly."""
|
||||
|
||||
class TestModel(BaseModel):
|
||||
name: str
|
||||
age: int
|
||||
status: Literal["active", "inactive"]
|
||||
|
||||
try:
|
||||
TestModel(name=123, status="invalid") # Multiple errors
|
||||
except ValidationError as e:
|
||||
with self.assertRaises(HttpError) as context:
|
||||
handle_validation_error(e)
|
||||
|
||||
http_error = context.exception
|
||||
error_data = json.loads(http_error.message)
|
||||
|
||||
# All messages should be user-friendly (contain "Please" or "This field")
|
||||
for field, messages in error_data["field_errors"].items():
|
||||
for message in messages:
|
||||
# User-friendly messages start with "Please" or "This field"
|
||||
self.assertTrue(
|
||||
message.startswith("Please") or message.startswith("This field"),
|
||||
f"Message '{message}' is not user-friendly",
|
||||
)
|
||||
|
||||
def test_handle_validation_error_multiple_errors_same_field(self):
|
||||
"""Test handling multiple validation errors for the same field."""
|
||||
|
||||
class TestModel(BaseModel):
|
||||
value: int
|
||||
|
||||
@field_validator("value")
|
||||
@classmethod
|
||||
def validate_range(cls, v):
|
||||
if v < 0:
|
||||
raise ValueError("Must be non-negative")
|
||||
if v > 100:
|
||||
raise ValueError("Must be at most 100")
|
||||
return v
|
||||
|
||||
# Test with string (type error) - this will fail before the validator runs
|
||||
try:
|
||||
TestModel(value="invalid")
|
||||
except ValidationError as e:
|
||||
with self.assertRaises(HttpError) as context:
|
||||
handle_validation_error(e)
|
||||
|
||||
http_error = context.exception
|
||||
error_data = json.loads(http_error.message)
|
||||
|
||||
# Should have error for 'value' field
|
||||
self.assertIn("value", error_data["field_errors"])
|
||||
self.assertIsInstance(error_data["field_errors"]["value"], list)
|
||||
self.assertGreater(len(error_data["field_errors"]["value"]), 0)
|
||||
446
epapi/tests/v1/test_additional_information.py
Normal file
446
epapi/tests/v1/test_additional_information.py
Normal file
@ -0,0 +1,446 @@
|
||||
"""
|
||||
Tests for Additional Information API endpoints.
|
||||
|
||||
Tests CRUD operations on scenario additional information including the new PATCH endpoint.
|
||||
"""
|
||||
|
||||
from django.test import TestCase, tag
|
||||
import json
|
||||
from uuid import uuid4
|
||||
|
||||
from epdb.logic import PackageManager, UserManager
|
||||
from epdb.models import Scenario
|
||||
|
||||
|
||||
@tag("api", "additional_information")
|
||||
class AdditionalInformationAPITests(TestCase):
|
||||
"""Test additional information API endpoints."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""Set up test data: user, package, and scenario."""
|
||||
cls.user = UserManager.create_user(
|
||||
"ai-test-user",
|
||||
"ai-test@envipath.com",
|
||||
"SuperSafe",
|
||||
set_setting=False,
|
||||
add_to_group=False,
|
||||
is_active=True,
|
||||
)
|
||||
cls.other_user = UserManager.create_user(
|
||||
"ai-other-user",
|
||||
"ai-other@envipath.com",
|
||||
"SuperSafe",
|
||||
set_setting=False,
|
||||
add_to_group=False,
|
||||
is_active=True,
|
||||
)
|
||||
cls.package = PackageManager.create_package(
|
||||
cls.user, "AI Test Package", "Test package for additional information"
|
||||
)
|
||||
# Package owned by other_user (no access for cls.user)
|
||||
cls.other_package = PackageManager.create_package(
|
||||
cls.other_user, "Other Package", "Package without access"
|
||||
)
|
||||
# Create a scenario for testing
|
||||
cls.scenario = Scenario.objects.create(
|
||||
package=cls.package,
|
||||
name="Test Scenario",
|
||||
description="Test scenario for additional information tests",
|
||||
scenario_type="biodegradation",
|
||||
scenario_date="2024-01-01",
|
||||
)
|
||||
cls.other_scenario = Scenario.objects.create(
|
||||
package=cls.other_package,
|
||||
name="Other Scenario",
|
||||
description="Scenario in package without access",
|
||||
scenario_type="biodegradation",
|
||||
scenario_date="2024-01-01",
|
||||
)
|
||||
|
||||
def test_list_all_schemas(self):
|
||||
"""Test GET /api/v1/information/schema/ returns all schemas."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.get("/api/v1/information/schema/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertIsInstance(data, dict)
|
||||
# Should have multiple schemas
|
||||
self.assertGreater(len(data), 0)
|
||||
# Each schema should have RJSF format
|
||||
for name, schema in data.items():
|
||||
self.assertIn("schema", schema)
|
||||
self.assertIn("uiSchema", schema)
|
||||
self.assertIn("formData", schema)
|
||||
self.assertIn("groups", schema)
|
||||
|
||||
def test_get_specific_schema(self):
|
||||
"""Test GET /api/v1/information/schema/{model_name}/ returns specific schema."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
# Assuming 'temperature' is a valid model
|
||||
response = self.client.get("/api/v1/information/schema/temperature/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertIn("schema", data)
|
||||
self.assertIn("uiSchema", data)
|
||||
|
||||
def test_get_nonexistent_schema_returns_404(self):
|
||||
"""Test GET for non-existent schema returns 404."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.get("/api/v1/information/schema/nonexistent/")
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_list_scenario_information_empty(self):
|
||||
"""Test GET /api/v1/scenario/{uuid}/information/ returns empty list initially."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.get(f"/api/v1/scenario/{self.scenario.uuid}/information/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertIsInstance(data, list)
|
||||
self.assertEqual(len(data), 0)
|
||||
|
||||
def test_create_additional_information(self):
|
||||
"""Test POST creates additional information."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
# Create temperature information (assuming temperature model exists)
|
||||
payload = {"interval": {"start": 20, "end": 25}}
|
||||
response = self.client.post(
|
||||
f"/api/v1/scenario/{self.scenario.uuid}/information/temperature/",
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertEqual(data["status"], "created")
|
||||
self.assertIn("uuid", data)
|
||||
self.assertIsNotNone(data["uuid"])
|
||||
|
||||
def test_create_with_invalid_data_returns_400(self):
|
||||
"""Test POST with invalid data returns 400 with validation errors."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
# Invalid data (missing required fields or wrong types)
|
||||
payload = {"invalid_field": "value"}
|
||||
response = self.client.post(
|
||||
f"/api/v1/scenario/{self.scenario.uuid}/information/temperature/",
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
data = response.json()
|
||||
# Should have validation error details in 'detail' field
|
||||
self.assertIn("detail", data)
|
||||
|
||||
def test_validation_errors_are_user_friendly(self):
|
||||
"""Test that validation errors are user-friendly and field-specific."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
# Invalid data - wrong type (string instead of number in interval)
|
||||
payload = {"interval": {"start": "not_a_number", "end": 25}}
|
||||
response = self.client.post(
|
||||
f"/api/v1/scenario/{self.scenario.uuid}/information/temperature/",
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
data = response.json()
|
||||
|
||||
# Parse the error response - Django Ninja wraps errors in 'detail'
|
||||
error_str = data.get("detail") or data.get("error")
|
||||
self.assertIsNotNone(error_str, "Response should contain error details")
|
||||
|
||||
# Parse the JSON error string
|
||||
error_data = json.loads(error_str)
|
||||
|
||||
# Check structure
|
||||
self.assertEqual(error_data.get("type"), "validation_error")
|
||||
self.assertIn("field_errors", error_data)
|
||||
self.assertIn("message", error_data)
|
||||
|
||||
# Ensure error messages are user-friendly (no Pydantic URLs or technical jargon)
|
||||
error_str = json.dumps(error_data)
|
||||
self.assertNotIn("pydantic", error_str.lower())
|
||||
self.assertNotIn("https://errors.pydantic.dev", error_str)
|
||||
self.assertNotIn("loc", error_str) # No technical field like 'loc'
|
||||
|
||||
# Check that error message is helpful
|
||||
self.assertIn("Please", error_data["message"]) # User-friendly language
|
||||
|
||||
def test_patch_additional_information(self):
|
||||
"""Test PATCH updates existing additional information."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
# First create an item
|
||||
create_payload = {"interval": {"start": 20, "end": 25}}
|
||||
create_response = self.client.post(
|
||||
f"/api/v1/scenario/{self.scenario.uuid}/information/temperature/",
|
||||
data=json.dumps(create_payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
item_uuid = create_response.json()["uuid"]
|
||||
|
||||
# Then update it with PATCH
|
||||
update_payload = {"interval": {"start": 30, "end": 35}}
|
||||
patch_response = self.client.patch(
|
||||
f"/api/v1/scenario/{self.scenario.uuid}/information/item/{item_uuid}/",
|
||||
data=json.dumps(update_payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(patch_response.status_code, 200)
|
||||
data = patch_response.json()
|
||||
self.assertEqual(data["status"], "updated")
|
||||
self.assertEqual(data["uuid"], item_uuid) # UUID preserved
|
||||
|
||||
# Verify the data was updated
|
||||
list_response = self.client.get(f"/api/v1/scenario/{self.scenario.uuid}/information/")
|
||||
items = list_response.json()
|
||||
self.assertEqual(len(items), 1)
|
||||
updated_item = items[0]
|
||||
self.assertEqual(updated_item["uuid"], item_uuid)
|
||||
self.assertEqual(updated_item["data"]["interval"]["start"], 30)
|
||||
self.assertEqual(updated_item["data"]["interval"]["end"], 35)
|
||||
|
||||
def test_patch_nonexistent_item_returns_404(self):
|
||||
"""Test PATCH on non-existent item returns 404."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
fake_uuid = str(uuid4())
|
||||
payload = {"interval": {"start": 30, "end": 35}}
|
||||
response = self.client.patch(
|
||||
f"/api/v1/scenario/{self.scenario.uuid}/information/item/{fake_uuid}/",
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_patch_with_invalid_data_returns_400(self):
|
||||
"""Test PATCH with invalid data returns 400."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
# First create an item
|
||||
create_payload = {"interval": {"start": 20, "end": 25}}
|
||||
create_response = self.client.post(
|
||||
f"/api/v1/scenario/{self.scenario.uuid}/information/temperature/",
|
||||
data=json.dumps(create_payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
item_uuid = create_response.json()["uuid"]
|
||||
|
||||
# Try to update with invalid data
|
||||
invalid_payload = {"invalid_field": "value"}
|
||||
patch_response = self.client.patch(
|
||||
f"/api/v1/scenario/{self.scenario.uuid}/information/item/{item_uuid}/",
|
||||
data=json.dumps(invalid_payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(patch_response.status_code, 400)
|
||||
|
||||
def test_patch_validation_errors_are_user_friendly(self):
|
||||
"""Test that PATCH validation errors are user-friendly and field-specific."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
# First create an item
|
||||
create_payload = {"interval": {"start": 20, "end": 25}}
|
||||
create_response = self.client.post(
|
||||
f"/api/v1/scenario/{self.scenario.uuid}/information/temperature/",
|
||||
data=json.dumps(create_payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
item_uuid = create_response.json()["uuid"]
|
||||
|
||||
# Update with invalid data - wrong type (string instead of number in interval)
|
||||
invalid_payload = {"interval": {"start": "not_a_number", "end": 25}}
|
||||
patch_response = self.client.patch(
|
||||
f"/api/v1/scenario/{self.scenario.uuid}/information/item/{item_uuid}/",
|
||||
data=json.dumps(invalid_payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(patch_response.status_code, 400)
|
||||
data = patch_response.json()
|
||||
|
||||
# Parse the error response - Django Ninja wraps errors in 'detail'
|
||||
error_str = data.get("detail") or data.get("error")
|
||||
self.assertIsNotNone(error_str, "Response should contain error details")
|
||||
|
||||
# Parse the JSON error string
|
||||
error_data = json.loads(error_str)
|
||||
|
||||
# Check structure
|
||||
self.assertEqual(error_data.get("type"), "validation_error")
|
||||
self.assertIn("field_errors", error_data)
|
||||
self.assertIn("message", error_data)
|
||||
|
||||
# Ensure error messages are user-friendly (no Pydantic URLs or technical jargon)
|
||||
error_str = json.dumps(error_data)
|
||||
self.assertNotIn("pydantic", error_str.lower())
|
||||
self.assertNotIn("https://errors.pydantic.dev", error_str)
|
||||
self.assertNotIn("loc", error_str) # No technical field like 'loc'
|
||||
|
||||
# Check that error message is helpful
|
||||
self.assertIn("Please", error_data["message"]) # User-friendly language
|
||||
|
||||
def test_delete_additional_information(self):
|
||||
"""Test DELETE removes additional information."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
# Create an item
|
||||
create_payload = {"interval": {"start": 20, "end": 25}}
|
||||
create_response = self.client.post(
|
||||
f"/api/v1/scenario/{self.scenario.uuid}/information/temperature/",
|
||||
data=json.dumps(create_payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
item_uuid = create_response.json()["uuid"]
|
||||
|
||||
# Delete it
|
||||
delete_response = self.client.delete(
|
||||
f"/api/v1/scenario/{self.scenario.uuid}/information/item/{item_uuid}/"
|
||||
)
|
||||
|
||||
self.assertEqual(delete_response.status_code, 200)
|
||||
data = delete_response.json()
|
||||
self.assertEqual(data["status"], "deleted")
|
||||
|
||||
# Verify deletion
|
||||
list_response = self.client.get(f"/api/v1/scenario/{self.scenario.uuid}/information/")
|
||||
items = list_response.json()
|
||||
self.assertEqual(len(items), 0)
|
||||
|
||||
def test_delete_nonexistent_item_returns_404(self):
|
||||
"""Test DELETE on non-existent item returns 404."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
fake_uuid = str(uuid4())
|
||||
response = self.client.delete(
|
||||
f"/api/v1/scenario/{self.scenario.uuid}/information/item/{fake_uuid}/"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_multiple_items_crud(self):
|
||||
"""Test creating, updating, and deleting multiple items."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
# Create first item
|
||||
item1_payload = {"interval": {"start": 20, "end": 25}}
|
||||
response1 = self.client.post(
|
||||
f"/api/v1/scenario/{self.scenario.uuid}/information/temperature/",
|
||||
data=json.dumps(item1_payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
item1_uuid = response1.json()["uuid"]
|
||||
|
||||
# Create second item (different type if available, or same type)
|
||||
item2_payload = {"interval": {"start": 30, "end": 35}}
|
||||
response2 = self.client.post(
|
||||
f"/api/v1/scenario/{self.scenario.uuid}/information/temperature/",
|
||||
data=json.dumps(item2_payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
item2_uuid = response2.json()["uuid"]
|
||||
|
||||
# Verify both exist
|
||||
list_response = self.client.get(f"/api/v1/scenario/{self.scenario.uuid}/information/")
|
||||
items = list_response.json()
|
||||
self.assertEqual(len(items), 2)
|
||||
|
||||
# Update first item
|
||||
update_payload = {"interval": {"start": 15, "end": 20}}
|
||||
self.client.patch(
|
||||
f"/api/v1/scenario/{self.scenario.uuid}/information/item/{item1_uuid}/",
|
||||
data=json.dumps(update_payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
# Delete second item
|
||||
self.client.delete(f"/api/v1/scenario/{self.scenario.uuid}/information/item/{item2_uuid}/")
|
||||
|
||||
# Verify final state: one item with updated data
|
||||
list_response = self.client.get(f"/api/v1/scenario/{self.scenario.uuid}/information/")
|
||||
items = list_response.json()
|
||||
self.assertEqual(len(items), 1)
|
||||
self.assertEqual(items[0]["uuid"], item1_uuid)
|
||||
self.assertEqual(items[0]["data"]["interval"]["start"], 15)
|
||||
|
||||
def test_list_info_denied_without_permission(self):
|
||||
"""User cannot list info for scenario in package they don't have access to"""
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(f"/api/v1/scenario/{self.other_scenario.uuid}/information/")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_add_info_denied_without_permission(self):
|
||||
"""User cannot add info to scenario in package they don't have access to"""
|
||||
self.client.force_login(self.user)
|
||||
payload = {"interval": {"start": 25, "end": 30}}
|
||||
response = self.client.post(
|
||||
f"/api/v1/scenario/{self.other_scenario.uuid}/information/temperature/",
|
||||
json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_update_info_denied_without_permission(self):
|
||||
"""User cannot update info in scenario they don't have access to"""
|
||||
self.client.force_login(self.other_user)
|
||||
# First create an item as other_user
|
||||
create_payload = {"interval": {"start": 20, "end": 25}}
|
||||
create_response = self.client.post(
|
||||
f"/api/v1/scenario/{self.other_scenario.uuid}/information/temperature/",
|
||||
data=json.dumps(create_payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
item_uuid = create_response.json()["uuid"]
|
||||
|
||||
# Try to update as user (who doesn't have access)
|
||||
self.client.force_login(self.user)
|
||||
update_payload = {"interval": {"start": 30, "end": 35}}
|
||||
response = self.client.patch(
|
||||
f"/api/v1/scenario/{self.other_scenario.uuid}/information/item/{item_uuid}/",
|
||||
data=json.dumps(update_payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_delete_info_denied_without_permission(self):
|
||||
"""User cannot delete info from scenario they don't have access to"""
|
||||
self.client.force_login(self.other_user)
|
||||
# First create an item as other_user
|
||||
create_payload = {"interval": {"start": 20, "end": 25}}
|
||||
create_response = self.client.post(
|
||||
f"/api/v1/scenario/{self.other_scenario.uuid}/information/temperature/",
|
||||
data=json.dumps(create_payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
item_uuid = create_response.json()["uuid"]
|
||||
|
||||
# Try to delete as user (who doesn't have access)
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.delete(
|
||||
f"/api/v1/scenario/{self.other_scenario.uuid}/information/item/{item_uuid}/"
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_nonexistent_scenario_returns_404(self):
|
||||
"""Test operations on non-existent scenario return 404."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
fake_uuid = uuid4()
|
||||
response = self.client.get(f"/api/v1/scenario/{fake_uuid}/information/")
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
@ -261,13 +261,6 @@ class GlobalCompoundListPermissionTest(APIPermissionTestBase):
|
||||
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"]}
|
||||
@ -303,54 +296,6 @@ class GlobalCompoundListPermissionTest(APIPermissionTestBase):
|
||||
# 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):
|
||||
|
||||
@ -134,7 +134,7 @@ class BaseTestAPIGetPaginated:
|
||||
f"({self.total_reviewed} <= {self.default_page_size})"
|
||||
)
|
||||
|
||||
response = self.client.get(self.global_endpoint, {"page": 2})
|
||||
response = self.client.get(self.global_endpoint, {"page": 2, "review_status": True})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
payload = response.json()
|
||||
|
||||
301
epapi/tests/v1/test_scenario_creation.py
Normal file
301
epapi/tests/v1/test_scenario_creation.py
Normal file
@ -0,0 +1,301 @@
|
||||
"""
|
||||
Tests for Scenario Creation Endpoint Error Handling.
|
||||
|
||||
Tests comprehensive error handling for POST /api/v1/package/{uuid}/scenario/
|
||||
including package not found, permission denied, validation errors, and database errors.
|
||||
"""
|
||||
|
||||
from django.test import TestCase, tag
|
||||
import json
|
||||
from uuid import uuid4
|
||||
|
||||
from epdb.logic import PackageManager, UserManager
|
||||
from epdb.models import Scenario
|
||||
|
||||
|
||||
@tag("api", "scenario_creation")
|
||||
class ScenarioCreationAPITests(TestCase):
|
||||
"""Test scenario creation endpoint error handling."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""Set up test data: users and packages."""
|
||||
cls.user = UserManager.create_user(
|
||||
"scenario-test-user",
|
||||
"scenario-test@envipath.com",
|
||||
"SuperSafe",
|
||||
set_setting=False,
|
||||
add_to_group=False,
|
||||
is_active=True,
|
||||
)
|
||||
cls.other_user = UserManager.create_user(
|
||||
"other-user",
|
||||
"other@envipath.com",
|
||||
"SuperSafe",
|
||||
set_setting=False,
|
||||
add_to_group=False,
|
||||
is_active=True,
|
||||
)
|
||||
cls.package = PackageManager.create_package(
|
||||
cls.user, "Test Package", "Test package for scenario creation"
|
||||
)
|
||||
|
||||
def test_create_scenario_package_not_found(self):
|
||||
"""Test that non-existent package UUID returns 404."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
fake_uuid = uuid4()
|
||||
payload = {
|
||||
"name": "Test Scenario",
|
||||
"description": "Test description",
|
||||
"scenario_date": "2024-01-01",
|
||||
"scenario_type": "biodegradation",
|
||||
"additional_information": [],
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/v1/package/{fake_uuid}/scenario/",
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertIn(f"Package with UUID {fake_uuid} not found", response.json()["detail"])
|
||||
|
||||
def test_create_scenario_insufficient_permissions(self):
|
||||
"""Test that unauthorized access returns 403."""
|
||||
self.client.force_login(self.other_user)
|
||||
|
||||
payload = {
|
||||
"name": "Test Scenario",
|
||||
"description": "Test description",
|
||||
"scenario_date": "2024-01-01",
|
||||
"scenario_type": "biodegradation",
|
||||
"additional_information": [],
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/v1/package/{self.package.uuid}/scenario/",
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertIn("permission", response.json()["detail"].lower())
|
||||
|
||||
def test_create_scenario_invalid_ai_type(self):
|
||||
"""Test that unknown additional information type returns 400."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
payload = {
|
||||
"name": "Test Scenario",
|
||||
"description": "Test description",
|
||||
"scenario_date": "2024-01-01",
|
||||
"scenario_type": "biodegradation",
|
||||
"additional_information": [
|
||||
{"type": "invalid_type_that_does_not_exist", "data": {"some_field": "some_value"}}
|
||||
],
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/v1/package/{self.package.uuid}/scenario/",
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
response_data = response.json()
|
||||
self.assertIn("Validation errors", response_data["detail"])
|
||||
|
||||
def test_create_scenario_validation_error(self):
|
||||
"""Test that invalid additional information data returns 400."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
# Use malformed data structure for an actual AI type
|
||||
payload = {
|
||||
"name": "Test Scenario",
|
||||
"description": "Test description",
|
||||
"scenario_date": "2024-01-01",
|
||||
"scenario_type": "biodegradation",
|
||||
"additional_information": [
|
||||
{
|
||||
"type": "invalid_type_name",
|
||||
"data": None, # This should cause a validation error
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/v1/package/{self.package.uuid}/scenario/",
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
# Should return 422 for validation errors
|
||||
self.assertEqual(response.status_code, 422)
|
||||
|
||||
def test_create_scenario_success(self):
|
||||
"""Test that valid scenario creation returns 200."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
payload = {
|
||||
"name": "Test Scenario",
|
||||
"description": "Test description",
|
||||
"scenario_date": "2024-01-01",
|
||||
"scenario_type": "biodegradation",
|
||||
"additional_information": [],
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/v1/package/{self.package.uuid}/scenario/",
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertEqual(data["name"], "Test Scenario")
|
||||
self.assertEqual(data["description"], "Test description")
|
||||
|
||||
# Verify scenario was actually created
|
||||
scenario = Scenario.objects.get(name="Test Scenario")
|
||||
self.assertEqual(scenario.package, self.package)
|
||||
self.assertEqual(scenario.scenario_type, "biodegradation")
|
||||
|
||||
def test_create_scenario_auto_name(self):
|
||||
"""Test that empty name triggers auto-generation."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
payload = {
|
||||
"name": "", # Empty name should be auto-generated
|
||||
"description": "Test description",
|
||||
"scenario_date": "2024-01-01",
|
||||
"scenario_type": "biodegradation",
|
||||
"additional_information": [],
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/v1/package/{self.package.uuid}/scenario/",
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
# Auto-generated name should follow pattern "Scenario N"
|
||||
self.assertTrue(data["name"].startswith("Scenario "))
|
||||
|
||||
def test_create_scenario_xss_protection(self):
|
||||
"""Test that XSS attempts are sanitized."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
payload = {
|
||||
"name": "<script>alert('xss')</script>Clean Name",
|
||||
"description": "<img src=x onerror=alert('xss')>Description",
|
||||
"scenario_date": "2024-01-01",
|
||||
"scenario_type": "biodegradation",
|
||||
"additional_information": [],
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/v1/package/{self.package.uuid}/scenario/",
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
# XSS should be cleaned out
|
||||
self.assertNotIn("<script>", data["name"])
|
||||
self.assertNotIn("onerror", data["description"])
|
||||
|
||||
def test_create_scenario_missing_required_field(self):
|
||||
"""Test that missing required fields returns validation error."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
# Missing 'name' field entirely
|
||||
payload = {
|
||||
"description": "Test description",
|
||||
"scenario_date": "2024-01-01",
|
||||
"scenario_type": "biodegradation",
|
||||
"additional_information": [],
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/v1/package/{self.package.uuid}/scenario/",
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
# Should return 422 for schema validation errors
|
||||
self.assertEqual(response.status_code, 422)
|
||||
|
||||
def test_create_scenario_type_error_in_ai(self):
|
||||
"""Test that TypeError in AI instantiation returns 400."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
payload = {
|
||||
"name": "Test Scenario",
|
||||
"description": "Test description",
|
||||
"scenario_date": "2024-01-01",
|
||||
"scenario_type": "biodegradation",
|
||||
"additional_information": [
|
||||
{
|
||||
"type": "invalid_type_name",
|
||||
"data": "string instead of dict", # Wrong type
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/v1/package/{self.package.uuid}/scenario/",
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
# Should return 422 for validation errors
|
||||
self.assertEqual(response.status_code, 422)
|
||||
|
||||
def test_create_scenario_default_values(self):
|
||||
"""Test that default values are applied correctly."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
# Minimal payload with only name
|
||||
payload = {"name": "Minimal Scenario"}
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/v1/package/{self.package.uuid}/scenario/",
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertEqual(data["name"], "Minimal Scenario")
|
||||
# Check defaults are applied
|
||||
scenario = Scenario.objects.get(name="Minimal Scenario")
|
||||
# Default description from model is "no description"
|
||||
self.assertIn(scenario.description.lower(), ["", "no description"])
|
||||
|
||||
def test_create_scenario_unicode_characters(self):
|
||||
"""Test that unicode characters are handled properly."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
payload = {
|
||||
"name": "Test Scenario 测试 🧪",
|
||||
"description": "Description with émojis and spëcial çhars",
|
||||
"scenario_date": "2024-01-01",
|
||||
"scenario_type": "biodegradation",
|
||||
"additional_information": [],
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/v1/package/{self.package.uuid}/scenario/",
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertIn("测试", data["name"])
|
||||
self.assertIn("émojis", data["description"])
|
||||
113
epapi/tests/v1/test_schema_generation.py
Normal file
113
epapi/tests/v1/test_schema_generation.py
Normal file
@ -0,0 +1,113 @@
|
||||
"""
|
||||
Property-based tests for schema generation.
|
||||
|
||||
Tests that verify schema generation works correctly for all models,
|
||||
regardless of their structure.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from typing import Type
|
||||
from pydantic import BaseModel
|
||||
|
||||
from envipy_additional_information import registry, EnviPyModel
|
||||
from epapi.utils.schema_transformers import build_rjsf_output
|
||||
|
||||
|
||||
class TestSchemaGeneration:
|
||||
"""Test that all models can generate valid RJSF schemas."""
|
||||
|
||||
@pytest.mark.parametrize("model_name,model_cls", list(registry.list_models().items()))
|
||||
def test_all_models_generate_rjsf(self, model_name: str, model_cls: Type[BaseModel]):
|
||||
"""Every model in the registry should generate valid RJSF format."""
|
||||
# Skip non-EnviPyModel classes (parsers, etc.)
|
||||
if not issubclass(model_cls, EnviPyModel):
|
||||
pytest.skip(f"{model_name} is not an EnviPyModel")
|
||||
|
||||
# Should not raise exception
|
||||
result = build_rjsf_output(model_cls)
|
||||
|
||||
# Verify structure
|
||||
assert isinstance(result, dict), f"{model_name}: Result should be a dict"
|
||||
assert "schema" in result, f"{model_name}: Missing 'schema' key"
|
||||
assert "uiSchema" in result, f"{model_name}: Missing 'uiSchema' key"
|
||||
assert "formData" in result, f"{model_name}: Missing 'formData' key"
|
||||
assert "groups" in result, f"{model_name}: Missing 'groups' key"
|
||||
|
||||
# Verify types
|
||||
assert isinstance(result["schema"], dict), f"{model_name}: schema should be dict"
|
||||
assert isinstance(result["uiSchema"], dict), f"{model_name}: uiSchema should be dict"
|
||||
assert isinstance(result["formData"], dict), f"{model_name}: formData should be dict"
|
||||
assert isinstance(result["groups"], list), f"{model_name}: groups should be list"
|
||||
|
||||
# Verify schema has properties
|
||||
assert "properties" in result["schema"], f"{model_name}: schema should have 'properties'"
|
||||
assert isinstance(result["schema"]["properties"], dict), (
|
||||
f"{model_name}: properties should be dict"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("model_name,model_cls", list(registry.list_models().items()))
|
||||
def test_ui_schema_matches_schema_fields(self, model_name: str, model_cls: Type[BaseModel]):
|
||||
"""uiSchema keys should match schema properties (or be nested for intervals)."""
|
||||
if not issubclass(model_cls, EnviPyModel):
|
||||
pytest.skip(f"{model_name} is not an EnviPyModel")
|
||||
|
||||
result = build_rjsf_output(model_cls)
|
||||
schema_props = set(result["schema"]["properties"].keys())
|
||||
ui_schema_keys = set(result["uiSchema"].keys())
|
||||
|
||||
# uiSchema should have entries for all top-level properties
|
||||
# (intervals may have nested start/end, but the main field should be present)
|
||||
assert ui_schema_keys.issubset(schema_props), (
|
||||
f"{model_name}: uiSchema has keys not in schema: {ui_schema_keys - schema_props}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("model_name,model_cls", list(registry.list_models().items()))
|
||||
def test_groups_is_list_of_strings(self, model_name: str, model_cls: Type[BaseModel]):
|
||||
"""Groups should be a list of strings."""
|
||||
if not issubclass(model_cls, EnviPyModel):
|
||||
pytest.skip(f"{model_name} is not an EnviPyModel")
|
||||
|
||||
result = build_rjsf_output(model_cls)
|
||||
groups = result["groups"]
|
||||
|
||||
assert isinstance(groups, list), f"{model_name}: groups should be list"
|
||||
assert all(isinstance(g, str) for g in groups), (
|
||||
f"{model_name}: all groups should be strings, got {groups}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("model_name,model_cls", list(registry.list_models().items()))
|
||||
def test_form_data_matches_schema(self, model_name: str, model_cls: Type[BaseModel]):
|
||||
"""formData keys should match schema properties."""
|
||||
if not issubclass(model_cls, EnviPyModel):
|
||||
pytest.skip(f"{model_name} is not an EnviPyModel")
|
||||
|
||||
result = build_rjsf_output(model_cls)
|
||||
schema_props = set(result["schema"]["properties"].keys())
|
||||
form_data_keys = set(result["formData"].keys())
|
||||
|
||||
# formData should only contain keys that are in schema
|
||||
assert form_data_keys.issubset(schema_props), (
|
||||
f"{model_name}: formData has keys not in schema: {form_data_keys - schema_props}"
|
||||
)
|
||||
|
||||
|
||||
class TestWidgetTypes:
|
||||
"""Test that widget types are valid."""
|
||||
|
||||
@pytest.mark.parametrize("model_name,model_cls", list(registry.list_models().items()))
|
||||
def test_widget_types_are_valid(self, model_name: str, model_cls: Type[BaseModel]):
|
||||
"""All widget types in uiSchema should be valid WidgetType values."""
|
||||
from envipy_additional_information.ui_config import WidgetType
|
||||
|
||||
if not issubclass(model_cls, EnviPyModel):
|
||||
pytest.skip(f"{model_name} is not an EnviPyModel")
|
||||
|
||||
result = build_rjsf_output(model_cls)
|
||||
valid_widgets = {wt.value for wt in WidgetType}
|
||||
|
||||
for field_name, ui_config in result["uiSchema"].items():
|
||||
widget = ui_config.get("ui:widget")
|
||||
if widget:
|
||||
assert widget in valid_widgets, (
|
||||
f"{model_name}.{field_name}: Invalid widget '{widget}'. Valid: {valid_widgets}"
|
||||
)
|
||||
94
epapi/tests/v1/test_token_auth.py
Normal file
94
epapi/tests/v1/test_token_auth.py
Normal file
@ -0,0 +1,94 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.test import TestCase, tag
|
||||
from django.utils import timezone
|
||||
|
||||
from epdb.logic import PackageManager, UserManager
|
||||
from epdb.models import APIToken
|
||||
|
||||
|
||||
@tag("api", "auth")
|
||||
class BearerTokenAuthTests(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.user = UserManager.create_user(
|
||||
"token-user",
|
||||
"token-user@envipath.com",
|
||||
"SuperSafe",
|
||||
set_setting=False,
|
||||
add_to_group=False,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
default_pkg = cls.user.default_package
|
||||
cls.user.default_package = None
|
||||
cls.user.save()
|
||||
if default_pkg:
|
||||
default_pkg.delete()
|
||||
|
||||
cls.unreviewed_package = PackageManager.create_package(
|
||||
cls.user, "Token Auth Package", "Package for token auth tests"
|
||||
)
|
||||
|
||||
def _auth_header(self, raw_token):
|
||||
return {"HTTP_AUTHORIZATION": f"Bearer {raw_token}"}
|
||||
|
||||
def test_valid_token_allows_access(self):
|
||||
_, raw_token = APIToken.create_token(self.user, name="Valid Token", expires_days=1)
|
||||
|
||||
response = self.client.get("/api/v1/compounds/", **self._auth_header(raw_token))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_expired_token_rejected(self):
|
||||
token, raw_token = APIToken.create_token(self.user, name="Expired Token", expires_days=1)
|
||||
token.expires_at = timezone.now() - timedelta(days=1)
|
||||
token.save(update_fields=["expires_at"])
|
||||
|
||||
response = self.client.get("/api/v1/compounds/", **self._auth_header(raw_token))
|
||||
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_inactive_token_rejected(self):
|
||||
token, raw_token = APIToken.create_token(self.user, name="Inactive Token", expires_days=1)
|
||||
token.is_active = False
|
||||
token.save(update_fields=["is_active"])
|
||||
|
||||
response = self.client.get("/api/v1/compounds/", **self._auth_header(raw_token))
|
||||
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_invalid_token_rejected(self):
|
||||
response = self.client.get("/api/v1/compounds/", HTTP_AUTHORIZATION="Bearer invalid-token")
|
||||
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_no_token_rejected(self):
|
||||
self.client.logout()
|
||||
response = self.client.get("/api/v1/compounds/")
|
||||
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_bearer_populates_request_user_for_packages(self):
|
||||
response = self.client.get("/api/v1/packages/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
uuids = {item["uuid"] for item in payload["items"]}
|
||||
self.assertNotIn(str(self.unreviewed_package.uuid), uuids)
|
||||
|
||||
_, raw_token = APIToken.create_token(self.user, name="Package Token", expires_days=1)
|
||||
response = self.client.get("/api/v1/packages/", **self._auth_header(raw_token))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
uuids = {item["uuid"] for item in payload["items"]}
|
||||
self.assertIn(str(self.unreviewed_package.uuid), uuids)
|
||||
|
||||
def test_session_auth_still_works_without_bearer(self):
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get("/api/v1/packages/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
uuids = {item["uuid"] for item in payload["items"]}
|
||||
self.assertIn(str(self.unreviewed_package.uuid), uuids)
|
||||
0
epapi/utils/__init__.py
Normal file
0
epapi/utils/__init__.py
Normal file
181
epapi/utils/schema_transformers.py
Normal file
181
epapi/utils/schema_transformers.py
Normal file
@ -0,0 +1,181 @@
|
||||
"""
|
||||
Schema transformation utilities for converting Pydantic models to RJSF format.
|
||||
|
||||
This module provides functions to extract UI configuration from Pydantic models
|
||||
and transform them into React JSON Schema Form (RJSF) compatible format.
|
||||
"""
|
||||
|
||||
from typing import Type, Optional, Any
|
||||
|
||||
import jsonref
|
||||
from pydantic import BaseModel
|
||||
|
||||
from envipy_additional_information.ui_config import UIConfig
|
||||
from envipy_additional_information import registry
|
||||
|
||||
|
||||
def extract_groups(model_cls: Type[BaseModel]) -> list[str]:
|
||||
"""
|
||||
Extract groups from registry-stored group information.
|
||||
|
||||
Args:
|
||||
model_cls: The model class
|
||||
|
||||
Returns:
|
||||
List of group names the model belongs to
|
||||
"""
|
||||
return registry.get_groups(model_cls)
|
||||
|
||||
|
||||
def extract_ui_metadata(model_cls: Type[BaseModel]) -> dict[str, Any]:
|
||||
"""
|
||||
Extract model-level UI metadata from UI class.
|
||||
|
||||
Returns metadata attributes that are NOT UIConfig instances.
|
||||
Common metadata includes: unit, description, title.
|
||||
"""
|
||||
metadata: dict[str, Any] = {}
|
||||
|
||||
if not hasattr(model_cls, "UI"):
|
||||
return metadata
|
||||
|
||||
ui_class = getattr(model_cls, "UI")
|
||||
|
||||
# Iterate over all attributes in the UI class
|
||||
for attr_name in dir(ui_class):
|
||||
# Skip private attributes
|
||||
if attr_name.startswith("_"):
|
||||
continue
|
||||
|
||||
# Get the attribute value
|
||||
try:
|
||||
attr_value = getattr(ui_class, attr_name)
|
||||
except AttributeError:
|
||||
continue
|
||||
|
||||
# Skip callables but keep types/classes
|
||||
if callable(attr_value) and not isinstance(attr_value, type):
|
||||
continue
|
||||
|
||||
# Skip UIConfig instances (these are field-level configs, not metadata)
|
||||
# This includes both UIConfig and IntervalConfig
|
||||
if isinstance(attr_value, UIConfig):
|
||||
continue
|
||||
|
||||
metadata[attr_name] = attr_value
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
def extract_ui_config_from_model(model_cls: Type[BaseModel]) -> dict[str, Any]:
|
||||
"""
|
||||
Extract UI configuration from model's UI class.
|
||||
|
||||
Returns a dictionary mapping field names to their UI schema configurations.
|
||||
Trusts the config classes to handle their own transformation logic.
|
||||
"""
|
||||
ui_configs: dict[str, Any] = {}
|
||||
|
||||
if not hasattr(model_cls, "UI"):
|
||||
return ui_configs
|
||||
|
||||
ui_class = getattr(model_cls, "UI")
|
||||
schema = model_cls.model_json_schema()
|
||||
field_names = schema.get("properties", {}).keys()
|
||||
|
||||
# Extract config for each field
|
||||
for field_name in field_names:
|
||||
# Skip if UI config doesn't exist for this field (field may be hidden from UI)
|
||||
if not hasattr(ui_class, field_name):
|
||||
continue
|
||||
|
||||
ui_config = getattr(ui_class, field_name)
|
||||
|
||||
if isinstance(ui_config, UIConfig):
|
||||
ui_configs[field_name] = ui_config.to_ui_schema_field()
|
||||
|
||||
return ui_configs
|
||||
|
||||
|
||||
def build_ui_schema(model_cls: Type[BaseModel]) -> dict:
|
||||
"""Generate RJSF uiSchema from model's UI class."""
|
||||
ui_schema = {}
|
||||
|
||||
# Extract field-level UI configs
|
||||
field_configs = extract_ui_config_from_model(model_cls)
|
||||
|
||||
for field_name, config in field_configs.items():
|
||||
ui_schema[field_name] = config
|
||||
|
||||
return ui_schema
|
||||
|
||||
|
||||
def build_schema(model_cls: Type[BaseModel]) -> dict[str, Any]:
|
||||
"""
|
||||
Build JSON schema from Pydantic model, applying UI metadata.
|
||||
|
||||
Dereferences all $ref pointers to produce fully inlined schema.
|
||||
This ensures the frontend receives schemas with enum values and nested
|
||||
properties fully resolved, without needing client-side ref resolution.
|
||||
|
||||
Extracts model-level metadata from UI class (title, unit, etc.) and applies
|
||||
it to the generated schema. This ensures UI metadata is the single source of truth.
|
||||
"""
|
||||
schema = model_cls.model_json_schema()
|
||||
|
||||
# Dereference $ref pointers (inlines $defs) using jsonref
|
||||
# This ensures the frontend receives schemas with enum values and nested
|
||||
# properties fully resolved, currently necessary for client-side rendering.
|
||||
# FIXME: This is a hack to get the schema to work with alpine schema-form.js replace once we migrate to client-side framework.
|
||||
schema = jsonref.replace_refs(schema, proxies=False)
|
||||
|
||||
# Remove $defs section as all refs are now inlined
|
||||
if "$defs" in schema:
|
||||
del schema["$defs"]
|
||||
|
||||
# Extract and apply UI metadata (title, unit, description, etc.)
|
||||
ui_metadata = extract_ui_metadata(model_cls)
|
||||
|
||||
# Apply all metadata consistently as custom properties with x- prefix
|
||||
# This ensures consistency and avoids conflicts with standard JSON Schema properties
|
||||
for key, value in ui_metadata.items():
|
||||
if value is not None:
|
||||
schema[f"x-{key}"] = value
|
||||
|
||||
# Set standard title property from UI metadata for JSON Schema compliance
|
||||
if "title" in ui_metadata:
|
||||
schema["title"] = ui_metadata["title"]
|
||||
elif "label" in ui_metadata:
|
||||
schema["title"] = ui_metadata["label"]
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
def build_rjsf_output(model_cls: Type[BaseModel], initial_data: Optional[dict] = None) -> dict:
|
||||
"""
|
||||
Main function that returns complete RJSF format.
|
||||
|
||||
Trusts the config classes to handle their own transformation logic.
|
||||
No special-case handling - if a config knows how to transform itself, it will.
|
||||
|
||||
Returns:
|
||||
dict with keys: schema, uiSchema, formData, groups
|
||||
"""
|
||||
# Build schema with UI metadata applied
|
||||
schema = build_schema(model_cls)
|
||||
|
||||
# Build UI schema - config classes handle their own transformation
|
||||
ui_schema = build_ui_schema(model_cls)
|
||||
|
||||
# Extract groups from marker interfaces
|
||||
groups = extract_groups(model_cls)
|
||||
|
||||
# Use provided initial_data or empty dict
|
||||
form_data = initial_data if initial_data is not None else {}
|
||||
|
||||
return {
|
||||
"schema": schema,
|
||||
"uiSchema": ui_schema,
|
||||
"formData": form_data,
|
||||
"groups": groups,
|
||||
}
|
||||
82
epapi/utils/validation_errors.py
Normal file
82
epapi/utils/validation_errors.py
Normal file
@ -0,0 +1,82 @@
|
||||
"""Shared utilities for handling Pydantic validation errors."""
|
||||
|
||||
import json
|
||||
from pydantic import ValidationError
|
||||
from pydantic_core import ErrorDetails
|
||||
from ninja.errors import HttpError
|
||||
|
||||
|
||||
def format_validation_error(error: ErrorDetails) -> str:
|
||||
"""Format a Pydantic validation error into a user-friendly message.
|
||||
|
||||
Args:
|
||||
error: A Pydantic error details dictionary containing 'msg', 'type', 'ctx', etc.
|
||||
|
||||
Returns:
|
||||
A user-friendly error message string.
|
||||
"""
|
||||
msg = error.get("msg") or "Invalid value"
|
||||
error_type = error.get("type") or ""
|
||||
|
||||
# Handle common validation types with friendly messages
|
||||
if error_type == "enum":
|
||||
ctx = error.get("ctx", {})
|
||||
expected = ctx.get("expected", "") if ctx else ""
|
||||
return f"Please select a valid option{': ' + expected if expected else ''}"
|
||||
elif error_type == "literal_error":
|
||||
# Literal errors (like Literal["active", "inactive"])
|
||||
return msg.replace("Input should be ", "Please enter ")
|
||||
elif error_type == "missing":
|
||||
return "This field is required"
|
||||
elif error_type == "string_type":
|
||||
return "Please enter a valid string"
|
||||
elif error_type == "int_type":
|
||||
return "Please enter a valid int"
|
||||
elif error_type == "int_parsing":
|
||||
return "Please enter a valid int"
|
||||
elif error_type == "float_type":
|
||||
return "Please enter a valid float"
|
||||
elif error_type == "float_parsing":
|
||||
return "Please enter a valid float"
|
||||
elif error_type == "value_error":
|
||||
# Strip "Value error, " prefix from custom validator messages
|
||||
return msg.replace("Value error, ", "")
|
||||
else:
|
||||
# Default: use the message from Pydantic but clean it up
|
||||
return msg.replace("Input should be ", "Please enter ").replace("Value error, ", "")
|
||||
|
||||
|
||||
def handle_validation_error(e: ValidationError) -> None:
|
||||
"""Convert a Pydantic ValidationError into a structured HttpError.
|
||||
|
||||
This function transforms Pydantic validation errors into a JSON structure
|
||||
that the frontend expects for displaying field-level errors.
|
||||
|
||||
Args:
|
||||
e: The Pydantic ValidationError to handle.
|
||||
|
||||
Raises:
|
||||
HttpError: Always raises a 400 error with structured JSON containing
|
||||
type, field_errors, and message fields.
|
||||
"""
|
||||
# Transform Pydantic validation errors into user-friendly format
|
||||
field_errors: dict[str, list[str]] = {}
|
||||
for error in e.errors():
|
||||
# Get the field name from location tuple
|
||||
loc = error.get("loc", ())
|
||||
field = str(loc[-1]) if loc else "root"
|
||||
|
||||
# Format the error message
|
||||
friendly_msg = format_validation_error(error)
|
||||
|
||||
if field not in field_errors:
|
||||
field_errors[field] = []
|
||||
field_errors[field].append(friendly_msg)
|
||||
|
||||
# Return structured error for frontend parsing
|
||||
error_response = {
|
||||
"type": "validation_error",
|
||||
"field_errors": field_errors,
|
||||
"message": "Please correct the errors below",
|
||||
}
|
||||
raise HttpError(400, json.dumps(error_response))
|
||||
@ -1,8 +1,34 @@
|
||||
import hashlib
|
||||
|
||||
from ninja.security import HttpBearer
|
||||
from ninja.errors import HttpError
|
||||
|
||||
from epdb.models import APIToken
|
||||
|
||||
|
||||
class BearerTokenAuth(HttpBearer):
|
||||
def authenticate(self, request, token):
|
||||
# FIXME: placeholder; implement it in O(1) time
|
||||
raise HttpError(401, "Invalid or expired token")
|
||||
if token is None:
|
||||
return None
|
||||
|
||||
hashed_token = hashlib.sha256(token.encode()).hexdigest()
|
||||
user = APIToken.authenticate(hashed_token, hashed=True)
|
||||
if not user:
|
||||
raise HttpError(401, "Invalid or expired token")
|
||||
|
||||
request.user = user
|
||||
return user
|
||||
|
||||
|
||||
class OptionalBearerTokenAuth:
|
||||
"""Bearer auth that allows unauthenticated access.
|
||||
|
||||
Validates the Bearer token if present (401 on invalid token),
|
||||
otherwise lets the request through for anonymous/session access.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._bearer = BearerTokenAuth()
|
||||
|
||||
def __call__(self, request):
|
||||
return self._bearer(request) or request.user
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
from uuid import UUID
|
||||
|
||||
from django.conf import settings as s
|
||||
from django.db.models import Model
|
||||
from epdb.logic import PackageManager
|
||||
from epdb.models import CompoundStructure, User, Package, Compound
|
||||
from uuid import UUID
|
||||
from epdb.models import CompoundStructure, User, Compound, Scenario
|
||||
|
||||
from .errors import EPAPINotFoundError, EPAPIPermissionDeniedError
|
||||
|
||||
Package = s.GET_PACKAGE_MODEL()
|
||||
|
||||
def get_compound_or_error(user, compound_uuid: UUID):
|
||||
|
||||
def get_compound_for_read(user, compound_uuid: UUID):
|
||||
"""
|
||||
Get compound by UUID with permission check.
|
||||
"""
|
||||
@ -23,7 +27,7 @@ def get_compound_or_error(user, compound_uuid: UUID):
|
||||
return compound
|
||||
|
||||
|
||||
def get_package_or_error(user, package_uuid: UUID):
|
||||
def get_package_for_read(user, package_uuid: UUID):
|
||||
"""
|
||||
Get package by UUID with permission check.
|
||||
"""
|
||||
@ -41,14 +45,58 @@ def get_package_or_error(user, package_uuid: UUID):
|
||||
return package
|
||||
|
||||
|
||||
def get_user_packages_qs(user: User | None):
|
||||
def get_package_for_write(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.writable(user, package):
|
||||
raise EPAPIPermissionDeniedError("Insufficient permissions to access this package.")
|
||||
|
||||
return package
|
||||
|
||||
|
||||
def get_scenario_for_read(user, scenario_uuid: UUID):
|
||||
"""Get scenario by UUID with read permission check."""
|
||||
try:
|
||||
scenario = Scenario.objects.select_related("package").get(uuid=scenario_uuid)
|
||||
except Scenario.DoesNotExist:
|
||||
raise EPAPINotFoundError(f"Scenario with UUID {scenario_uuid} not found")
|
||||
|
||||
if not user or user.is_anonymous or not PackageManager.readable(user, scenario.package):
|
||||
raise EPAPIPermissionDeniedError("Insufficient permissions to access this scenario.")
|
||||
|
||||
return scenario
|
||||
|
||||
|
||||
def get_scenario_for_write(user, scenario_uuid: UUID):
|
||||
"""Get scenario by UUID with write permission check."""
|
||||
try:
|
||||
scenario = Scenario.objects.select_related("package").get(uuid=scenario_uuid)
|
||||
except Scenario.DoesNotExist:
|
||||
raise EPAPINotFoundError(f"Scenario with UUID {scenario_uuid} not found")
|
||||
|
||||
if not user or user.is_anonymous or not PackageManager.writable(user, scenario.package):
|
||||
raise EPAPIPermissionDeniedError("Insufficient permissions to modify this scenario.")
|
||||
|
||||
return scenario
|
||||
|
||||
|
||||
def get_user_packages_for_read(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):
|
||||
def get_user_entities_for_read(model_class: Model, user: User | None):
|
||||
"""Build queryset for reviewed package entities."""
|
||||
|
||||
if not user or user.is_anonymous:
|
||||
@ -60,16 +108,14 @@ def get_user_entities_qs(model_class: Model, user: User | None):
|
||||
return qs
|
||||
|
||||
|
||||
def get_package_scoped_entities_qs(
|
||||
model_class: Model, package_uuid: UUID, user: User | None = None
|
||||
):
|
||||
def get_package_entities_for_read(model_class: Model, package_uuid: UUID, user: User | None = None):
|
||||
"""Build queryset for specific package entities."""
|
||||
package = get_package_or_error(user, package_uuid)
|
||||
package = get_package_for_read(user, package_uuid)
|
||||
qs = model_class.objects.filter(package=package).select_related("package")
|
||||
return qs
|
||||
|
||||
|
||||
def get_user_structures_qs(user: User | None):
|
||||
def get_user_structure_for_read(user: User | None):
|
||||
"""Build queryset for structures accessible to the user (via compound->package)."""
|
||||
|
||||
if not user or user.is_anonymous:
|
||||
@ -83,13 +129,13 @@ def get_user_structures_qs(user: User | None):
|
||||
return qs
|
||||
|
||||
|
||||
def get_package_compound_scoped_structure_qs(
|
||||
def get_package_compound_structure_for_read(
|
||||
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)
|
||||
get_package_for_read(user, package_uuid)
|
||||
compound = get_compound_for_read(user, compound_uuid)
|
||||
|
||||
qs = CompoundStructure.objects.filter(compound=compound).select_related("compound__package")
|
||||
return qs
|
||||
|
||||
174
epapi/v1/endpoints/additional_information.py
Normal file
174
epapi/v1/endpoints/additional_information.py
Normal file
@ -0,0 +1,174 @@
|
||||
from ninja import Router, Body
|
||||
from ninja.errors import HttpError
|
||||
from uuid import UUID
|
||||
from pydantic import ValidationError
|
||||
from typing import Dict, Any
|
||||
import logging
|
||||
|
||||
from envipy_additional_information import registry
|
||||
from envipy_additional_information.groups import GroupEnum
|
||||
from epapi.utils.schema_transformers import build_rjsf_output
|
||||
from epapi.utils.validation_errors import handle_validation_error
|
||||
from epdb.models import AdditionalInformation
|
||||
from ..dal import get_scenario_for_read, get_scenario_for_write
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = Router(tags=["Additional Information"])
|
||||
|
||||
|
||||
@router.get("/information/schema/")
|
||||
def list_all_schemas(request):
|
||||
"""Return all schemas in RJSF format with lowercase class names as keys."""
|
||||
result = {}
|
||||
for name, cls in registry.list_models().items():
|
||||
try:
|
||||
result[name] = build_rjsf_output(cls)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to generate schema for {name}: {e}")
|
||||
continue
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/information/schema/{model_name}/")
|
||||
def get_model_schema(request, model_name: str):
|
||||
"""Return RJSF schema for specific model."""
|
||||
cls = registry.get_model(model_name.lower())
|
||||
if not cls:
|
||||
raise HttpError(404, f"Unknown model: {model_name}")
|
||||
return build_rjsf_output(cls)
|
||||
|
||||
|
||||
@router.get("/scenario/{uuid:scenario_uuid}/information/")
|
||||
def list_scenario_info(request, scenario_uuid: UUID):
|
||||
"""List all additional information for a scenario"""
|
||||
scenario = get_scenario_for_read(request.user, scenario_uuid)
|
||||
|
||||
result = []
|
||||
|
||||
for ai in AdditionalInformation.objects.filter(scenario=scenario):
|
||||
result.append(
|
||||
{
|
||||
"type": ai.get().__class__.__name__,
|
||||
"uuid": getattr(ai, "uuid", None),
|
||||
"data": ai.data,
|
||||
"attach_object": ai.content_object.simple_json() if ai.content_object else None,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/scenario/{uuid:scenario_uuid}/information/{model_name}/")
|
||||
def add_scenario_info(
|
||||
request, scenario_uuid: UUID, model_name: str, payload: Dict[str, Any] = Body(...)
|
||||
):
|
||||
"""Add new additional information to scenario"""
|
||||
cls = registry.get_model(model_name.lower())
|
||||
if not cls:
|
||||
raise HttpError(404, f"Unknown model: {model_name}")
|
||||
|
||||
try:
|
||||
instance = cls(**payload) # Pydantic validates
|
||||
except ValidationError as e:
|
||||
handle_validation_error(e)
|
||||
|
||||
scenario = get_scenario_for_write(request.user, scenario_uuid)
|
||||
|
||||
# Model method now returns the UUID
|
||||
created_uuid = scenario.add_additional_information(instance)
|
||||
|
||||
return {"status": "created", "uuid": created_uuid}
|
||||
|
||||
|
||||
@router.patch("/scenario/{uuid:scenario_uuid}/information/item/{uuid:ai_uuid}/")
|
||||
def update_scenario_info(
|
||||
request, scenario_uuid: UUID, ai_uuid: UUID, payload: Dict[str, Any] = Body(...)
|
||||
):
|
||||
"""Update existing additional information for a scenario"""
|
||||
scenario = get_scenario_for_write(request.user, scenario_uuid)
|
||||
ai_uuid_str = str(ai_uuid)
|
||||
|
||||
ai = AdditionalInformation.objects.filter(uuid=ai_uuid_str, scenario=scenario)
|
||||
|
||||
if not ai.exists():
|
||||
raise HttpError(404, f"Additional information with UUID {ai_uuid} not found")
|
||||
|
||||
ai = ai.first()
|
||||
|
||||
# Get the model class for validation
|
||||
cls = registry.get_model(ai.type.lower())
|
||||
if not cls:
|
||||
raise HttpError(500, f"Unknown model type in data: {ai.type}")
|
||||
|
||||
# Validate the payload against the model
|
||||
try:
|
||||
instance = cls(**payload)
|
||||
except ValidationError as e:
|
||||
handle_validation_error(e)
|
||||
|
||||
# Use model method for update
|
||||
try:
|
||||
scenario.update_additional_information(ai_uuid_str, instance)
|
||||
except ValueError as e:
|
||||
raise HttpError(404, str(e))
|
||||
|
||||
return {"status": "updated", "uuid": ai_uuid_str}
|
||||
|
||||
|
||||
@router.delete("/scenario/{uuid:scenario_uuid}/information/item/{uuid:ai_uuid}/")
|
||||
def delete_scenario_info(request, scenario_uuid: UUID, ai_uuid: UUID):
|
||||
"""Delete additional information from scenario"""
|
||||
scenario = get_scenario_for_write(request.user, scenario_uuid)
|
||||
|
||||
try:
|
||||
scenario.remove_additional_information(str(ai_uuid))
|
||||
except ValueError as e:
|
||||
raise HttpError(404, str(e))
|
||||
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.get("/information/groups/")
|
||||
def list_groups(request):
|
||||
"""Return list of available group names."""
|
||||
return {"groups": GroupEnum.values()}
|
||||
|
||||
|
||||
@router.get("/information/groups/{group_name}/")
|
||||
def get_group_models(request, group_name: str):
|
||||
"""
|
||||
Return models for a specific group organized by subcategory.
|
||||
|
||||
Args:
|
||||
group_name: One of "sludge", "soil", or "sediment" (string)
|
||||
|
||||
Returns:
|
||||
Dictionary with subcategories (exp, spike, comp, misc, or group name)
|
||||
as keys and lists of model info as values
|
||||
"""
|
||||
# Convert string to enum (raises ValueError if invalid)
|
||||
try:
|
||||
group_enum = GroupEnum(group_name)
|
||||
except ValueError:
|
||||
valid = ", ".join(GroupEnum.values())
|
||||
raise HttpError(400, f"Invalid group '{group_name}'. Valid: {valid}")
|
||||
|
||||
try:
|
||||
group_data = registry.collect_group(group_enum)
|
||||
except (ValueError, TypeError) as e:
|
||||
raise HttpError(400, str(e))
|
||||
|
||||
result = {}
|
||||
for subcategory, models in group_data.items():
|
||||
result[subcategory] = [
|
||||
{
|
||||
"name": cls.__name__.lower(),
|
||||
"class": cls.__name__,
|
||||
"title": getattr(cls.UI, "title", cls.__name__)
|
||||
if hasattr(cls, "UI")
|
||||
else cls.__name__,
|
||||
}
|
||||
for cls in models
|
||||
]
|
||||
|
||||
return result
|
||||
@ -6,7 +6,7 @@ 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
|
||||
from ..dal import get_user_entities_for_read, get_package_entities_for_read
|
||||
|
||||
router = Router()
|
||||
|
||||
@ -21,7 +21,7 @@ def list_all_compounds(request):
|
||||
"""
|
||||
List all compounds from reviewed packages.
|
||||
"""
|
||||
return get_user_entities_qs(Compound, request.user).order_by("name").all()
|
||||
return get_user_entities_for_read(Compound, request.user).order_by("name").all()
|
||||
|
||||
|
||||
@router.get(
|
||||
@ -38,4 +38,4 @@ 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()
|
||||
return get_package_entities_for_read(Compound, package_uuid, user).order_by("name").all()
|
||||
|
||||
23
epapi/v1/endpoints/groups.py
Normal file
23
epapi/v1/endpoints/groups.py
Normal file
@ -0,0 +1,23 @@
|
||||
from django.conf import settings as s
|
||||
from ninja import Router
|
||||
from ninja_extra.pagination import paginate
|
||||
|
||||
from epdb.logic import GroupManager
|
||||
|
||||
from ..pagination import EnhancedPageNumberPagination
|
||||
from ..schemas import GroupOutSchema
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.get("/groups/", response=EnhancedPageNumberPagination.Output[GroupOutSchema])
|
||||
@paginate(
|
||||
EnhancedPageNumberPagination,
|
||||
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
|
||||
)
|
||||
def list_all_groups(request):
|
||||
"""
|
||||
List all groups the user has access to.
|
||||
"""
|
||||
user = request.user
|
||||
return GroupManager.get_groups(user)
|
||||
@ -6,7 +6,7 @@ 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
|
||||
from ..dal import get_user_entities_for_read, get_package_entities_for_read
|
||||
|
||||
router = Router()
|
||||
|
||||
@ -21,7 +21,7 @@ def list_all_models(request):
|
||||
"""
|
||||
List all models from reviewed packages.
|
||||
"""
|
||||
return get_user_entities_qs(EPModel, request.user).order_by("name").all()
|
||||
return get_user_entities_for_read(EPModel, request.user).order_by("name").all()
|
||||
|
||||
|
||||
@router.get(
|
||||
@ -38,4 +38,4 @@ 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()
|
||||
return get_package_entities_for_read(EPModel, package_uuid, user).order_by("name").all()
|
||||
|
||||
@ -3,7 +3,8 @@ from ninja import Router
|
||||
from ninja_extra.pagination import paginate
|
||||
import logging
|
||||
|
||||
from ..dal import get_user_packages_qs
|
||||
from ..auth import OptionalBearerTokenAuth
|
||||
from ..dal import get_user_packages_for_read
|
||||
from ..pagination import EnhancedPageNumberPagination
|
||||
from ..schemas import PackageOutSchema, SelfReviewStatusFilter
|
||||
|
||||
@ -11,7 +12,11 @@ router = Router()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("/packages/", response=EnhancedPageNumberPagination.Output[PackageOutSchema], auth=None)
|
||||
@router.get(
|
||||
"/packages/",
|
||||
response=EnhancedPageNumberPagination.Output[PackageOutSchema],
|
||||
auth=OptionalBearerTokenAuth(),
|
||||
)
|
||||
@paginate(
|
||||
EnhancedPageNumberPagination,
|
||||
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
|
||||
@ -23,5 +28,5 @@ def list_all_packages(request):
|
||||
|
||||
"""
|
||||
user = request.user
|
||||
qs = get_user_packages_qs(user)
|
||||
qs = get_user_packages_for_read(user)
|
||||
return qs.order_by("name").all()
|
||||
|
||||
@ -6,7 +6,7 @@ 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
|
||||
from ..dal import get_user_entities_for_read, get_package_entities_for_read
|
||||
|
||||
router = Router()
|
||||
|
||||
@ -22,7 +22,7 @@ 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()
|
||||
return get_user_entities_for_read(Pathway, user).order_by("name").all()
|
||||
|
||||
|
||||
@router.get(
|
||||
@ -39,4 +39,4 @@ 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()
|
||||
return get_package_entities_for_read(Pathway, package_uuid, user).order_by("name").all()
|
||||
|
||||
@ -6,7 +6,7 @@ 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
|
||||
from ..dal import get_user_entities_for_read, get_package_entities_for_read
|
||||
|
||||
router = Router()
|
||||
|
||||
@ -22,7 +22,7 @@ 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()
|
||||
return get_user_entities_for_read(Reaction, user).order_by("name").all()
|
||||
|
||||
|
||||
@router.get(
|
||||
@ -39,4 +39,4 @@ 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()
|
||||
return get_package_entities_for_read(Reaction, package_uuid, user).order_by("name").all()
|
||||
|
||||
@ -6,7 +6,7 @@ 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
|
||||
from ..dal import get_user_entities_for_read, get_package_entities_for_read
|
||||
|
||||
router = Router()
|
||||
|
||||
@ -22,7 +22,7 @@ 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()
|
||||
return get_user_entities_for_read(Rule, user).order_by("name").all()
|
||||
|
||||
|
||||
@router.get(
|
||||
@ -39,4 +39,4 @@ 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()
|
||||
return get_package_entities_for_read(Rule, package_uuid, user).order_by("name").all()
|
||||
|
||||
@ -1,12 +1,25 @@
|
||||
from django.conf import settings as s
|
||||
from ninja import Router
|
||||
from django.db import IntegrityError, OperationalError, DatabaseError
|
||||
from ninja import Router, Body
|
||||
from ninja.errors import HttpError
|
||||
from ninja_extra.pagination import paginate
|
||||
from uuid import UUID
|
||||
from pydantic import ValidationError
|
||||
import logging
|
||||
import json
|
||||
|
||||
from epdb.models import Scenario
|
||||
from epdb.views import _anonymous_or_real
|
||||
from ..pagination import EnhancedPageNumberPagination
|
||||
from ..schemas import ReviewStatusFilter, ScenarioOutSchema
|
||||
from ..dal import get_user_entities_qs, get_package_scoped_entities_qs
|
||||
from ..schemas import (
|
||||
ReviewStatusFilter,
|
||||
ScenarioOutSchema,
|
||||
ScenarioCreateSchema,
|
||||
)
|
||||
from ..dal import get_user_entities_for_read, get_package_entities_for_read, get_package_for_write
|
||||
from envipy_additional_information import registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = Router()
|
||||
|
||||
@ -19,7 +32,8 @@ router = Router()
|
||||
)
|
||||
def list_all_scenarios(request):
|
||||
user = request.user
|
||||
return get_user_entities_qs(Scenario, user).order_by("name").all()
|
||||
items = get_user_entities_for_read(Scenario, user)
|
||||
return items.order_by("name").all()
|
||||
|
||||
|
||||
@router.get(
|
||||
@ -33,4 +47,83 @@ def list_all_scenarios(request):
|
||||
)
|
||||
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()
|
||||
items = get_package_entities_for_read(Scenario, package_uuid, user)
|
||||
return items.order_by("name").all()
|
||||
|
||||
|
||||
@router.post("/package/{uuid:package_uuid}/scenario/", response=ScenarioOutSchema)
|
||||
def create_scenario(request, package_uuid: UUID, payload: ScenarioCreateSchema = Body(...)):
|
||||
"""Create a new scenario with optional additional information."""
|
||||
user = _anonymous_or_real(request)
|
||||
|
||||
try:
|
||||
current_package = get_package_for_write(user, package_uuid)
|
||||
except ValueError as e:
|
||||
error_msg = str(e)
|
||||
if "does not exist" in error_msg:
|
||||
raise HttpError(404, f"Package not found: {package_uuid}")
|
||||
elif "Insufficient permissions" in error_msg:
|
||||
raise HttpError(403, "You do not have permission to access this package")
|
||||
else:
|
||||
logger.error(f"Unexpected ValueError from get_package_by_id: {error_msg}")
|
||||
raise HttpError(400, "Invalid package request")
|
||||
|
||||
# Build additional information models from payload
|
||||
additional_information_models = []
|
||||
validation_errors = []
|
||||
|
||||
for ai_item in payload.additional_information:
|
||||
# Get model class from registry
|
||||
model_cls = registry.get_model(ai_item.type.lower())
|
||||
if not model_cls:
|
||||
validation_errors.append(f"Unknown additional information type: {ai_item.type}")
|
||||
continue
|
||||
|
||||
try:
|
||||
# Validate and create model instance
|
||||
instance = model_cls(**ai_item.data)
|
||||
additional_information_models.append(instance)
|
||||
except ValidationError as e:
|
||||
# Collect validation errors to return to user
|
||||
error_messages = [err.get("msg", "Validation error") for err in e.errors()]
|
||||
validation_errors.append(f"{ai_item.type}: {', '.join(error_messages)}")
|
||||
except (TypeError, AttributeError, KeyError) as e:
|
||||
logger.warning(f"Failed to instantiate {ai_item.type} model: {str(e)}")
|
||||
validation_errors.append(f"{ai_item.type}: Invalid data structure - {str(e)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error instantiating {ai_item.type}: {str(e)}")
|
||||
validation_errors.append(f"{ai_item.type}: Failed to process - please check your data")
|
||||
|
||||
# If there are validation errors, return them
|
||||
if validation_errors:
|
||||
raise HttpError(
|
||||
400,
|
||||
json.dumps(
|
||||
{
|
||||
"error": "Validation errors in additional information",
|
||||
"details": validation_errors,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
# Create scenario using the existing Scenario.create method
|
||||
try:
|
||||
new_scenario = Scenario.create(
|
||||
package=current_package,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
scenario_date=payload.scenario_date,
|
||||
scenario_type=payload.scenario_type,
|
||||
additional_information=additional_information_models,
|
||||
)
|
||||
except IntegrityError as e:
|
||||
logger.error(f"Database integrity error creating scenario: {str(e)}")
|
||||
raise HttpError(400, "Scenario creation failed - data constraint violation")
|
||||
except OperationalError as e:
|
||||
logger.error(f"Database operational error creating scenario: {str(e)}")
|
||||
raise HttpError(503, "Database temporarily unavailable - please try again")
|
||||
except (DatabaseError, AttributeError) as e:
|
||||
logger.error(f"Error creating scenario: {str(e)}")
|
||||
raise HttpError(500, "Failed to create scenario due to database error")
|
||||
|
||||
return new_scenario
|
||||
|
||||
23
epapi/v1/endpoints/settings.py
Normal file
23
epapi/v1/endpoints/settings.py
Normal file
@ -0,0 +1,23 @@
|
||||
from django.conf import settings as s
|
||||
from ninja import Router
|
||||
from ninja_extra.pagination import paginate
|
||||
|
||||
from epdb.logic import SettingManager
|
||||
|
||||
from ..pagination import EnhancedPageNumberPagination
|
||||
from ..schemas import SettingOutSchema
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.get("/settings/", response=EnhancedPageNumberPagination.Output[SettingOutSchema])
|
||||
@paginate(
|
||||
EnhancedPageNumberPagination,
|
||||
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
|
||||
)
|
||||
def list_all_settings(request):
|
||||
"""
|
||||
List all settings the user has access to.
|
||||
"""
|
||||
user = request.user
|
||||
return SettingManager.get_all_settings(user)
|
||||
@ -6,8 +6,8 @@ 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,
|
||||
get_user_structure_for_read,
|
||||
get_package_compound_structure_for_read,
|
||||
)
|
||||
|
||||
router = Router()
|
||||
@ -26,7 +26,7 @@ def list_all_structures(request):
|
||||
List all structures from all packages.
|
||||
"""
|
||||
user = request.user
|
||||
return get_user_structures_qs(user).order_by("name").all()
|
||||
return get_user_structure_for_read(user).order_by("name").all()
|
||||
|
||||
|
||||
@router.get(
|
||||
@ -44,7 +44,7 @@ def list_package_structures(request, package_uuid: UUID, compound_uuid: UUID):
|
||||
"""
|
||||
user = request.user
|
||||
return (
|
||||
get_package_compound_scoped_structure_qs(package_uuid, compound_uuid, user)
|
||||
get_package_compound_structure_for_read(package_uuid, compound_uuid, user)
|
||||
.order_by("name")
|
||||
.all()
|
||||
)
|
||||
|
||||
3
epapi/v1/interfaces/__init__.py
Normal file
3
epapi/v1/interfaces/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""
|
||||
Service interfaces: each subdirectory defines the full boundary contract between enviPy and feature-flagged apps. DTOs and projections are shared concerns to avoid direct ORM access.
|
||||
"""
|
||||
0
epapi/v1/interfaces/iuclid/__init__.py
Normal file
0
epapi/v1/interfaces/iuclid/__init__.py
Normal file
58
epapi/v1/interfaces/iuclid/dto.py
Normal file
58
epapi/v1/interfaces/iuclid/dto.py
Normal file
@ -0,0 +1,58 @@
|
||||
from dataclasses import dataclass, field
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PathwayCompoundDTO:
|
||||
pk: int
|
||||
name: str
|
||||
smiles: str | None = None
|
||||
cas_number: str | None = None
|
||||
ec_number: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PathwayScenarioDTO:
|
||||
scenario_uuid: UUID
|
||||
name: str
|
||||
additional_info: list = field(default_factory=list) # EnviPyModel instances
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PathwayNodeDTO:
|
||||
node_uuid: UUID
|
||||
compound_pk: int
|
||||
name: str
|
||||
depth: int
|
||||
smiles: str | None = None
|
||||
cas_number: str | None = None
|
||||
ec_number: str | None = None
|
||||
additional_info: list = field(default_factory=list) # EnviPyModel instances
|
||||
scenarios: list[PathwayScenarioDTO] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PathwayEdgeDTO:
|
||||
edge_uuid: UUID
|
||||
start_compound_pks: list[int] = field(default_factory=list)
|
||||
end_compound_pks: list[int] = field(default_factory=list)
|
||||
probability: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PathwayModelInfoDTO:
|
||||
model_name: str | None = None
|
||||
model_uuid: UUID | None = None
|
||||
software_name: str | None = None
|
||||
software_version: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PathwayExportDTO:
|
||||
pathway_uuid: UUID
|
||||
pathway_name: str
|
||||
compounds: list[PathwayCompoundDTO] = field(default_factory=list)
|
||||
nodes: list[PathwayNodeDTO] = field(default_factory=list)
|
||||
edges: list[PathwayEdgeDTO] = field(default_factory=list)
|
||||
root_compound_pks: list[int] = field(default_factory=list)
|
||||
model_info: PathwayModelInfoDTO | None = None
|
||||
142
epapi/v1/interfaces/iuclid/projections.py
Normal file
142
epapi/v1/interfaces/iuclid/projections.py
Normal file
@ -0,0 +1,142 @@
|
||||
from uuid import UUID
|
||||
|
||||
from epdb.logic import PackageManager
|
||||
from epdb.models import Pathway
|
||||
|
||||
from epapi.v1.errors import EPAPINotFoundError, EPAPIPermissionDeniedError
|
||||
|
||||
from .dto import (
|
||||
PathwayCompoundDTO,
|
||||
PathwayEdgeDTO,
|
||||
PathwayExportDTO,
|
||||
PathwayModelInfoDTO,
|
||||
PathwayNodeDTO,
|
||||
PathwayScenarioDTO,
|
||||
)
|
||||
|
||||
|
||||
def get_pathway_for_iuclid_export(user, pathway_uuid: UUID) -> PathwayExportDTO:
|
||||
"""Return pathway data projected into DTOs for the IUCLID export consumer."""
|
||||
try:
|
||||
pathway = (
|
||||
Pathway.objects.select_related("package", "setting", "setting__model")
|
||||
.prefetch_related(
|
||||
"node_set__default_node_label__compound__external_identifiers__database",
|
||||
"node_set__scenarios",
|
||||
"edge_set__start_nodes__default_node_label__compound",
|
||||
"edge_set__end_nodes__default_node_label__compound",
|
||||
)
|
||||
.get(uuid=pathway_uuid)
|
||||
)
|
||||
except Pathway.DoesNotExist:
|
||||
raise EPAPINotFoundError(f"Pathway with UUID {pathway_uuid} not found")
|
||||
|
||||
if not user or user.is_anonymous or not PackageManager.readable(user, pathway.package):
|
||||
raise EPAPIPermissionDeniedError("Insufficient permissions to access this pathway.")
|
||||
|
||||
nodes: list[PathwayNodeDTO] = []
|
||||
edges: list[PathwayEdgeDTO] = []
|
||||
compounds_by_pk: dict[int, PathwayCompoundDTO] = {}
|
||||
root_compound_pks: list[int] = []
|
||||
|
||||
for node in pathway.node_set.all().order_by("depth", "pk"):
|
||||
cs = node.default_node_label
|
||||
if cs is None:
|
||||
continue
|
||||
compound = cs.compound
|
||||
|
||||
cas_number = None
|
||||
ec_number = None
|
||||
for ext_id in compound.external_identifiers.all():
|
||||
db_name = ext_id.database.name if ext_id.database else None
|
||||
if db_name == "CAS" and cas_number is None:
|
||||
cas_number = ext_id.identifier_value
|
||||
elif db_name == "EC" and ec_number is None:
|
||||
ec_number = ext_id.identifier_value
|
||||
|
||||
ai_for_node = []
|
||||
scenario_entries: list[PathwayScenarioDTO] = []
|
||||
for scenario in sorted(node.scenarios.all(), key=lambda item: item.pk):
|
||||
ai_for_scenario = list(scenario.get_additional_information(direct_only=True))
|
||||
ai_for_node.extend(ai_for_scenario)
|
||||
scenario_entries.append(
|
||||
PathwayScenarioDTO(
|
||||
scenario_uuid=scenario.uuid,
|
||||
name=scenario.name,
|
||||
additional_info=ai_for_scenario,
|
||||
)
|
||||
)
|
||||
|
||||
nodes.append(
|
||||
PathwayNodeDTO(
|
||||
node_uuid=node.uuid,
|
||||
compound_pk=compound.pk,
|
||||
name=compound.name,
|
||||
depth=node.depth,
|
||||
smiles=cs.smiles,
|
||||
cas_number=cas_number,
|
||||
ec_number=ec_number,
|
||||
additional_info=ai_for_node,
|
||||
scenarios=scenario_entries,
|
||||
)
|
||||
)
|
||||
|
||||
if node.depth == 0 and compound.pk not in root_compound_pks:
|
||||
root_compound_pks.append(compound.pk)
|
||||
|
||||
if compound.pk not in compounds_by_pk:
|
||||
compounds_by_pk[compound.pk] = PathwayCompoundDTO(
|
||||
pk=compound.pk,
|
||||
name=compound.name,
|
||||
smiles=cs.smiles,
|
||||
cas_number=cas_number,
|
||||
ec_number=ec_number,
|
||||
)
|
||||
|
||||
for edge in pathway.edge_set.all():
|
||||
start_compounds = {
|
||||
n.default_node_label.compound.pk
|
||||
for n in edge.start_nodes.all()
|
||||
if n.default_node_label is not None
|
||||
}
|
||||
end_compounds = {
|
||||
n.default_node_label.compound.pk
|
||||
for n in edge.end_nodes.all()
|
||||
if n.default_node_label is not None
|
||||
}
|
||||
|
||||
probability = None
|
||||
if edge.kv and edge.kv.get("probability") is not None:
|
||||
try:
|
||||
probability = float(edge.kv.get("probability"))
|
||||
except (TypeError, ValueError):
|
||||
probability = None
|
||||
|
||||
edges.append(
|
||||
PathwayEdgeDTO(
|
||||
edge_uuid=edge.uuid,
|
||||
start_compound_pks=sorted(start_compounds),
|
||||
end_compound_pks=sorted(end_compounds),
|
||||
probability=probability,
|
||||
)
|
||||
)
|
||||
|
||||
model_info = None
|
||||
if pathway.setting and pathway.setting.model:
|
||||
model = pathway.setting.model
|
||||
model_info = PathwayModelInfoDTO(
|
||||
model_name=model.get_name(),
|
||||
model_uuid=model.uuid,
|
||||
software_name="enviPath",
|
||||
software_version=None,
|
||||
)
|
||||
|
||||
return PathwayExportDTO(
|
||||
pathway_uuid=pathway.uuid,
|
||||
pathway_name=pathway.get_name(),
|
||||
compounds=list(compounds_by_pk.values()),
|
||||
nodes=nodes,
|
||||
edges=edges,
|
||||
root_compound_pks=root_compound_pks,
|
||||
model_info=model_info,
|
||||
)
|
||||
@ -1,7 +1,21 @@
|
||||
from ninja import Router
|
||||
from ninja.security import SessionAuth
|
||||
|
||||
from envipath import settings as s
|
||||
from .auth import BearerTokenAuth
|
||||
from .endpoints import packages, scenarios, compounds, rules, reactions, pathways, models, structure
|
||||
from .endpoints import (
|
||||
packages,
|
||||
scenarios,
|
||||
compounds,
|
||||
rules,
|
||||
reactions,
|
||||
pathways,
|
||||
models,
|
||||
structure,
|
||||
additional_information,
|
||||
settings,
|
||||
groups,
|
||||
)
|
||||
|
||||
# Main router with authentication
|
||||
router = Router(
|
||||
@ -20,3 +34,11 @@ router.add_router("", reactions.router)
|
||||
router.add_router("", pathways.router)
|
||||
router.add_router("", models.router)
|
||||
router.add_router("", structure.router)
|
||||
router.add_router("", additional_information.router)
|
||||
router.add_router("", settings.router)
|
||||
router.add_router("", groups.router)
|
||||
|
||||
if s.IUCLID_EXPORT_ENABLED:
|
||||
from epiuclid.api import router as iuclid_router
|
||||
|
||||
router.add_router("", iuclid_router)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
from ninja import FilterSchema, FilterLookup, Schema
|
||||
from typing import Annotated, Optional
|
||||
from typing import Annotated, Optional, List, Dict, Any
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
@ -51,6 +51,23 @@ class ScenarioOutSchema(PackageEntityOutSchema):
|
||||
pass
|
||||
|
||||
|
||||
class AdditionalInformationItemSchema(Schema):
|
||||
"""Schema for additional information item in scenario creation."""
|
||||
|
||||
type: str
|
||||
data: Dict[str, Any]
|
||||
|
||||
|
||||
class ScenarioCreateSchema(Schema):
|
||||
"""Schema for creating a new scenario."""
|
||||
|
||||
name: str
|
||||
description: str = ""
|
||||
scenario_date: str = "No date"
|
||||
scenario_type: str = "Not specified"
|
||||
additional_information: List[AdditionalInformationItemSchema] = []
|
||||
|
||||
|
||||
class CompoundOutSchema(PackageEntityOutSchema):
|
||||
pass
|
||||
|
||||
@ -102,3 +119,17 @@ class PackageOutSchema(Schema):
|
||||
@staticmethod
|
||||
def resolve_review_status(obj):
|
||||
return "reviewed" if obj.reviewed else "unreviewed"
|
||||
|
||||
|
||||
class SettingOutSchema(Schema):
|
||||
uuid: UUID
|
||||
url: str = ""
|
||||
name: str
|
||||
description: str
|
||||
|
||||
|
||||
class GroupOutSchema(Schema):
|
||||
uuid: UUID
|
||||
url: str = ""
|
||||
name: str
|
||||
description: str
|
||||
|
||||
@ -3,6 +3,7 @@ from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("microsoft/login/", views.microsoft_login, name="microsoft_login"),
|
||||
path("microsoft/callback/", views.microsoft_callback, name="microsoft_callback"),
|
||||
path("entra/login/", views.entra_login, name="entra_login"),
|
||||
path("auth/redirect/", views.entra_callback, name="entra_callback"),
|
||||
path("auth/token/", views.get_token, name="get_token"),
|
||||
]
|
||||
|
||||
170
epauth/views.py
170
epauth/views.py
@ -1,34 +1,51 @@
|
||||
import msal
|
||||
from django.conf import settings as s
|
||||
from django.contrib.auth import login
|
||||
from django.shortcuts import redirect
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth import login
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
|
||||
from epdb.logic import UserManager
|
||||
from epdb.logic import UserManager, GroupManager
|
||||
from epdb.models import Group
|
||||
|
||||
|
||||
def microsoft_login(request):
|
||||
def get_msal_app_with_cache(request):
|
||||
"""
|
||||
Create MSAL app with session-based token cache.
|
||||
"""
|
||||
cache = msal.SerializableTokenCache()
|
||||
|
||||
# Load cache from session if it exists
|
||||
if request.session.get("msal_token_cache"):
|
||||
cache.deserialize(request.session["msal_token_cache"])
|
||||
|
||||
msal_app = msal.ConfidentialClientApplication(
|
||||
client_id=s.MS_ENTRA_CLIENT_ID,
|
||||
client_credential=s.MS_ENTRA_CLIENT_SECRET,
|
||||
authority=s.MS_ENTRA_AUTHORITY
|
||||
authority=s.MS_ENTRA_AUTHORITY,
|
||||
token_cache=cache,
|
||||
)
|
||||
|
||||
return msal_app, cache
|
||||
|
||||
|
||||
def entra_login(request):
|
||||
msal_app = msal.ConfidentialClientApplication(
|
||||
client_id=s.MS_ENTRA_CLIENT_ID,
|
||||
client_credential=s.MS_ENTRA_CLIENT_SECRET,
|
||||
authority=s.MS_ENTRA_AUTHORITY,
|
||||
)
|
||||
|
||||
flow = msal_app.initiate_auth_code_flow(
|
||||
scopes=s.MS_ENTRA_SCOPES,
|
||||
redirect_uri=s.MS_ENTRA_REDIRECT_URI
|
||||
scopes=s.MS_ENTRA_SCOPES, redirect_uri=s.MS_ENTRA_REDIRECT_URI
|
||||
)
|
||||
|
||||
request.session["msal_auth_flow"] = flow
|
||||
return redirect(flow["auth_uri"])
|
||||
|
||||
|
||||
def microsoft_callback(request):
|
||||
msal_app = msal.ConfidentialClientApplication(
|
||||
client_id=s.MS_ENTRA_CLIENT_ID,
|
||||
client_credential=s.MS_ENTRA_CLIENT_SECRET,
|
||||
authority=s.MS_ENTRA_AUTHORITY
|
||||
)
|
||||
def entra_callback(request):
|
||||
msal_app, cache = get_msal_app_with_cache(request)
|
||||
|
||||
flow = request.session.pop("msal_auth_flow", None)
|
||||
if not flow:
|
||||
@ -37,30 +54,117 @@ def microsoft_callback(request):
|
||||
# Acquire token using the flow and callback request
|
||||
result = msal_app.acquire_token_by_auth_code_flow(flow, request.GET)
|
||||
|
||||
if "access_token" in result:
|
||||
# Optional: Fetch user info from Microsoft Graph
|
||||
import requests
|
||||
resp = requests.get(
|
||||
"https://graph.microsoft.com/v1.0/me",
|
||||
headers={"Authorization": f"Bearer {result['access_token']}"}
|
||||
)
|
||||
user_info = resp.json()
|
||||
# Save the token cache to session
|
||||
if cache.has_state_changed:
|
||||
request.session["msal_token_cache"] = cache.serialize()
|
||||
|
||||
user_name = user_info["displayName"]
|
||||
user_email = user_info["mail"]
|
||||
user_oid = user_info["id"]
|
||||
claims = result["id_token_claims"]
|
||||
|
||||
# Get implementing class
|
||||
User = get_user_model()
|
||||
user_name = claims.get("name")
|
||||
user_email = claims.get("emailaddress", claims.get("email"))
|
||||
user_oid = claims.get("oid")
|
||||
|
||||
if User.objects.filter(uuid=user_oid).exists():
|
||||
login(request, User.objects.get(uuid=user_oid))
|
||||
if not all([user_name, user_email, user_oid]):
|
||||
raise ValueError("Missing required claims in ID token")
|
||||
|
||||
# Get implementing class
|
||||
User = get_user_model()
|
||||
|
||||
if User.objects.filter(uuid=user_oid).exists():
|
||||
u = User.objects.get(uuid=user_oid)
|
||||
|
||||
if u.username != user_name:
|
||||
u.username = user_name
|
||||
u.save()
|
||||
|
||||
else:
|
||||
u = UserManager.create_user(user_name, user_email, None, uuid=user_oid, is_active=True)
|
||||
|
||||
login(request, u)
|
||||
|
||||
# EDIT START
|
||||
|
||||
# Ensure groups exists in eP
|
||||
for id, name in s.ENTRA_SECRET_GROUPS.items():
|
||||
if not Group.objects.filter(uuid=id).exists():
|
||||
g = GroupManager.create_group(User.objects.get(username="admin"), name, f"Synced Entra Group {name} ",
|
||||
uuid=id)
|
||||
else:
|
||||
u = UserManager.create_user(user_name, user_email, None, uuid=user_oid, is_active=True)
|
||||
login(request, u)
|
||||
g = Group.objects.get(uuid=id)
|
||||
# Ensure its secret
|
||||
g.secret = True
|
||||
g.save()
|
||||
|
||||
# TODO Group Sync
|
||||
for id, name in s.ENTRA_GROUPS.items():
|
||||
if not Group.objects.filter(uuid=id).exists():
|
||||
g = GroupManager.create_group(User.objects.get(username="admin"), name, f"Synced Entra Group {name} ",
|
||||
uuid=id)
|
||||
else:
|
||||
g = Group.objects.get(uuid=id)
|
||||
|
||||
return redirect("/")
|
||||
for group_uuid in claims.get("groups", []):
|
||||
if Group.objects.filter(uuid=group_uuid).exists():
|
||||
g = Group.objects.get(uuid=group_uuid)
|
||||
g.user_member.add(u)
|
||||
|
||||
return redirect("/") # Handle errors
|
||||
# EDIT END
|
||||
|
||||
return redirect(s.SERVER_URL) # Handle errors
|
||||
|
||||
|
||||
def get_access_token_from_request(request, scopes=None):
|
||||
"""
|
||||
Get an access token from the request using MSAL token cache.
|
||||
"""
|
||||
|
||||
# Check if auth via Access Token
|
||||
if request.headers.get("Authorization"):
|
||||
return {"access_token": request.headers.get("Authorization").split(" ")[1]}
|
||||
|
||||
if scopes is None:
|
||||
scopes = s.MS_ENTRA_SCOPES
|
||||
|
||||
# Get user from request (must be authenticated)
|
||||
if not request.user.is_authenticated:
|
||||
return None
|
||||
|
||||
# Create MSAL app with persistent cache
|
||||
msal_app, cache = get_msal_app_with_cache(request)
|
||||
|
||||
# Try to get accounts from cache
|
||||
accounts = msal_app.get_accounts()
|
||||
|
||||
if not accounts:
|
||||
return None
|
||||
|
||||
# Find the account that matches the current user
|
||||
user_account = None
|
||||
for account in accounts:
|
||||
if account.get("local_account_id") == str(request.user.uuid):
|
||||
user_account = account
|
||||
break
|
||||
|
||||
# If no matching account found, use the first available account
|
||||
if not user_account and accounts:
|
||||
user_account = accounts[0]
|
||||
|
||||
if not user_account:
|
||||
return None
|
||||
|
||||
# Try to acquire token silently from cache
|
||||
result = msal_app.acquire_token_silent(scopes=scopes, account=user_account)
|
||||
|
||||
# Save cache changes back to session
|
||||
if cache.has_state_changed:
|
||||
request.session["msal_token_cache"] = cache.serialize()
|
||||
|
||||
if result and "access_token" in result:
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_token(request):
|
||||
token = get_access_token_from_request(request)
|
||||
msg = f"{token}"
|
||||
return HttpResponse(msg, content_type='text/plain')
|
||||
|
||||
142
epdb/admin.py
142
epdb/admin.py
@ -1,7 +1,12 @@
|
||||
import logging
|
||||
|
||||
from django.conf import settings as s
|
||||
from django.contrib import admin
|
||||
from django.contrib import messages
|
||||
|
||||
from .models import (
|
||||
AdditionalInformation,
|
||||
ClassifierPluginModel,
|
||||
Compound,
|
||||
CompoundStructure,
|
||||
Edge,
|
||||
@ -16,6 +21,7 @@ from .models import (
|
||||
Node,
|
||||
ParallelRule,
|
||||
Pathway,
|
||||
PropertyPluginModel,
|
||||
Reaction,
|
||||
Scenario,
|
||||
Setting,
|
||||
@ -26,9 +32,130 @@ from .models import (
|
||||
|
||||
Package = s.GET_PACKAGE_MODEL()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdditionalInformationAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
class UserAdmin(admin.ModelAdmin):
|
||||
list_display = ["username", "email", "is_active"]
|
||||
list_display = [
|
||||
"username",
|
||||
"email",
|
||||
"is_active",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"last_login",
|
||||
"date_joined",
|
||||
]
|
||||
|
||||
actions = ["send_welcome_mail", "send_affiliation_mail"]
|
||||
|
||||
@admin.action(description="Send welcome mail")
|
||||
def send_welcome_mail(self, request, queryset):
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
|
||||
tpl = """Hello {username},
|
||||
|
||||
Your account has been successfully activated.
|
||||
|
||||
To log in, please visit
|
||||
https://envipath.org/password_reset/
|
||||
and request a new password.
|
||||
|
||||
If you have any questions or feedback, feel free to visit our community forum at
|
||||
https://community.envipath.org/.
|
||||
You do not need to register again for the forum - you can log in using your enviPath account by clicking "Log In" and then "Log in with enviPath."
|
||||
|
||||
Best regards,
|
||||
|
||||
The enviPath Team"""
|
||||
|
||||
users = []
|
||||
|
||||
for user in queryset:
|
||||
if user.is_active:
|
||||
logger.info(f"{user.username} already active - not sending mail again")
|
||||
continue
|
||||
try:
|
||||
msg = EmailMultiAlternatives(
|
||||
"Your enviPath Account Is Now Active",
|
||||
tpl.format(username=user.username),
|
||||
"admin@envipath.org",
|
||||
[user.email],
|
||||
bcc=["admin@envipath.org"],
|
||||
)
|
||||
|
||||
msg.send(fail_silently=False)
|
||||
|
||||
user.is_active = True
|
||||
user.password = "ASDF"
|
||||
user.save()
|
||||
|
||||
users.append(user)
|
||||
logger.info(f"{user.username} -> {user.email} mail sent")
|
||||
except Exception as e:
|
||||
logger.info(f"Error sending mail to {user.username}: {e}")
|
||||
|
||||
self.message_user(
|
||||
request, f"Sent welcome mail to {[u.email for u in users]}", messages.SUCCESS
|
||||
)
|
||||
|
||||
@admin.action(description="Send affiliation mail")
|
||||
def send_affiliation_mail(self, request, queryset):
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
|
||||
tpl = """Dear {username},
|
||||
|
||||
Thank you for your interest in enviPath!
|
||||
|
||||
Please note that the public enviPath system is intended for non-commercial use only.
|
||||
We see that you registered using the email address {email}.
|
||||
If possible, we kindly ask you to register using an official email address that reflects your affiliation (e.g., a university, NGO, or research organization).
|
||||
|
||||
If you would like us to update your account, simply reply to this email and let us know which address we should use.
|
||||
We will then change it in our system, and you will receive a password reset email at the new address.
|
||||
|
||||
If you are registering with a company email address and are interested in commercial use, you are very welcome to book a meeting with us so we can discuss how we can best support you.
|
||||
To book a meeting, please visit https://envipath.com/book
|
||||
|
||||
If changing to an affiliation email address is not possible, please contact us at registration@envipath.org
|
||||
|
||||
Best regards,
|
||||
|
||||
enviPath team"""
|
||||
|
||||
users = []
|
||||
|
||||
for user in queryset:
|
||||
if user.is_active or user.contacted:
|
||||
logger.info(
|
||||
f"{user.username} already active or already contacted - not sending mail again"
|
||||
)
|
||||
continue
|
||||
try:
|
||||
msg = EmailMultiAlternatives(
|
||||
"Regarding your enviPath registration",
|
||||
tpl.format(username=user.username, email=user.email),
|
||||
"admin@envipath.org",
|
||||
[user.email],
|
||||
bcc=["admin@envipath.org"],
|
||||
)
|
||||
|
||||
msg.send(fail_silently=False)
|
||||
|
||||
user.contacted = True
|
||||
user.save()
|
||||
|
||||
users.append(user)
|
||||
logger.info(f"{user.username} -> {user.email} affiliation mail sent")
|
||||
except Exception as e:
|
||||
logger.info(f"Error sending mail to {user.username}: {e}")
|
||||
|
||||
self.message_user(
|
||||
request, f"Sent affiliation mail to {[u.email for u in users]}", messages.SUCCESS
|
||||
)
|
||||
|
||||
|
||||
class UserPackagePermissionAdmin(admin.ModelAdmin):
|
||||
@ -48,7 +175,7 @@ class JobLogAdmin(admin.ModelAdmin):
|
||||
|
||||
|
||||
class EPAdmin(admin.ModelAdmin):
|
||||
search_fields = ["name", "description"]
|
||||
search_fields = ["name", "description", "url", "uuid"]
|
||||
list_display = ["name", "url", "created"]
|
||||
ordering = ["-created"]
|
||||
|
||||
@ -65,6 +192,14 @@ class EnviFormerAdmin(EPAdmin):
|
||||
pass
|
||||
|
||||
|
||||
class PropertyPluginModelAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
class ClassifierPluginModelAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
class LicenseAdmin(admin.ModelAdmin):
|
||||
list_display = ["cc_string", "link", "image_link"]
|
||||
|
||||
@ -117,6 +252,7 @@ class ExternalIdentifierAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
admin.site.register(AdditionalInformation, AdditionalInformationAdmin)
|
||||
admin.site.register(User, UserAdmin)
|
||||
admin.site.register(UserPackagePermission, UserPackagePermissionAdmin)
|
||||
admin.site.register(Group, GroupAdmin)
|
||||
@ -125,7 +261,9 @@ admin.site.register(JobLog, JobLogAdmin)
|
||||
admin.site.register(Package, PackageAdmin)
|
||||
admin.site.register(MLRelativeReasoning, MLRelativeReasoningAdmin)
|
||||
admin.site.register(EnviFormer, EnviFormerAdmin)
|
||||
admin.site.register(PropertyPluginModel, PropertyPluginModelAdmin)
|
||||
admin.site.register(License, LicenseAdmin)
|
||||
admin.site.register(ClassifierPluginModel, ClassifierPluginModelAdmin)
|
||||
admin.site.register(Compound, CompoundAdmin)
|
||||
admin.site.register(CompoundStructure, CompoundStructureAdmin)
|
||||
admin.site.register(SimpleAmbitRule, SimpleAmbitRuleAdmin)
|
||||
|
||||
14
epdb/api.py
14
epdb/api.py
@ -2,20 +2,12 @@ from typing import List
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from ninja import Router, Schema, Field
|
||||
from ninja.errors import HttpError
|
||||
from ninja.pagination import paginate
|
||||
from ninja.security import HttpBearer
|
||||
|
||||
from epapi.v1.auth import BearerTokenAuth
|
||||
|
||||
from .logic import PackageManager
|
||||
from .models import User, Compound, APIToken
|
||||
|
||||
|
||||
class BearerTokenAuth(HttpBearer):
|
||||
def authenticate(self, request, token):
|
||||
for token_obj in APIToken.objects.select_related("user").all():
|
||||
if token_obj.check_token(token) and token_obj.is_valid():
|
||||
return token_obj.user
|
||||
raise HttpError(401, "Invalid or expired token")
|
||||
from .models import User, Compound
|
||||
|
||||
|
||||
def _anonymous_or_real(request):
|
||||
|
||||
11
epdb/apps.py
11
epdb/apps.py
@ -15,3 +15,14 @@ class EPDBConfig(AppConfig):
|
||||
|
||||
model_name = getattr(settings, "EPDB_PACKAGE_MODEL", "epdb.Package")
|
||||
logger.info(f"Using Package model: {model_name}")
|
||||
|
||||
from .autodiscovery import autodiscover
|
||||
|
||||
autodiscover()
|
||||
|
||||
if settings.PLUGINS_ENABLED:
|
||||
from bridge.contracts import Property, Classifier
|
||||
from utilities.plugin import discover_plugins
|
||||
|
||||
settings.PROPERTY_PLUGINS.update(**discover_plugins(_cls=Property))
|
||||
settings.CLASSIFIER_PLUGINS.update(**discover_plugins(_cls=Classifier))
|
||||
|
||||
5
epdb/autodiscovery.py
Normal file
5
epdb/autodiscovery.py
Normal file
@ -0,0 +1,5 @@
|
||||
from django.utils.module_loading import autodiscover_modules
|
||||
|
||||
|
||||
def autodiscover():
|
||||
autodiscover_modules("epdb_hooks")
|
||||
File diff suppressed because it is too large
Load Diff
295
epdb/logic.py
295
epdb/logic.py
@ -1,4 +1,3 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Set, Union, Tuple
|
||||
@ -8,9 +7,11 @@ import nh3
|
||||
from django.conf import settings as s
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
from django.db.models import QuerySet
|
||||
from pydantic import ValidationError
|
||||
|
||||
from epdb.models import (
|
||||
AdditionalInformation,
|
||||
Compound,
|
||||
CompoundStructure,
|
||||
Edge,
|
||||
@ -22,6 +23,7 @@ from epdb.models import (
|
||||
Node,
|
||||
Pathway,
|
||||
Permission,
|
||||
PropertyPluginModel,
|
||||
Reaction,
|
||||
Rule,
|
||||
Setting,
|
||||
@ -194,8 +196,6 @@ class UserManager(object):
|
||||
if clean_username != username or clean_email != email:
|
||||
# This will be caught by the try in view.py/register
|
||||
raise ValueError("Invalid username or password")
|
||||
# avoid circular import :S
|
||||
from .tasks import send_registration_mail
|
||||
|
||||
extra_fields = {"is_active": not s.ADMIN_APPROVAL_REQUIRED}
|
||||
|
||||
@ -214,10 +214,6 @@ class UserManager(object):
|
||||
u.default_package = p
|
||||
u.save()
|
||||
|
||||
if not u.is_active:
|
||||
# send email for verification
|
||||
send_registration_mail.delay(u.pk)
|
||||
|
||||
if set_setting:
|
||||
u.default_setting = Setting.objects.get(global_default=True)
|
||||
u.save()
|
||||
@ -269,8 +265,12 @@ class GroupManager(object):
|
||||
return bool(re.findall(GroupManager.group_pattern, url))
|
||||
|
||||
@staticmethod
|
||||
def create_group(current_user, name, description):
|
||||
def create_group(current_user, name, description, *args, **kwargs):
|
||||
g = Group()
|
||||
|
||||
if "uuid" in kwargs:
|
||||
g.uuid = kwargs["uuid"]
|
||||
|
||||
# Clean for potential XSS
|
||||
g.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
|
||||
g.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
|
||||
@ -346,52 +346,17 @@ class PackageManager(object):
|
||||
|
||||
@staticmethod
|
||||
def readable(user, package):
|
||||
if (
|
||||
UserPackagePermission.objects.filter(package=package, user=user).exists()
|
||||
or GroupPackagePermission.objects.filter(
|
||||
package=package, group__in=GroupManager.get_groups(user)
|
||||
)
|
||||
or package.reviewed is True
|
||||
or user.is_superuser
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
return (
|
||||
PackageManager.has_package_permission(user, package, "read") | package.reviewed is True
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def writable(user, package):
|
||||
if (
|
||||
UserPackagePermission.objects.filter(
|
||||
package=package, user=user, permission=Permission.WRITE[0]
|
||||
).exists()
|
||||
or GroupPackagePermission.objects.filter(
|
||||
package=package,
|
||||
group__in=GroupManager.get_groups(user),
|
||||
permission=Permission.WRITE[0],
|
||||
).exists()
|
||||
or UserPackagePermission.objects.filter(
|
||||
package=package, user=user, permission=Permission.ALL[0]
|
||||
).exists()
|
||||
or user.is_superuser
|
||||
):
|
||||
return True
|
||||
return False
|
||||
return PackageManager.has_package_permission(user, package, "write")
|
||||
|
||||
@staticmethod
|
||||
def administrable(user, package):
|
||||
if (
|
||||
UserPackagePermission.objects.filter(
|
||||
package=package, user=user, permission=Permission.ALL[0]
|
||||
).exists()
|
||||
or GroupPackagePermission.objects.filter(
|
||||
package=package,
|
||||
group__in=GroupManager.get_groups(user),
|
||||
permission=Permission.ALL[0],
|
||||
).exists()
|
||||
or user.is_superuser
|
||||
):
|
||||
return True
|
||||
return False
|
||||
return PackageManager.has_package_permission(user, package, "all")
|
||||
|
||||
@staticmethod
|
||||
def has_package_permission(user: "User", package: Union[str, UUID, "Package"], permission: str):
|
||||
@ -400,6 +365,14 @@ class PackageManager(object):
|
||||
|
||||
groups = GroupManager.get_groups(user)
|
||||
|
||||
# EDIT START
|
||||
|
||||
if package.classification_level == Package.Classification.SECRET:
|
||||
if package.data_pool not in groups:
|
||||
return False
|
||||
|
||||
# EDIT END
|
||||
|
||||
perms = {"all": ["all"], "write": ["all", "write"], "read": ["all", "write", "read"]}
|
||||
|
||||
valid_perms = perms.get(permission)
|
||||
@ -442,6 +415,7 @@ class PackageManager(object):
|
||||
try:
|
||||
p = Package.objects.get(uuid=package_id)
|
||||
if PackageManager.readable(user, p):
|
||||
p = PackageManager.check_package_classification(user, p)
|
||||
return p
|
||||
else:
|
||||
# FIXME: use custom exception to be translatable to 403 in API
|
||||
@ -451,6 +425,37 @@ class PackageManager(object):
|
||||
except Package.DoesNotExist:
|
||||
raise ValueError("Package with ID {} does not exist!".format(package_id))
|
||||
|
||||
# EDIT START
|
||||
|
||||
@staticmethod
|
||||
def check_package_classification(user, pack: Package):
|
||||
if pack.classification_level == Package.Classification.SECRET:
|
||||
if pack.data_pool.user_member.filter(id=user.id).exists():
|
||||
return pack
|
||||
|
||||
raise ValueError("Package is secret and not accessible to user!")
|
||||
|
||||
else:
|
||||
return pack
|
||||
|
||||
|
||||
@staticmethod
|
||||
def check_package_classifications(user, package_qs: QuerySet[Package]):
|
||||
non_secret = package_qs.exclude(classification_level=Package.Classification.SECRET)
|
||||
secret = package_qs.filter(classification_level=Package.Classification.SECRET)
|
||||
|
||||
# TODO we should be able to do via the db
|
||||
accessible_secret = []
|
||||
|
||||
for s_package in secret:
|
||||
if s_package.data_pool.user_member.filter(id=user.id).exists():
|
||||
accessible_secret.append(s_package.pk)
|
||||
|
||||
# Cannot combine a unique query with a non-unique query -> we have to call distinct
|
||||
return Package.objects.filter(pk__in=accessible_secret).distinct() | non_secret.distinct()
|
||||
|
||||
# EDIT END
|
||||
|
||||
@staticmethod
|
||||
def get_all_readable_packages(user, include_reviewed=False):
|
||||
# UserPermission only exists if at least read is granted...
|
||||
@ -475,7 +480,13 @@ class PackageManager(object):
|
||||
# remove package if user is owner and package is reviewed e.g. admin
|
||||
qs = qs.filter(reviewed=False)
|
||||
|
||||
return qs.distinct()
|
||||
qs = qs.distinct()
|
||||
|
||||
# EDIT START
|
||||
qs = PackageManager.check_package_classifications(user, qs)
|
||||
# EDIT END
|
||||
|
||||
return qs
|
||||
|
||||
@staticmethod
|
||||
def get_all_writeable_packages(user):
|
||||
@ -519,11 +530,13 @@ class PackageManager(object):
|
||||
|
||||
qs = qs.filter(reviewed=False)
|
||||
|
||||
return qs.distinct()
|
||||
qs = qs.distinct()
|
||||
|
||||
@staticmethod
|
||||
def get_packages():
|
||||
return Package.objects.all()
|
||||
# EDIT START
|
||||
qs = PackageManager.check_package_classifications(user, qs)
|
||||
# EDIT END
|
||||
|
||||
return qs
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
@ -628,6 +641,25 @@ class PackageManager(object):
|
||||
else:
|
||||
pack.reviewed = False
|
||||
|
||||
# EDIT START
|
||||
if data.get("classification"):
|
||||
if data["classification"] == "INTERNAL":
|
||||
pack.classification = Package.Classification.RESTRICTED
|
||||
elif data["classification"] == "RESTRICTED":
|
||||
pack.classification = Package.Classification.RESTRICTED
|
||||
elif data["classification"] == "SECRET":
|
||||
pack.classification = Package.Classification.SECRET
|
||||
|
||||
if not "datapool" in data:
|
||||
raise ValueError("Missing datapool in package")
|
||||
|
||||
g = Group.objects.get(uuid=data["datapool"].split('/')[-1])
|
||||
pack.data_pool = g
|
||||
else:
|
||||
raise ValueError(f"Invalid classification {data['classification']}")
|
||||
|
||||
# EDIT END
|
||||
|
||||
pack.description = data["description"]
|
||||
pack.save()
|
||||
|
||||
@ -639,15 +671,30 @@ class PackageManager(object):
|
||||
|
||||
# Stores old_id to new_id
|
||||
mapping = {}
|
||||
# Stores new_scen_id to old_parent_scen_id
|
||||
parent_mapping = {}
|
||||
# Mapping old scen_id to old_obj_id
|
||||
scen_mapping = defaultdict(list)
|
||||
# Enzymelink Mapping rule_id to enzymelink objects
|
||||
enzyme_mapping = defaultdict(list)
|
||||
|
||||
# old_parent_id to child
|
||||
postponed_scens = defaultdict(list)
|
||||
|
||||
# Store Scenarios
|
||||
for scenario in data["scenarios"]:
|
||||
skip_scen = False
|
||||
# Check if parent exists and park this Scenario to convert it later into an
|
||||
# AdditionalInformation object
|
||||
for ex in scenario.get("additionalInformationCollection", {}).get(
|
||||
"additionalInformation", []
|
||||
):
|
||||
if ex["name"] == "referringscenario":
|
||||
postponed_scens[ex["data"]].append(scenario)
|
||||
skip_scen = True
|
||||
break
|
||||
|
||||
if skip_scen:
|
||||
continue
|
||||
|
||||
scen = Scenario()
|
||||
scen.package = pack
|
||||
scen.uuid = UUID(scenario["id"].split("/")[-1]) if keep_ids else uuid4()
|
||||
@ -660,19 +707,12 @@ class PackageManager(object):
|
||||
|
||||
mapping[scenario["id"]] = scen.uuid
|
||||
|
||||
new_add_inf = defaultdict(list)
|
||||
# TODO Store AI...
|
||||
for ex in scenario.get("additionalInformationCollection", {}).get(
|
||||
"additionalInformation", []
|
||||
):
|
||||
name = ex["name"]
|
||||
addinf_data = ex["data"]
|
||||
|
||||
# park the parent scen id for now and link it later
|
||||
if name == "referringscenario":
|
||||
parent_mapping[scen.uuid] = addinf_data
|
||||
continue
|
||||
|
||||
# Broken eP Data
|
||||
if name == "initialmasssediment" and addinf_data == "missing data":
|
||||
continue
|
||||
@ -680,17 +720,11 @@ class PackageManager(object):
|
||||
continue
|
||||
|
||||
try:
|
||||
res = AdditionalInformationConverter.convert(name, addinf_data)
|
||||
res_cls_name = res.__class__.__name__
|
||||
ai_data = json.loads(res.model_dump_json())
|
||||
ai_data["uuid"] = f"{uuid4()}"
|
||||
new_add_inf[res_cls_name].append(ai_data)
|
||||
except ValidationError:
|
||||
ai = AdditionalInformationConverter.convert(name, addinf_data)
|
||||
AdditionalInformation.create(pack, ai, scenario=scen)
|
||||
except (ValidationError, ValueError):
|
||||
logger.error(f"Failed to convert {name} with {addinf_data}")
|
||||
|
||||
scen.additional_information = new_add_inf
|
||||
scen.save()
|
||||
|
||||
print("Scenarios imported...")
|
||||
|
||||
# Store compounds and its structures
|
||||
@ -711,7 +745,13 @@ class PackageManager(object):
|
||||
default_structure = None
|
||||
|
||||
for structure in compound["structures"]:
|
||||
struc = CompoundStructure()
|
||||
if structure.get("pesLink"):
|
||||
from bayer.models import PESStructure
|
||||
struc = PESStructure()
|
||||
struc.pes_link = structure["pesLink"]
|
||||
else:
|
||||
struc = CompoundStructure()
|
||||
|
||||
# struc.object_url = Command.get_id(structure, keep_ids)
|
||||
struc.compound = comp
|
||||
struc.uuid = UUID(structure["id"].split("/")[-1]) if keep_ids else uuid4()
|
||||
@ -719,6 +759,10 @@ class PackageManager(object):
|
||||
struc.description = structure["description"]
|
||||
struc.aliases = structure.get("aliases", [])
|
||||
struc.smiles = structure["smiles"]
|
||||
|
||||
if structure.get("molfile"):
|
||||
struc.molfile = structure["molfile"]
|
||||
|
||||
struc.save()
|
||||
|
||||
for scen in structure["scenarios"]:
|
||||
@ -930,14 +974,46 @@ class PackageManager(object):
|
||||
|
||||
print("Pathways imported...")
|
||||
|
||||
# Linking Phase
|
||||
for child, parent in parent_mapping.items():
|
||||
child_obj = Scenario.objects.get(uuid=child)
|
||||
parent_obj = Scenario.objects.get(uuid=mapping[parent])
|
||||
child_obj.parent = parent_obj
|
||||
child_obj.save()
|
||||
for parent, children in postponed_scens.items():
|
||||
for child in children:
|
||||
for ex in child.get("additionalInformationCollection", {}).get(
|
||||
"additionalInformation", []
|
||||
):
|
||||
child_id = child["id"]
|
||||
name = ex["name"]
|
||||
addinf_data = ex["data"]
|
||||
|
||||
if name == "referringscenario":
|
||||
continue
|
||||
# Broken eP Data
|
||||
if name == "initialmasssediment" and addinf_data == "missing data":
|
||||
continue
|
||||
if name == "columnheight" and addinf_data == "(2)-(2.5);(6)-(8)":
|
||||
continue
|
||||
|
||||
ai = AdditionalInformationConverter.convert(name, addinf_data)
|
||||
|
||||
if child_id not in scen_mapping:
|
||||
logger.info(
|
||||
f"{child_id} not found in scen_mapping. Seems like its not attached to any object"
|
||||
)
|
||||
print(
|
||||
f"{child_id} not found in scen_mapping. Seems like its not attached to any object"
|
||||
)
|
||||
|
||||
scen = Scenario.objects.get(uuid=mapping[parent])
|
||||
mapping[child_id] = scen.uuid
|
||||
for obj in scen_mapping[child_id]:
|
||||
_ = AdditionalInformation.create(pack, ai, scen, content_object=obj)
|
||||
|
||||
for scen_id, objects in scen_mapping.items():
|
||||
new_id = mapping.get(scen_id)
|
||||
|
||||
if new_id is None:
|
||||
logger.warning(f"Could not find mapping for {scen_id}")
|
||||
print(f"Could not find mapping for {scen_id}")
|
||||
continue
|
||||
|
||||
scen = Scenario.objects.get(uuid=mapping[scen_id])
|
||||
for o in objects:
|
||||
o.scenarios.add(scen)
|
||||
@ -970,6 +1046,7 @@ class PackageManager(object):
|
||||
matches = re.findall(r">(R[0-9]+)<", evidence["evidence"])
|
||||
if not matches or len(matches) != 1:
|
||||
logger.warning(f"Could not find reaction id in {evidence['evidence']}")
|
||||
print(f"Could not find reaction id in {evidence['evidence']}")
|
||||
continue
|
||||
|
||||
e.add_kegg_reaction_id(matches[0])
|
||||
@ -988,55 +1065,10 @@ class PackageManager(object):
|
||||
|
||||
print("Fixing Node depths...")
|
||||
total_pws = Pathway.objects.filter(package=pack).count()
|
||||
|
||||
for p, pw in enumerate(Pathway.objects.filter(package=pack)):
|
||||
print(pw.url)
|
||||
in_count = defaultdict(lambda: 0)
|
||||
out_count = defaultdict(lambda: 0)
|
||||
|
||||
for e in pw.edges:
|
||||
# TODO check if this will remain
|
||||
for react in e.start_nodes.all():
|
||||
out_count[str(react.uuid)] += 1
|
||||
|
||||
for prod in e.end_nodes.all():
|
||||
in_count[str(prod.uuid)] += 1
|
||||
|
||||
root_nodes = []
|
||||
for n in pw.nodes:
|
||||
num_parents = in_count[str(n.uuid)]
|
||||
if num_parents == 0:
|
||||
# must be a root node or unconnected node
|
||||
if n.depth != 0:
|
||||
n.depth = 0
|
||||
n.save()
|
||||
|
||||
# Only root node may have children
|
||||
if out_count[str(n.uuid)] > 0:
|
||||
root_nodes.append(n)
|
||||
|
||||
levels = [root_nodes]
|
||||
seen = set()
|
||||
# Do a bfs to determine depths starting with level 0 a.k.a. root nodes
|
||||
for i, level_nodes in enumerate(levels):
|
||||
new_level = []
|
||||
for n in level_nodes:
|
||||
for e in n.out_edges.all():
|
||||
for prod in e.end_nodes.all():
|
||||
if str(prod.uuid) not in seen:
|
||||
old_depth = prod.depth
|
||||
if old_depth != i + 1:
|
||||
print(f"updating depth from {old_depth} to {i + 1}")
|
||||
prod.depth = i + 1
|
||||
prod.save()
|
||||
|
||||
new_level.append(prod)
|
||||
|
||||
seen.add(str(n.uuid))
|
||||
|
||||
if new_level:
|
||||
levels.append(new_level)
|
||||
|
||||
print(f"{p + 1}/{total_pws} fixed.")
|
||||
pw.update_depths()
|
||||
print(f"{p + 1}/{total_pws} fixed.", end="\r")
|
||||
|
||||
return pack
|
||||
|
||||
@ -1115,19 +1147,23 @@ class SettingManager(object):
|
||||
description: str = None,
|
||||
max_nodes: int = None,
|
||||
max_depth: int = None,
|
||||
rule_packages: List[Package] = None,
|
||||
rule_packages: List[Package] | None = None,
|
||||
model: EPModel = None,
|
||||
model_threshold: float = None,
|
||||
expansion_scheme: ExpansionSchemeChoice = ExpansionSchemeChoice.BFS,
|
||||
property_models: List["PropertyPluginModel"] | None = None,
|
||||
):
|
||||
new_s = Setting()
|
||||
|
||||
# Clean for potential XSS
|
||||
new_s.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
|
||||
new_s.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
|
||||
|
||||
new_s.max_nodes = max_nodes
|
||||
new_s.max_depth = max_depth
|
||||
new_s.model = model
|
||||
new_s.model_threshold = model_threshold
|
||||
new_s.expansion_scheme = expansion_scheme
|
||||
|
||||
new_s.save()
|
||||
|
||||
@ -1136,6 +1172,11 @@ class SettingManager(object):
|
||||
new_s.rule_packages.add(r)
|
||||
new_s.save()
|
||||
|
||||
if property_models is not None:
|
||||
for pm in property_models:
|
||||
new_s.property_models.add(pm)
|
||||
new_s.save()
|
||||
|
||||
usp = UserSettingPermission()
|
||||
usp.user = user
|
||||
usp.setting = new_s
|
||||
|
||||
@ -8,7 +8,6 @@ from epdb.logic import UserManager, GroupManager, PackageManager, SettingManager
|
||||
from epdb.models import (
|
||||
UserSettingPermission,
|
||||
MLRelativeReasoning,
|
||||
EnviFormer,
|
||||
Permission,
|
||||
User,
|
||||
ExternalDatabase,
|
||||
@ -231,7 +230,6 @@ class Command(BaseCommand):
|
||||
package=pack,
|
||||
rule_packages=[mapping["EAWAG-BBD"]],
|
||||
data_packages=[mapping["EAWAG-BBD"]],
|
||||
eval_packages=[],
|
||||
threshold=0.5,
|
||||
name="ECC - BBD - T0.5",
|
||||
description="ML Relative Reasoning",
|
||||
@ -239,7 +237,3 @@ class Command(BaseCommand):
|
||||
|
||||
ml_model.build_dataset()
|
||||
ml_model.build_model()
|
||||
|
||||
# If available, create EnviFormerModel
|
||||
if s.ENVIFORMER_PRESENT:
|
||||
EnviFormer.create(pack, "EnviFormer - T0.5", "EnviFormer Model with Threshold 0.5", 0.5)
|
||||
|
||||
92
epdb/management/commands/create_api_token.py
Normal file
92
epdb/management/commands/create_api_token.py
Normal file
@ -0,0 +1,92 @@
|
||||
from django.conf import settings as s
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from epdb.models import APIToken
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Create an API token for a user"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--username",
|
||||
required=True,
|
||||
help="Username of the user who will own the token",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--name",
|
||||
required=True,
|
||||
help="Descriptive name for the token",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--expires-days",
|
||||
type=int,
|
||||
default=90,
|
||||
help="Days until expiration (0 for no expiration)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--inactive",
|
||||
action="store_true",
|
||||
help="Create the token as inactive",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--curl",
|
||||
action="store_true",
|
||||
help="Print a curl example using the token",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--base-url",
|
||||
default=None,
|
||||
help="Base URL for curl example (default SERVER_URL or http://localhost:8000)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--endpoint",
|
||||
default="/api/v1/compounds/",
|
||||
help="Endpoint path for curl example",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
username = options["username"]
|
||||
name = options["name"]
|
||||
expires_days = options["expires_days"]
|
||||
|
||||
if expires_days < 0:
|
||||
raise CommandError("--expires-days must be >= 0")
|
||||
|
||||
if expires_days == 0:
|
||||
expires_days = None
|
||||
|
||||
user_model = get_user_model()
|
||||
try:
|
||||
user = user_model.objects.get(username=username)
|
||||
except user_model.DoesNotExist as exc:
|
||||
raise CommandError(f"User not found for username '{username}'") from exc
|
||||
|
||||
token, raw_token = APIToken.create_token(user, name=name, expires_days=expires_days)
|
||||
|
||||
if options["inactive"]:
|
||||
token.is_active = False
|
||||
token.save(update_fields=["is_active"])
|
||||
|
||||
self.stdout.write(f"User: {user.username} ({user.email})")
|
||||
self.stdout.write(f"Token name: {token.name}")
|
||||
self.stdout.write(f"Token id: {token.id}")
|
||||
if token.expires_at:
|
||||
self.stdout.write(f"Expires at: {token.expires_at.isoformat()}")
|
||||
else:
|
||||
self.stdout.write("Expires at: never")
|
||||
self.stdout.write(f"Active: {token.is_active}")
|
||||
self.stdout.write("Raw token:")
|
||||
self.stdout.write(raw_token)
|
||||
|
||||
if options["curl"]:
|
||||
base_url = (
|
||||
options["base_url"] or getattr(s, "SERVER_URL", None) or "http://localhost:8000"
|
||||
)
|
||||
endpoint = options["endpoint"]
|
||||
endpoint = endpoint if endpoint.startswith("/") else f"/{endpoint}"
|
||||
url = f"{base_url.rstrip('/')}{endpoint}"
|
||||
curl_cmd = f'curl -H "Authorization: Bearer {raw_token}" "{url}"'
|
||||
self.stdout.write("Curl:")
|
||||
self.stdout.write(curl_cmd)
|
||||
@ -47,7 +47,7 @@ class Command(BaseCommand):
|
||||
"description": model.description,
|
||||
"kv": model.kv,
|
||||
"data_packages_uuids": [str(p.uuid) for p in model.data_packages.all()],
|
||||
"eval_packages_uuids": [str(p.uuid) for p in model.data_packages.all()],
|
||||
"eval_packages_uuids": [str(p.uuid) for p in model.eval_packages.all()],
|
||||
"threshold": model.threshold,
|
||||
"eval_results": model.eval_results,
|
||||
"multigen_eval": model.multigen_eval,
|
||||
|
||||
@ -41,9 +41,7 @@ class Command(BaseCommand):
|
||||
"SequentialRule",
|
||||
"Scenario",
|
||||
"Setting",
|
||||
"MLRelativeReasoning",
|
||||
"RuleBasedRelativeReasoning",
|
||||
"EnviFormer",
|
||||
"EPModel",
|
||||
"ApplicabilityDomain",
|
||||
"EnzymeLink",
|
||||
]
|
||||
|
||||
83
epdb/management/commands/recreate_db.py
Normal file
83
epdb/management/commands/recreate_db.py
Normal file
@ -0,0 +1,83 @@
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"-n",
|
||||
"--name",
|
||||
type=str,
|
||||
help="Name of the database to recreate. Default is 'appdb'",
|
||||
default="appdb",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-d",
|
||||
"--dump",
|
||||
type=str,
|
||||
help="Path to the dump file",
|
||||
default="./fixtures/db.dump",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-ou",
|
||||
"--oldurl",
|
||||
type=str,
|
||||
help="Old URL, e.g. https://envipath.org/",
|
||||
default="https://envipath.org/",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-nu",
|
||||
"--newurl",
|
||||
type=str,
|
||||
help="New URL, e.g. http://localhost:8000/",
|
||||
default="http://localhost:8000/",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dump_file = options["dump"]
|
||||
|
||||
if not os.path.exists(dump_file):
|
||||
raise ValueError(f"Dump file {dump_file} does not exist")
|
||||
|
||||
db_name = options["name"]
|
||||
|
||||
print(f"Dropping database {db_name} y/n: ", end="")
|
||||
|
||||
if input() in "yY":
|
||||
result = subprocess.run(
|
||||
["dropdb", db_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
print(result.stdout)
|
||||
else:
|
||||
raise ValueError("Aborted")
|
||||
|
||||
print(f"Creating database {db_name}")
|
||||
|
||||
result = subprocess.run(
|
||||
["createdb", db_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
print(result.stdout)
|
||||
print(f"Restoring database {db_name} from {dump_file}")
|
||||
|
||||
result = subprocess.run(
|
||||
["pg_restore", "-d", db_name, dump_file, "--no-owner"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
print(result.stdout)
|
||||
|
||||
if db_name == settings.DATABASES["default"]["NAME"]:
|
||||
call_command("localize_urls", "--old", options["oldurl"], "--new", options["newurl"])
|
||||
else:
|
||||
print("Skipping localize_urls as database is not the default one.")
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,128 +0,0 @@
|
||||
# Generated by Django 5.2.1 on 2025-08-25 18:07
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import model_utils.fields
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('epdb', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ExternalDatabase',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
|
||||
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('name', models.CharField(max_length=100, unique=True, verbose_name='Database Name')),
|
||||
('full_name', models.CharField(blank=True, max_length=255, verbose_name='Full Database Name')),
|
||||
('description', models.TextField(blank=True, verbose_name='Description')),
|
||||
('base_url', models.URLField(blank=True, null=True, verbose_name='Base URL')),
|
||||
('url_pattern', models.CharField(blank=True, help_text="URL pattern with {id} placeholder, e.g., 'https://pubchem.ncbi.nlm.nih.gov/compound/{id}'", max_length=500, verbose_name='URL Pattern')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Is Active')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'External Database',
|
||||
'verbose_name_plural': 'External Databases',
|
||||
'db_table': 'epdb_external_database',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='apitoken',
|
||||
options={'ordering': ['-created'], 'verbose_name': 'API Token', 'verbose_name_plural': 'API Tokens'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='edge',
|
||||
options={},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='edge',
|
||||
name='polymorphic_ctype',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='apitoken',
|
||||
name='is_active',
|
||||
field=models.BooleanField(default=True, help_text='Whether this token is active'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='apitoken',
|
||||
name='modified',
|
||||
field=model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='applicabilitydomain',
|
||||
name='functional_groups',
|
||||
field=models.JSONField(blank=True, default=dict, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='mlrelativereasoning',
|
||||
name='app_domain',
|
||||
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.applicabilitydomain'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='apitoken',
|
||||
name='created',
|
||||
field=model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='apitoken',
|
||||
name='expires_at',
|
||||
field=models.DateTimeField(blank=True, help_text='Token expiration time (null for no expiration)', null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='apitoken',
|
||||
name='hashed_key',
|
||||
field=models.CharField(help_text='SHA-256 hash of the token key', max_length=128, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='apitoken',
|
||||
name='name',
|
||||
field=models.CharField(help_text='Descriptive name for this token', max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='apitoken',
|
||||
name='user',
|
||||
field=models.ForeignKey(help_text='User who owns this token', on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='applicabilitydomain',
|
||||
name='num_neighbours',
|
||||
field=models.IntegerField(default=5),
|
||||
),
|
||||
migrations.AlterModelTable(
|
||||
name='apitoken',
|
||||
table='epdb_api_token',
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ExternalIdentifier',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
|
||||
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('object_id', models.IntegerField()),
|
||||
('identifier_value', models.CharField(max_length=255, verbose_name='Identifier Value')),
|
||||
('url', models.URLField(blank=True, null=True, verbose_name='Direct URL')),
|
||||
('is_primary', models.BooleanField(default=False, help_text='Mark this as the primary identifier for this database', verbose_name='Is Primary')),
|
||||
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
|
||||
('database', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.externaldatabase', verbose_name='External Database')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'External Identifier',
|
||||
'verbose_name_plural': 'External Identifiers',
|
||||
'db_table': 'epdb_external_identifier',
|
||||
'indexes': [models.Index(fields=['content_type', 'object_id'], name='epdb_extern_content_b76813_idx'), models.Index(fields=['database', 'identifier_value'], name='epdb_extern_databas_486422_idx')],
|
||||
'unique_together': {('content_type', 'object_id', 'database', 'identifier_value')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -1,228 +0,0 @@
|
||||
# Generated by Django 5.2.1 on 2025-08-26 17:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def populate_url(apps, schema_editor):
|
||||
MODELS = [
|
||||
'User',
|
||||
'Group',
|
||||
'Package',
|
||||
'Compound',
|
||||
'CompoundStructure',
|
||||
'Pathway',
|
||||
'Edge',
|
||||
'Node',
|
||||
'Reaction',
|
||||
'SimpleAmbitRule',
|
||||
'SimpleRDKitRule',
|
||||
'ParallelRule',
|
||||
'SequentialRule',
|
||||
'Scenario',
|
||||
'Setting',
|
||||
'MLRelativeReasoning',
|
||||
'EnviFormer',
|
||||
'ApplicabilityDomain',
|
||||
]
|
||||
for model in MODELS:
|
||||
obj_cls = apps.get_model("epdb", model)
|
||||
for obj in obj_cls.objects.all():
|
||||
obj.url = assemble_url(obj)
|
||||
if obj.url is None:
|
||||
raise ValueError(f"Could not assemble url for {obj}")
|
||||
obj.save()
|
||||
|
||||
|
||||
def assemble_url(obj):
|
||||
from django.conf import settings as s
|
||||
match obj.__class__.__name__:
|
||||
case 'User':
|
||||
return '{}/user/{}'.format(s.SERVER_URL, obj.uuid)
|
||||
case 'Group':
|
||||
return '{}/group/{}'.format(s.SERVER_URL, obj.uuid)
|
||||
case 'Package':
|
||||
return '{}/package/{}'.format(s.SERVER_URL, obj.uuid)
|
||||
case 'Compound':
|
||||
return '{}/compound/{}'.format(obj.package.url, obj.uuid)
|
||||
case 'CompoundStructure':
|
||||
return '{}/structure/{}'.format(obj.compound.url, obj.uuid)
|
||||
case 'SimpleAmbitRule':
|
||||
return '{}/simple-ambit-rule/{}'.format(obj.package.url, obj.uuid)
|
||||
case 'SimpleRDKitRule':
|
||||
return '{}/simple-rdkit-rule/{}'.format(obj.package.url, obj.uuid)
|
||||
case 'ParallelRule':
|
||||
return '{}/parallel-rule/{}'.format(obj.package.url, obj.uuid)
|
||||
case 'SequentialRule':
|
||||
return '{}/sequential-rule/{}'.format(obj.compound.url, obj.uuid)
|
||||
case 'Reaction':
|
||||
return '{}/reaction/{}'.format(obj.package.url, obj.uuid)
|
||||
case 'Pathway':
|
||||
return '{}/pathway/{}'.format(obj.package.url, obj.uuid)
|
||||
case 'Node':
|
||||
return '{}/node/{}'.format(obj.pathway.url, obj.uuid)
|
||||
case 'Edge':
|
||||
return '{}/edge/{}'.format(obj.pathway.url, obj.uuid)
|
||||
case 'MLRelativeReasoning':
|
||||
return '{}/model/{}'.format(obj.package.url, obj.uuid)
|
||||
case 'EnviFormer':
|
||||
return '{}/model/{}'.format(obj.package.url, obj.uuid)
|
||||
case 'ApplicabilityDomain':
|
||||
return '{}/model/{}/applicability-domain/{}'.format(obj.model.package.url, obj.model.uuid, obj.uuid)
|
||||
case 'Scenario':
|
||||
return '{}/scenario/{}'.format(obj.package.url, obj.uuid)
|
||||
case 'Setting':
|
||||
return '{}/setting/{}'.format(s.SERVER_URL, obj.uuid)
|
||||
case _:
|
||||
raise ValueError(f"Unknown model {obj.__class__.__name__}")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('epdb', '0002_externaldatabase_alter_apitoken_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='applicabilitydomain',
|
||||
name='url',
|
||||
field=models.TextField(null=True, unique=False, verbose_name='URL'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='compound',
|
||||
name='url',
|
||||
field=models.TextField(null=True, unique=False, verbose_name='URL'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='compoundstructure',
|
||||
name='url',
|
||||
field=models.TextField(null=True, unique=False, verbose_name='URL'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='edge',
|
||||
name='url',
|
||||
field=models.TextField(null=True, unique=False, verbose_name='URL'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='epmodel',
|
||||
name='url',
|
||||
field=models.TextField(null=True, unique=False, verbose_name='URL'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='group',
|
||||
name='url',
|
||||
field=models.TextField(null=True, unique=False, verbose_name='URL'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='node',
|
||||
name='url',
|
||||
field=models.TextField(null=True, unique=False, verbose_name='URL'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='package',
|
||||
name='url',
|
||||
field=models.TextField(null=True, unique=False, verbose_name='URL'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='pathway',
|
||||
name='url',
|
||||
field=models.TextField(null=True, unique=False, verbose_name='URL'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='reaction',
|
||||
name='url',
|
||||
field=models.TextField(null=True, unique=False, verbose_name='URL'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rule',
|
||||
name='url',
|
||||
field=models.TextField(null=True, unique=False, verbose_name='URL'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='scenario',
|
||||
name='url',
|
||||
field=models.TextField(null=True, unique=False, verbose_name='URL'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='setting',
|
||||
name='url',
|
||||
field=models.TextField(null=True, unique=False, verbose_name='URL'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='url',
|
||||
field=models.TextField(null=True, unique=False, verbose_name='URL'),
|
||||
),
|
||||
|
||||
migrations.RunPython(populate_url, reverse_code=migrations.RunPython.noop),
|
||||
|
||||
migrations.AlterField(
|
||||
model_name='applicabilitydomain',
|
||||
name='url',
|
||||
field=models.TextField(null=True, unique=True, verbose_name='URL'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='compound',
|
||||
name='url',
|
||||
field=models.TextField(null=True, unique=True, verbose_name='URL'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='compoundstructure',
|
||||
name='url',
|
||||
field=models.TextField(null=True, unique=True, verbose_name='URL'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='edge',
|
||||
name='url',
|
||||
field=models.TextField(null=True, unique=True, verbose_name='URL'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='epmodel',
|
||||
name='url',
|
||||
field=models.TextField(null=True, unique=True, verbose_name='URL'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='group',
|
||||
name='url',
|
||||
field=models.TextField(null=True, unique=True, verbose_name='URL'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='node',
|
||||
name='url',
|
||||
field=models.TextField(null=True, unique=True, verbose_name='URL'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='package',
|
||||
name='url',
|
||||
field=models.TextField(null=True, unique=True, verbose_name='URL'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='pathway',
|
||||
name='url',
|
||||
field=models.TextField(null=True, unique=True, verbose_name='URL'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='reaction',
|
||||
name='url',
|
||||
field=models.TextField(null=True, unique=True, verbose_name='URL'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rule',
|
||||
name='url',
|
||||
field=models.TextField(null=True, unique=True, verbose_name='URL'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='scenario',
|
||||
name='url',
|
||||
field=models.TextField(null=True, unique=True, verbose_name='URL'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='setting',
|
||||
name='url',
|
||||
field=models.TextField(null=True, unique=True, verbose_name='URL'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='url',
|
||||
field=models.TextField(null=True, unique=True, verbose_name='URL'),
|
||||
),
|
||||
]
|
||||
@ -1,55 +0,0 @@
|
||||
# Generated by Django 5.2.1 on 2025-09-09 09:21
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epdb', '0001_squashed_0003_applicabilitydomain_url_compound_url_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='mlrelativereasoning',
|
||||
options={},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='mlrelativereasoning',
|
||||
name='data_packages',
|
||||
field=models.ManyToManyField(related_name='%(app_label)s_%(class)s_data_packages', to='epdb.package', verbose_name='Data Packages'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='mlrelativereasoning',
|
||||
name='eval_packages',
|
||||
field=models.ManyToManyField(related_name='%(app_label)s_%(class)s_eval_packages', to='epdb.package', verbose_name='Evaluation Packages'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='mlrelativereasoning',
|
||||
name='rule_packages',
|
||||
field=models.ManyToManyField(related_name='%(app_label)s_%(class)s_rule_packages', to='epdb.package', verbose_name='Rule Packages'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RuleBasedRelativeReasoning',
|
||||
fields=[
|
||||
('epmodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.epmodel')),
|
||||
('threshold', models.FloatField(default=0.5)),
|
||||
('eval_results', models.JSONField(blank=True, default=dict, null=True)),
|
||||
('model_status', models.CharField(choices=[('INITIAL', 'Initial'), ('INITIALIZING', 'Model is initializing.'), ('BUILDING', 'Model is building.'), ('BUILT_NOT_EVALUATED', 'Model is built and can be used for predictions, Model is not evaluated yet.'), ('EVALUATING', 'Model is evaluating'), ('FINISHED', 'Model has finished building and evaluation.'), ('ERROR', 'Model has failed.')], default='INITIAL')),
|
||||
('min_count', models.IntegerField(default=10)),
|
||||
('max_count', models.IntegerField(default=0)),
|
||||
('app_domain', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.applicabilitydomain')),
|
||||
('data_packages', models.ManyToManyField(related_name='%(app_label)s_%(class)s_data_packages', to='epdb.package', verbose_name='Data Packages')),
|
||||
('eval_packages', models.ManyToManyField(related_name='%(app_label)s_%(class)s_eval_packages', to='epdb.package', verbose_name='Evaluation Packages')),
|
||||
('rule_packages', models.ManyToManyField(related_name='%(app_label)s_%(class)s_rule_packages', to='epdb.package', verbose_name='Rule Packages')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=('epdb.epmodel',),
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='RuleBaseRelativeReasoning',
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.1 on 2025-09-11 06:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epdb', '0004_alter_mlrelativereasoning_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='group',
|
||||
name='group_member',
|
||||
field=models.ManyToManyField(blank=True, related_name='groups_in_group', to='epdb.group', verbose_name='Group member'),
|
||||
),
|
||||
]
|
||||
@ -1,23 +0,0 @@
|
||||
# Generated by Django 5.2.1 on 2025-09-18 06:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epdb', '0005_alter_group_group_member'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='mlrelativereasoning',
|
||||
name='multigen_eval',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rulebasedrelativereasoning',
|
||||
name='multigen_eval',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@ -1,53 +0,0 @@
|
||||
# Generated by Django 5.2.1 on 2025-10-07 08:19
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epdb', '0006_mlrelativereasoning_multigen_eval_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='enviformer',
|
||||
options={},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='enviformer',
|
||||
name='app_domain',
|
||||
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.applicabilitydomain'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='enviformer',
|
||||
name='data_packages',
|
||||
field=models.ManyToManyField(related_name='%(app_label)s_%(class)s_data_packages', to='epdb.package', verbose_name='Data Packages'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='enviformer',
|
||||
name='eval_packages',
|
||||
field=models.ManyToManyField(related_name='%(app_label)s_%(class)s_eval_packages', to='epdb.package', verbose_name='Evaluation Packages'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='enviformer',
|
||||
name='eval_results',
|
||||
field=models.JSONField(blank=True, default=dict, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='enviformer',
|
||||
name='model_status',
|
||||
field=models.CharField(choices=[('INITIAL', 'Initial'), ('INITIALIZING', 'Model is initializing.'), ('BUILDING', 'Model is building.'), ('BUILT_NOT_EVALUATED', 'Model is built and can be used for predictions, Model is not evaluated yet.'), ('EVALUATING', 'Model is evaluating'), ('FINISHED', 'Model has finished building and evaluation.'), ('ERROR', 'Model has failed.')], default='INITIAL'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='enviformer',
|
||||
name='multigen_eval',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='enviformer',
|
||||
name='rule_packages',
|
||||
field=models.ManyToManyField(related_name='%(app_label)s_%(class)s_rule_packages', to='epdb.package', verbose_name='Rule Packages'),
|
||||
),
|
||||
]
|
||||
@ -1,64 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-10 06:58
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import model_utils.fields
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("epdb", "0007_alter_enviformer_options_enviformer_app_domain_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="EnzymeLink",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
model_utils.fields.AutoCreatedField(
|
||||
default=django.utils.timezone.now, editable=False, verbose_name="created"
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
model_utils.fields.AutoLastModifiedField(
|
||||
default=django.utils.timezone.now, editable=False, verbose_name="modified"
|
||||
),
|
||||
),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4, unique=True, verbose_name="UUID of this object"
|
||||
),
|
||||
),
|
||||
("name", models.TextField(default="no name", verbose_name="Name")),
|
||||
(
|
||||
"description",
|
||||
models.TextField(default="no description", verbose_name="Descriptions"),
|
||||
),
|
||||
("url", models.TextField(null=True, unique=True, verbose_name="URL")),
|
||||
("kv", models.JSONField(blank=True, default=dict, null=True)),
|
||||
("ec_number", models.TextField(verbose_name="EC Number")),
|
||||
("classification_level", models.IntegerField(verbose_name="Classification Level")),
|
||||
("linking_method", models.TextField(verbose_name="Linking Method")),
|
||||
("edge_evidence", models.ManyToManyField(to="epdb.edge")),
|
||||
("reaction_evidence", models.ManyToManyField(to="epdb.reaction")),
|
||||
(
|
||||
"rule",
|
||||
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="epdb.rule"),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -1,66 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-27 09:39
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import model_utils.fields
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("epdb", "0008_enzymelink"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="JobLog",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
model_utils.fields.AutoCreatedField(
|
||||
default=django.utils.timezone.now, editable=False, verbose_name="created"
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
model_utils.fields.AutoLastModifiedField(
|
||||
default=django.utils.timezone.now, editable=False, verbose_name="modified"
|
||||
),
|
||||
),
|
||||
("task_id", models.UUIDField(unique=True)),
|
||||
("job_name", models.TextField()),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("INITIAL", "Initial"),
|
||||
("SUCCESS", "Success"),
|
||||
("FAILURE", "Failure"),
|
||||
("REVOKED", "Revoked"),
|
||||
("IGNORED", "Ignored"),
|
||||
],
|
||||
default="INITIAL",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("done_at", models.DateTimeField(blank=True, default=None, null=True)),
|
||||
("task_result", models.TextField(blank=True, default=None, null=True)),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-11 14:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("epdb", "0009_joblog"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="license",
|
||||
name="cc_string",
|
||||
field=models.TextField(default="by-nc-sa", verbose_name="CC string"),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
@ -1,59 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-11 14:13
|
||||
|
||||
import re
|
||||
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.db import migrations
|
||||
from django.db.models import Min
|
||||
|
||||
|
||||
def set_cc(apps, schema_editor):
|
||||
License = apps.get_model("epdb", "License")
|
||||
|
||||
# For all existing licenses extract cc_string from link
|
||||
for license in License.objects.all():
|
||||
pattern = r"/licenses/([^/]+)/4\.0"
|
||||
match = re.search(pattern, license.link)
|
||||
if match:
|
||||
license.cc_string = match.group(1)
|
||||
license.save()
|
||||
else:
|
||||
raise ValueError(f"Could not find license for {license.link}")
|
||||
|
||||
# Ensure we have all licenses
|
||||
cc_strings = ["by", "by-nc", "by-nc-nd", "by-nc-sa", "by-nd", "by-sa"]
|
||||
for cc_string in cc_strings:
|
||||
if not License.objects.filter(cc_string=cc_string).exists():
|
||||
new_license = License()
|
||||
new_license.cc_string = cc_string
|
||||
new_license.link = f"https://creativecommons.org/licenses/{cc_string}/4.0/"
|
||||
new_license.image_link = f"https://licensebuttons.net/l/{cc_string}/4.0/88x31.png"
|
||||
new_license.save()
|
||||
|
||||
# As we might have existing Licenses representing the same License,
|
||||
# get min pk and all pks as a list
|
||||
license_lookup_qs = License.objects.values("cc_string").annotate(
|
||||
lowest_pk=Min("id"), all_pks=ArrayAgg("id", order_by=("id",))
|
||||
)
|
||||
|
||||
license_lookup = {
|
||||
row["cc_string"]: (row["lowest_pk"], row["all_pks"]) for row in license_lookup_qs
|
||||
}
|
||||
|
||||
Packages = apps.get_model("epdb", "Package")
|
||||
|
||||
for k, v in license_lookup.items():
|
||||
# Set min pk to all packages pointing to any of the duplicates
|
||||
Packages.objects.filter(pk__in=v[1]).update(license_id=v[0])
|
||||
# remove the min pk from "other" pks as we use them for deletion
|
||||
v[1].remove(v[0])
|
||||
# Delete redundant License objects
|
||||
License.objects.filter(pk__in=v[1]).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("epdb", "0010_license_cc_string"),
|
||||
]
|
||||
|
||||
operations = [migrations.RunPython(set_cc)]
|
||||
@ -1,22 +0,0 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@ -1,25 +0,0 @@
|
||||
# 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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -1,17 +0,0 @@
|
||||
# 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",
|
||||
),
|
||||
]
|
||||
@ -1,6 +1,5 @@
|
||||
# Generated by Django 5.2.1 on 2025-07-22 20:58
|
||||
# Generated by Django 5.2.7 on 2026-03-06 10:51
|
||||
|
||||
import datetime
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
import django.contrib.postgres.fields
|
||||
@ -19,11 +18,12 @@ class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
migrations.swappable_dependency(settings.EPDB_PACKAGE_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Compound',
|
||||
name='ApplicabilityDomain',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
|
||||
@ -31,9 +31,33 @@ class Migration(migrations.Migration):
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
|
||||
('name', models.TextField(default='no name', verbose_name='Name')),
|
||||
('description', models.TextField(default='no description', verbose_name='Descriptions')),
|
||||
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
|
||||
('kv', models.JSONField(blank=True, default=dict, null=True)),
|
||||
('num_neighbours', models.IntegerField(default=5)),
|
||||
('reliability_threshold', models.FloatField(default=0.5)),
|
||||
('local_compatibilty_threshold', models.FloatField(default=0.5)),
|
||||
('functional_groups', models.JSONField(blank=True, default=dict, null=True)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Edge',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
|
||||
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
|
||||
('name', models.TextField(default='no name', verbose_name='Name')),
|
||||
('description', models.TextField(default='no description', verbose_name='Descriptions')),
|
||||
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
|
||||
('kv', models.JSONField(blank=True, default=dict, null=True)),
|
||||
('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EPModel',
|
||||
@ -44,7 +68,10 @@ class Migration(migrations.Migration):
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
|
||||
('name', models.TextField(default='no name', verbose_name='Name')),
|
||||
('description', models.TextField(default='no description', verbose_name='Descriptions')),
|
||||
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
|
||||
('kv', models.JSONField(blank=True, default=dict, null=True)),
|
||||
('model_status', models.CharField(choices=[('INITIAL', 'Initial'), ('INITIALIZING', 'Model is initializing.'), ('BUILDING', 'Model is building.'), ('BUILT_NOT_EVALUATED', 'Model is built and can be used for predictions, Model is not evaluated yet.'), ('EVALUATING', 'Model is evaluating'), ('FINISHED', 'Model has finished building and evaluation.'), ('ERROR', 'Model has failed.')], default='INITIAL')),
|
||||
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.EPDB_PACKAGE_MODEL, verbose_name='Package')),
|
||||
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype')),
|
||||
],
|
||||
options={
|
||||
@ -52,6 +79,27 @@ class Migration(migrations.Migration):
|
||||
'base_manager_name': 'objects',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ExternalDatabase',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
|
||||
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('name', models.CharField(max_length=100, unique=True, verbose_name='Database Name')),
|
||||
('full_name', models.CharField(blank=True, max_length=255, verbose_name='Full Database Name')),
|
||||
('description', models.TextField(blank=True, verbose_name='Description')),
|
||||
('base_url', models.URLField(blank=True, null=True, verbose_name='Base URL')),
|
||||
('url_pattern', models.CharField(blank=True, help_text="URL pattern with {id} placeholder, e.g., 'https://pubchem.ncbi.nlm.nih.gov/compound/{id}'", max_length=500, verbose_name='URL Pattern')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Is Active')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'External Database',
|
||||
'verbose_name_plural': 'External Databases',
|
||||
'db_table': 'epdb_external_database',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Permission',
|
||||
fields=[
|
||||
@ -65,6 +113,7 @@ class Migration(migrations.Migration):
|
||||
name='License',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('cc_string', models.TextField(verbose_name='CC string')),
|
||||
('link', models.URLField(verbose_name='link')),
|
||||
('image_link', models.URLField(verbose_name='Image link')),
|
||||
],
|
||||
@ -78,8 +127,11 @@ class Migration(migrations.Migration):
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
|
||||
('name', models.TextField(default='no name', verbose_name='Name')),
|
||||
('description', models.TextField(default='no description', verbose_name='Descriptions')),
|
||||
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
|
||||
('kv', models.JSONField(blank=True, default=dict, null=True)),
|
||||
('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')),
|
||||
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.EPDB_PACKAGE_MODEL, verbose_name='Package')),
|
||||
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
@ -101,6 +153,9 @@ class Migration(migrations.Migration):
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('email', models.EmailField(max_length=254, unique=True)),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
|
||||
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
|
||||
('is_reviewer', models.BooleanField(default=False)),
|
||||
('default_package', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.EPDB_PACKAGE_MODEL, verbose_name='Default Package')),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
@ -117,11 +172,34 @@ class Migration(migrations.Migration):
|
||||
name='APIToken',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('hashed_key', models.CharField(max_length=128, unique=True)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('expires_at', models.DateTimeField(blank=True, default=datetime.datetime(2025, 10, 20, 20, 58, 48, 351675, tzinfo=datetime.timezone.utc), null=True)),
|
||||
('name', models.CharField(blank=True, help_text='Optional name for the token', max_length=100)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
|
||||
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
|
||||
('hashed_key', models.CharField(help_text='SHA-256 hash of the token key', max_length=128, unique=True)),
|
||||
('expires_at', models.DateTimeField(blank=True, help_text='Token expiration time (null for no expiration)', null=True)),
|
||||
('name', models.CharField(help_text='Descriptive name for this token', max_length=100)),
|
||||
('is_active', models.BooleanField(default=True, help_text='Whether this token is active')),
|
||||
('user', models.ForeignKey(help_text='User who owns this token', on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'API Token',
|
||||
'verbose_name_plural': 'API Tokens',
|
||||
'db_table': 'epdb_api_token',
|
||||
'ordering': ['-created'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Compound',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
|
||||
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
|
||||
('name', models.TextField(default='no name', verbose_name='Name')),
|
||||
('description', models.TextField(default='no description', verbose_name='Descriptions')),
|
||||
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
|
||||
('kv', models.JSONField(blank=True, default=dict, null=True)),
|
||||
('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')),
|
||||
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.EPDB_PACKAGE_MODEL, verbose_name='Package')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
@ -133,6 +211,7 @@ class Migration(migrations.Migration):
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
|
||||
('name', models.TextField(default='no name', verbose_name='Name')),
|
||||
('description', models.TextField(default='no description', verbose_name='Descriptions')),
|
||||
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
|
||||
('kv', models.JSONField(blank=True, default=dict, null=True)),
|
||||
('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')),
|
||||
('smiles', models.TextField(verbose_name='SMILES')),
|
||||
@ -151,54 +230,16 @@ class Migration(migrations.Migration):
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='compound_default_structure', to='epdb.compoundstructure', verbose_name='Default Structure'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Edge',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
|
||||
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
|
||||
('name', models.TextField(default='no name', verbose_name='Name')),
|
||||
('description', models.TextField(default='no description', verbose_name='Descriptions')),
|
||||
('kv', models.JSONField(blank=True, default=dict, null=True)),
|
||||
('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')),
|
||||
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
'base_manager_name': 'objects',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EnviFormer',
|
||||
name='PropertyPluginModel',
|
||||
fields=[
|
||||
('epmodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.epmodel')),
|
||||
('threshold', models.FloatField(default=0.5)),
|
||||
('eval_results', models.JSONField(blank=True, default=dict, null=True)),
|
||||
('multigen_eval', models.BooleanField(default=False)),
|
||||
('plugin_identifier', models.CharField(max_length=255)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
'base_manager_name': 'objects',
|
||||
},
|
||||
bases=('epdb.epmodel',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PluginModel',
|
||||
fields=[
|
||||
('epmodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.epmodel')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
'base_manager_name': 'objects',
|
||||
},
|
||||
bases=('epdb.epmodel',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RuleBaseRelativeReasoning',
|
||||
fields=[
|
||||
('epmodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.epmodel')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
'base_manager_name': 'objects',
|
||||
},
|
||||
bases=('epdb.epmodel',),
|
||||
),
|
||||
@ -209,10 +250,11 @@ class Migration(migrations.Migration):
|
||||
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
|
||||
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
|
||||
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
|
||||
('name', models.TextField(verbose_name='Group name')),
|
||||
('public', models.BooleanField(default=False, verbose_name='Public Group')),
|
||||
('description', models.TextField(default='no description', verbose_name='Descriptions')),
|
||||
('group_member', models.ManyToManyField(related_name='groups_in_group', to='epdb.group', verbose_name='Group member')),
|
||||
('group_member', models.ManyToManyField(blank=True, related_name='groups_in_group', to='epdb.group', verbose_name='Group member')),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Group Owner')),
|
||||
('user_member', models.ManyToManyField(related_name='users_in_group', to=settings.AUTH_USER_MODEL, verbose_name='User members')),
|
||||
],
|
||||
@ -225,6 +267,41 @@ class Migration(migrations.Migration):
|
||||
name='default_group',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_group', to='epdb.group', verbose_name='Default Group'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='JobLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
|
||||
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
|
||||
('task_id', models.UUIDField(unique=True)),
|
||||
('job_name', models.TextField()),
|
||||
('status', models.CharField(choices=[('INITIAL', 'Initial'), ('SUCCESS', 'Success'), ('FAILURE', 'Failure'), ('REVOKED', 'Revoked'), ('IGNORED', 'Ignored')], default='INITIAL', max_length=20)),
|
||||
('done_at', models.DateTimeField(blank=True, default=None, null=True)),
|
||||
('task_result', models.TextField(blank=True, default=None, null=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Package',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
|
||||
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
|
||||
('name', models.TextField(default='no name', verbose_name='Name')),
|
||||
('description', models.TextField(default='no description', verbose_name='Descriptions')),
|
||||
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
|
||||
('kv', models.JSONField(blank=True, default=dict, null=True)),
|
||||
('reviewed', models.BooleanField(default=False, verbose_name='Reviewstatus')),
|
||||
('license', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.license', verbose_name='License')),
|
||||
],
|
||||
options={
|
||||
'swappable': 'EPDB_PACKAGE_MODEL',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Node',
|
||||
fields=[
|
||||
@ -234,9 +311,11 @@ class Migration(migrations.Migration):
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
|
||||
('name', models.TextField(default='no name', verbose_name='Name')),
|
||||
('description', models.TextField(default='no description', verbose_name='Descriptions')),
|
||||
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
|
||||
('kv', models.JSONField(blank=True, default=dict, null=True)),
|
||||
('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')),
|
||||
('depth', models.IntegerField(verbose_name='Node depth')),
|
||||
('stereo_removed', models.BooleanField(default=False)),
|
||||
('default_node_label', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='default_node_structure', to='epdb.compoundstructure', verbose_name='Default Node Label')),
|
||||
('node_labels', models.ManyToManyField(related_name='node_structures', to='epdb.compoundstructure', verbose_name='All Node Labels')),
|
||||
('out_edges', models.ManyToManyField(to='epdb.edge', verbose_name='Outgoing Edges')),
|
||||
@ -255,38 +334,6 @@ class Migration(migrations.Migration):
|
||||
name='start_nodes',
|
||||
field=models.ManyToManyField(related_name='edge_educts', to='epdb.node', verbose_name='Start Nodes'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Package',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
|
||||
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
|
||||
('name', models.TextField(default='no name', verbose_name='Name')),
|
||||
('description', models.TextField(default='no description', verbose_name='Descriptions')),
|
||||
('kv', models.JSONField(blank=True, default=dict, null=True)),
|
||||
('reviewed', models.BooleanField(default=False, verbose_name='Reviewstatus')),
|
||||
('license', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.license', verbose_name='License')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='epmodel',
|
||||
name='package',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.package', verbose_name='Package'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='compound',
|
||||
name='package',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.package', verbose_name='Package'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='default_package',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.package', verbose_name='Default Package'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SequentialRule',
|
||||
fields=[
|
||||
@ -309,16 +356,6 @@ class Migration(migrations.Migration):
|
||||
},
|
||||
bases=('epdb.rule',),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rule',
|
||||
name='package',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.package', verbose_name='Package'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rule',
|
||||
name='polymorphic_ctype',
|
||||
field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Pathway',
|
||||
fields=[
|
||||
@ -328,9 +365,11 @@ class Migration(migrations.Migration):
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
|
||||
('name', models.TextField(default='no name', verbose_name='Name')),
|
||||
('description', models.TextField(default='no description', verbose_name='Descriptions')),
|
||||
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
|
||||
('kv', models.JSONField(blank=True, default=dict, null=True)),
|
||||
('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')),
|
||||
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.package', verbose_name='Package')),
|
||||
('predicted', models.BooleanField(default=False)),
|
||||
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.EPDB_PACKAGE_MODEL, verbose_name='Package')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
@ -355,12 +394,13 @@ class Migration(migrations.Migration):
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
|
||||
('name', models.TextField(default='no name', verbose_name='Name')),
|
||||
('description', models.TextField(default='no description', verbose_name='Descriptions')),
|
||||
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
|
||||
('kv', models.JSONField(blank=True, default=dict, null=True)),
|
||||
('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')),
|
||||
('multi_step', models.BooleanField(verbose_name='Multistep Reaction')),
|
||||
('medline_references', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), null=True, size=None, verbose_name='Medline References')),
|
||||
('educts', models.ManyToManyField(related_name='reaction_educts', to='epdb.compoundstructure', verbose_name='Educts')),
|
||||
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.package', verbose_name='Package')),
|
||||
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.EPDB_PACKAGE_MODEL, verbose_name='Package')),
|
||||
('products', models.ManyToManyField(related_name='reaction_products', to='epdb.compoundstructure', verbose_name='Products')),
|
||||
('rules', models.ManyToManyField(related_name='reaction_rule', to='epdb.rule', verbose_name='Rule')),
|
||||
],
|
||||
@ -368,6 +408,28 @@ class Migration(migrations.Migration):
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EnzymeLink',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
|
||||
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
|
||||
('name', models.TextField(default='no name', verbose_name='Name')),
|
||||
('description', models.TextField(default='no description', verbose_name='Descriptions')),
|
||||
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
|
||||
('kv', models.JSONField(blank=True, default=dict, null=True)),
|
||||
('ec_number', models.TextField(verbose_name='EC Number')),
|
||||
('classification_level', models.IntegerField(verbose_name='Classification Level')),
|
||||
('linking_method', models.TextField(verbose_name='Linking Method')),
|
||||
('edge_evidence', models.ManyToManyField(to='epdb.edge')),
|
||||
('rule', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.rule')),
|
||||
('reaction_evidence', models.ManyToManyField(to='epdb.reaction')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='edge',
|
||||
name='edge_label',
|
||||
@ -382,12 +444,11 @@ class Migration(migrations.Migration):
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
|
||||
('name', models.TextField(default='no name', verbose_name='Name')),
|
||||
('description', models.TextField(default='no description', verbose_name='Descriptions')),
|
||||
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
|
||||
('kv', models.JSONField(blank=True, default=dict, null=True)),
|
||||
('scenario_date', models.CharField(default='No date', max_length=256)),
|
||||
('scenario_type', models.CharField(default='Not specified', max_length=256)),
|
||||
('additional_information', models.JSONField(verbose_name='Additional Information')),
|
||||
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.package', verbose_name='Package')),
|
||||
('parent', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='epdb.scenario')),
|
||||
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.EPDB_PACKAGE_MODEL, verbose_name='Package')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
@ -437,14 +498,17 @@ class Migration(migrations.Migration):
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
|
||||
('name', models.TextField(default='no name', verbose_name='Name')),
|
||||
('description', models.TextField(default='no description', verbose_name='Descriptions')),
|
||||
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
|
||||
('kv', models.JSONField(blank=True, default=dict, null=True)),
|
||||
('public', models.BooleanField(default=False)),
|
||||
('global_default', models.BooleanField(default=False)),
|
||||
('max_depth', models.IntegerField(default=5, verbose_name='Setting Max Depth')),
|
||||
('max_nodes', models.IntegerField(default=30, verbose_name='Setting Max Number of Nodes')),
|
||||
('model_threshold', models.FloatField(blank=True, default=0.25, null=True, verbose_name='Setting Model Threshold')),
|
||||
('expansion_scheme', models.CharField(choices=[('BFS', 'Breadth First Search'), ('DFS', 'Depth First Search'), ('GREEDY', 'Greedy')], default='BFS', max_length=20)),
|
||||
('model', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.epmodel', verbose_name='Setting EPModel')),
|
||||
('rule_packages', models.ManyToManyField(blank=True, related_name='setting_rule_packages', to='epdb.package', verbose_name='Setting Rule Packages')),
|
||||
('rule_packages', models.ManyToManyField(blank=True, related_name='setting_rule_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Setting Rule Packages')),
|
||||
('property_models', models.ManyToManyField(blank=True, related_name='settings', to='epdb.propertypluginmodel', verbose_name='Setting Property Models')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
@ -461,39 +525,103 @@ class Migration(migrations.Migration):
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.setting', verbose_name='The users default settings'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MLRelativeReasoning',
|
||||
name='EnviFormer',
|
||||
fields=[
|
||||
('epmodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.epmodel')),
|
||||
('threshold', models.FloatField(default=0.5)),
|
||||
('model_status', models.CharField(choices=[('INITIAL', 'Initial'), ('INITIALIZING', 'Model is initializing.'), ('BUILDING', 'Model is building.'), ('BUILT_NOT_EVALUATED', 'Model is built and can be used for predictions, Model is not evaluated yet.'), ('EVALUATING', 'Model is evaluating'), ('FINISHED', 'Model has finished building and evaluation.'), ('ERROR', 'Model has failed.')], default='INITIAL')),
|
||||
('eval_results', models.JSONField(blank=True, default=dict, null=True)),
|
||||
('data_packages', models.ManyToManyField(related_name='data_packages', to='epdb.package', verbose_name='Data Packages')),
|
||||
('eval_packages', models.ManyToManyField(related_name='eval_packages', to='epdb.package', verbose_name='Evaluation Packages')),
|
||||
('rule_packages', models.ManyToManyField(related_name='rule_packages', to='epdb.package', verbose_name='Rule Packages')),
|
||||
('multigen_eval', models.BooleanField(default=False)),
|
||||
('app_domain', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.applicabilitydomain')),
|
||||
('data_packages', models.ManyToManyField(related_name='%(app_label)s_%(class)s_data_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Data Packages')),
|
||||
('eval_packages', models.ManyToManyField(blank=True, related_name='%(app_label)s_%(class)s_eval_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Evaluation Packages')),
|
||||
('rule_packages', models.ManyToManyField(blank=True, related_name='%(app_label)s_%(class)s_rule_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Rule Packages')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
'base_manager_name': 'objects',
|
||||
},
|
||||
bases=('epdb.epmodel',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ApplicabilityDomain',
|
||||
name='MLRelativeReasoning',
|
||||
fields=[
|
||||
('epmodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.epmodel')),
|
||||
('threshold', models.FloatField(default=0.5)),
|
||||
('eval_results', models.JSONField(blank=True, default=dict, null=True)),
|
||||
('multigen_eval', models.BooleanField(default=False)),
|
||||
('app_domain', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.applicabilitydomain')),
|
||||
('data_packages', models.ManyToManyField(related_name='%(app_label)s_%(class)s_data_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Data Packages')),
|
||||
('eval_packages', models.ManyToManyField(blank=True, related_name='%(app_label)s_%(class)s_eval_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Evaluation Packages')),
|
||||
('rule_packages', models.ManyToManyField(blank=True, related_name='%(app_label)s_%(class)s_rule_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Rule Packages')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=('epdb.epmodel',),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='applicabilitydomain',
|
||||
name='model',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.mlrelativereasoning'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='propertypluginmodel',
|
||||
name='app_domain',
|
||||
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.applicabilitydomain'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='propertypluginmodel',
|
||||
name='data_packages',
|
||||
field=models.ManyToManyField(blank=True, related_name='%(app_label)s_%(class)s_data_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Data Packages'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='propertypluginmodel',
|
||||
name='eval_packages',
|
||||
field=models.ManyToManyField(blank=True, related_name='%(app_label)s_%(class)s_eval_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Evaluation Packages'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='propertypluginmodel',
|
||||
name='rule_packages',
|
||||
field=models.ManyToManyField(blank=True, related_name='%(app_label)s_%(class)s_rule_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Rule Packages'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RuleBasedRelativeReasoning',
|
||||
fields=[
|
||||
('epmodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.epmodel')),
|
||||
('threshold', models.FloatField(default=0.5)),
|
||||
('eval_results', models.JSONField(blank=True, default=dict, null=True)),
|
||||
('multigen_eval', models.BooleanField(default=False)),
|
||||
('min_count', models.IntegerField(default=10)),
|
||||
('max_count', models.IntegerField(default=0)),
|
||||
('app_domain', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.applicabilitydomain')),
|
||||
('data_packages', models.ManyToManyField(related_name='%(app_label)s_%(class)s_data_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Data Packages')),
|
||||
('eval_packages', models.ManyToManyField(blank=True, related_name='%(app_label)s_%(class)s_eval_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Evaluation Packages')),
|
||||
('rule_packages', models.ManyToManyField(blank=True, related_name='%(app_label)s_%(class)s_rule_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Rule Packages')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=('epdb.epmodel',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ExternalIdentifier',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
|
||||
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
|
||||
('name', models.TextField(default='no name', verbose_name='Name')),
|
||||
('description', models.TextField(default='no description', verbose_name='Descriptions')),
|
||||
('kv', models.JSONField(blank=True, default=dict, null=True)),
|
||||
('num_neighbours', models.FloatField(default=5)),
|
||||
('reliability_threshold', models.FloatField(default=0.5)),
|
||||
('local_compatibilty_threshold', models.FloatField(default=0.5)),
|
||||
('model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.mlrelativereasoning')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('object_id', models.IntegerField()),
|
||||
('identifier_value', models.CharField(max_length=255, verbose_name='Identifier Value')),
|
||||
('url', models.URLField(blank=True, null=True, verbose_name='Direct URL')),
|
||||
('is_primary', models.BooleanField(default=False, help_text='Mark this as the primary identifier for this database', verbose_name='Is Primary')),
|
||||
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
|
||||
('database', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.externaldatabase', verbose_name='External Database')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
'verbose_name': 'External Identifier',
|
||||
'verbose_name_plural': 'External Identifiers',
|
||||
'db_table': 'epdb_external_identifier',
|
||||
'indexes': [models.Index(fields=['content_type', 'object_id'], name='epdb_extern_content_b76813_idx'), models.Index(fields=['database', 'identifier_value'], name='epdb_extern_databas_486422_idx')],
|
||||
'unique_together': {('content_type', 'object_id', 'database', 'identifier_value')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
@ -552,13 +680,32 @@ class Migration(migrations.Migration):
|
||||
name='compound',
|
||||
unique_together={('uuid', 'package')},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AdditionalInformation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
|
||||
('kv', models.JSONField(blank=True, default=dict, null=True)),
|
||||
('type', models.TextField(verbose_name='Additional Information Type')),
|
||||
('data', models.JSONField(blank=True, default=dict, null=True)),
|
||||
('object_id', models.PositiveBigIntegerField(blank=True, null=True)),
|
||||
('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
|
||||
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.EPDB_PACKAGE_MODEL, verbose_name='Package')),
|
||||
('scenario', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='scenario_additional_information', to='epdb.scenario')),
|
||||
],
|
||||
options={
|
||||
'indexes': [models.Index(fields=['type'], name='epdb_additi_type_394349_idx'), models.Index(fields=['scenario', 'type'], name='epdb_additi_scenari_a59edf_idx'), models.Index(fields=['content_type', 'object_id'], name='epdb_additi_content_44d4b4_idx'), models.Index(fields=['scenario', 'content_type', 'object_id'], name='epdb_additi_scenari_ef2bf5_idx')],
|
||||
'constraints': [models.CheckConstraint(condition=models.Q(models.Q(('content_type__isnull', True), ('object_id__isnull', True)), models.Q(('content_type__isnull', False), ('object_id__isnull', False)), _connector='OR'), name='ck_addinfo_gfk_pair'), models.CheckConstraint(condition=models.Q(('scenario__isnull', False), ('content_type__isnull', False), _connector='OR'), name='ck_addinfo_not_both_null')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='GroupPackagePermission',
|
||||
fields=[
|
||||
('permission_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, to='epdb.permission')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='UUID of this object')),
|
||||
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.group', verbose_name='Permission to')),
|
||||
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.package', verbose_name='Permission on')),
|
||||
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.EPDB_PACKAGE_MODEL, verbose_name='Permission on')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('package', 'group')},
|
||||
@ -570,7 +717,7 @@ class Migration(migrations.Migration):
|
||||
fields=[
|
||||
('permission_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, to='epdb.permission')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='UUID of this object')),
|
||||
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.package', verbose_name='Permission on')),
|
||||
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.EPDB_PACKAGE_MODEL, verbose_name='Permission on')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Permission to')),
|
||||
],
|
||||
options={
|
||||
@ -0,0 +1,65 @@
|
||||
# Generated by Django 5.2.7 on 2026-03-09 10:41
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def populate_polymorphic_ctype(apps, schema_editor):
|
||||
ContentType = apps.get_model("contenttypes", "ContentType")
|
||||
Compound = apps.get_model("epdb", "Compound")
|
||||
CompoundStructure = apps.get_model("epdb", "CompoundStructure")
|
||||
|
||||
# Update Compound records
|
||||
compound_ct = ContentType.objects.get_for_model(Compound)
|
||||
Compound.objects.filter(polymorphic_ctype__isnull=True).update(polymorphic_ctype=compound_ct)
|
||||
|
||||
# Update CompoundStructure records
|
||||
compound_structure_ct = ContentType.objects.get_for_model(CompoundStructure)
|
||||
CompoundStructure.objects.filter(polymorphic_ctype__isnull=True).update(
|
||||
polymorphic_ctype=compound_structure_ct
|
||||
)
|
||||
|
||||
|
||||
def reverse_populate_polymorphic_ctype(apps, schema_editor):
|
||||
Compound = apps.get_model("epdb", "Compound")
|
||||
CompoundStructure = apps.get_model("epdb", "CompoundStructure")
|
||||
|
||||
Compound.objects.all().update(polymorphic_ctype=None)
|
||||
CompoundStructure.objects.all().update(polymorphic_ctype=None)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
("epdb", "0019_remove_scenario_additional_information_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="compoundstructure",
|
||||
options={"base_manager_name": "objects"},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="compound",
|
||||
name="polymorphic_ctype",
|
||||
field=models.ForeignKey(
|
||||
editable=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="polymorphic_%(app_label)s.%(class)s_set+",
|
||||
to="contenttypes.contenttype",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="compoundstructure",
|
||||
name="polymorphic_ctype",
|
||||
field=models.ForeignKey(
|
||||
editable=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="polymorphic_%(app_label)s.%(class)s_set+",
|
||||
to="contenttypes.contenttype",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(populate_polymorphic_ctype, reverse_populate_polymorphic_ctype),
|
||||
]
|
||||
75
epdb/migrations/0021_classifierpluginmodel.py
Normal file
75
epdb/migrations/0021_classifierpluginmodel.py
Normal file
@ -0,0 +1,75 @@
|
||||
# Generated by Django 5.2.7 on 2026-03-25 11:44
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("epdb", "0020_alter_compoundstructure_options_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ClassifierPluginModel",
|
||||
fields=[
|
||||
(
|
||||
"epmodel_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="epdb.epmodel",
|
||||
),
|
||||
),
|
||||
("threshold", models.FloatField(default=0.5)),
|
||||
("eval_results", models.JSONField(blank=True, default=dict, null=True)),
|
||||
("multigen_eval", models.BooleanField(default=False)),
|
||||
("plugin_identifier", models.CharField(max_length=255)),
|
||||
("plugin_config", models.JSONField(blank=True, default=dict, null=True)),
|
||||
(
|
||||
"app_domain",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="epdb.applicabilitydomain",
|
||||
),
|
||||
),
|
||||
(
|
||||
"data_packages",
|
||||
models.ManyToManyField(
|
||||
related_name="%(app_label)s_%(class)s_data_packages",
|
||||
to=settings.EPDB_PACKAGE_MODEL,
|
||||
verbose_name="Data Packages",
|
||||
),
|
||||
),
|
||||
(
|
||||
"eval_packages",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="%(app_label)s_%(class)s_eval_packages",
|
||||
to=settings.EPDB_PACKAGE_MODEL,
|
||||
verbose_name="Evaluation Packages",
|
||||
),
|
||||
),
|
||||
(
|
||||
"rule_packages",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="%(app_label)s_%(class)s_rule_packages",
|
||||
to=settings.EPDB_PACKAGE_MODEL,
|
||||
verbose_name="Rule Packages",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
bases=("epdb.epmodel",),
|
||||
),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user