forked from enviPath/enviPy
Compare commits
48 Commits
6499a0c659
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
|
||||
98
Dockerfile
Normal file
98
Dockerfile
Normal file
@ -0,0 +1,98 @@
|
||||
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: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 biotransformer biotransformer
|
||||
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
|
||||
```
|
||||
|
||||
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
400
bridge/contracts.py
Normal file
400
bridge/contracts.py
Normal file
@ -0,0 +1,400 @@
|
||||
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
|
||||
return cls.Config(**(data or {}))
|
||||
|
||||
@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,6 +1,6 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:15
|
||||
image: postgres:18
|
||||
container_name: envipath-postgres
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
@ -9,12 +9,18 @@ services:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- 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: envipath-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
volumes:
|
||||
postgres_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_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_redis_data:/data
|
||||
|
||||
biotransformer3:
|
||||
image: envipath/biotransformer3:1.0
|
||||
container_name: epbiotransformer3
|
||||
|
||||
web:
|
||||
image: envipath/envipy: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_data:/opt/enviPy/
|
||||
|
||||
celery_worker:
|
||||
image: envipath/envipy:1.0
|
||||
container_name: epcelery
|
||||
env_file:
|
||||
- .env
|
||||
command: celery -A envipath worker --concurrency=6 -Q model,predict,background --pool threads
|
||||
volumes:
|
||||
- ep_data:/opt/enviPy/
|
||||
|
||||
volumes:
|
||||
ep_postgres_data:
|
||||
ep_redis_data:
|
||||
ep_data:
|
||||
@ -14,14 +14,15 @@ 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")
|
||||
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,13 @@ 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"],
|
||||
}
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
|
||||
|
||||
@ -173,11 +191,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 +227,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 +304,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 +337,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 +375,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 +387,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 +400,6 @@ LOGIN_EXEMPT_URLS = [
|
||||
"/o/userinfo/",
|
||||
"/password_reset/",
|
||||
"/reset/",
|
||||
"/microsoft/",
|
||||
"/terms",
|
||||
"/privacy",
|
||||
"/cookie-policy",
|
||||
@ -377,8 +408,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 +430,15 @@ 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)
|
||||
|
||||
@ -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,6 @@ 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"),
|
||||
]
|
||||
|
||||
128
epauth/views.py
128
epauth/views.py
@ -1,34 +1,49 @@
|
||||
import msal
|
||||
from django.conf import settings as s
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth import login
|
||||
from django.shortcuts import redirect
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from epdb.logic import UserManager
|
||||
|
||||
|
||||
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 +52,79 @@ 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))
|
||||
else:
|
||||
u = UserManager.create_user(user_name, user_email, None, uuid=user_oid, is_active=True)
|
||||
login(request, u)
|
||||
if not all([user_name, user_email, user_oid]):
|
||||
raise ValueError("Missing required claims in ID token")
|
||||
|
||||
# TODO Group Sync
|
||||
# Get implementing class
|
||||
User = get_user_model()
|
||||
|
||||
return redirect("/")
|
||||
if User.objects.filter(uuid=user_oid).exists():
|
||||
u = User.objects.get(uuid=user_oid)
|
||||
|
||||
return redirect("/") # Handle errors
|
||||
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)
|
||||
|
||||
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.
|
||||
"""
|
||||
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
|
||||
|
||||
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")
|
||||
@ -1,36 +1,58 @@
|
||||
from collections import defaultdict
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import nh3
|
||||
from django.conf import settings as s
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.http import HttpResponse
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.shortcuts import redirect
|
||||
from ninja import Field, Form, Router, Schema, Query
|
||||
from ninja import Field, Form, Query, Router, Schema
|
||||
from ninja.security import SessionAuth
|
||||
|
||||
from utilities.chem import FormatConverter
|
||||
from utilities.misc import PackageExporter
|
||||
|
||||
from .logic import GroupManager, PackageManager, SettingManager, UserManager, SearchManager
|
||||
from .logic import (
|
||||
EPDBURLParser,
|
||||
GroupManager,
|
||||
PackageManager,
|
||||
SearchManager,
|
||||
SettingManager,
|
||||
UserManager,
|
||||
)
|
||||
from .models import (
|
||||
AdditionalInformation,
|
||||
Compound,
|
||||
CompoundStructure,
|
||||
Edge,
|
||||
EnviFormer,
|
||||
EPModel,
|
||||
Group,
|
||||
GroupPackagePermission,
|
||||
MLRelativeReasoning,
|
||||
Node,
|
||||
PackageBasedModel,
|
||||
ParallelRule,
|
||||
Pathway,
|
||||
Reaction,
|
||||
Rule,
|
||||
RuleBasedRelativeReasoning,
|
||||
Scenario,
|
||||
SimpleAmbitRule,
|
||||
User,
|
||||
UserPackagePermission,
|
||||
ParallelRule,
|
||||
)
|
||||
|
||||
Package = s.GET_PACKAGE_MODEL()
|
||||
|
||||
|
||||
def get_package_for_write(user, package_uuid):
|
||||
p = PackageManager.get_package_by_id(user, package_uuid)
|
||||
if not PackageManager.writable(user, p):
|
||||
raise ValueError("You do not have the rights to write to this Package!")
|
||||
return p
|
||||
|
||||
|
||||
def _anonymous_or_real(request):
|
||||
if request.user.is_authenticated and not request.user.is_anonymous:
|
||||
return request.user
|
||||
@ -81,6 +103,8 @@ class SimpleObject(Schema):
|
||||
return "reviewed" if obj.compound.package.reviewed else "unreviewed"
|
||||
elif isinstance(obj, Node) or isinstance(obj, Edge):
|
||||
return "reviewed" if obj.pathway.package.reviewed else "unreviewed"
|
||||
elif isinstance(obj, dict) and "review_status" in obj:
|
||||
return "reviewed" if obj.get("review_status") else "unreviewed"
|
||||
else:
|
||||
raise ValueError("Object has no package")
|
||||
|
||||
@ -200,6 +224,82 @@ def get_user(request, user_uuid):
|
||||
}
|
||||
|
||||
|
||||
########
|
||||
# Group #
|
||||
########
|
||||
|
||||
|
||||
class GroupMember(Schema):
|
||||
id: str
|
||||
identifier: str
|
||||
name: str
|
||||
|
||||
|
||||
class GroupWrapper(Schema):
|
||||
group: List[SimpleGroup]
|
||||
|
||||
|
||||
class GroupSchema(Schema):
|
||||
description: str
|
||||
id: str = Field(None, alias="url")
|
||||
identifier: str = "group"
|
||||
members: List[GroupMember] = Field([], alias="members")
|
||||
name: str = Field(None, alias="name")
|
||||
ownerid: str = Field(None, alias="owner.url")
|
||||
ownername: str = Field(None, alias="owner.get_name")
|
||||
packages: List["SimplePackage"] = Field([], alias="packages")
|
||||
readers: List[GroupMember] = Field([], alias="readers")
|
||||
writers: List[GroupMember] = Field([], alias="writers")
|
||||
|
||||
@staticmethod
|
||||
def resolve_members(obj: Group):
|
||||
res = []
|
||||
for member in obj.user_member.all():
|
||||
res.append(GroupMember(id=member.url, identifier="usermember", name=member.get_name()))
|
||||
|
||||
for member in obj.group_member.all():
|
||||
res.append(GroupMember(id=member.url, identifier="groupmember", name=member.get_name()))
|
||||
|
||||
return res
|
||||
|
||||
@staticmethod
|
||||
def resolve_packages(obj: Group):
|
||||
return Package.objects.filter(
|
||||
id__in=[
|
||||
GroupPackagePermission.objects.filter(group=obj).values_list(
|
||||
"package_id", flat=True
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def resolve_readers(obj: Group):
|
||||
return GroupSchema.resolve_members(obj)
|
||||
|
||||
@staticmethod
|
||||
def resolve_writers(obj: Group):
|
||||
return [GroupMember(id=obj.owner.url, identifier="usermember", name=obj.owner.username)]
|
||||
|
||||
|
||||
@router.get("/group", response={200: GroupWrapper, 403: Error})
|
||||
def get_groups(request):
|
||||
return {"group": GroupManager.get_groups(request.user)}
|
||||
|
||||
|
||||
@router.get("/group/{uuid:group_uuid}", response={200: GroupSchema, 403: Error})
|
||||
def get_group(request, group_uuid):
|
||||
try:
|
||||
g = GroupManager.get_group_by_id(request.user, group_uuid)
|
||||
return g
|
||||
except ValueError:
|
||||
return 403, {
|
||||
"message": f"Getting Group with id {group_uuid} failed due to insufficient rights!"
|
||||
}
|
||||
|
||||
|
||||
##########
|
||||
# Search #
|
||||
##########
|
||||
class Search(Schema):
|
||||
packages: List[str] = Field(alias="packages[]")
|
||||
search: str
|
||||
@ -237,11 +337,11 @@ def search(request, search: Query[Search]):
|
||||
if "Compound Structures" in search_res:
|
||||
res["structure"] = search_res["Compound Structures"]
|
||||
|
||||
if "Reaction" in search_res:
|
||||
res["reaction"] = search_res["Reaction"]
|
||||
if "Reactions" in search_res:
|
||||
res["reaction"] = search_res["Reactions"]
|
||||
|
||||
if "Pathway" in search_res:
|
||||
res["pathway"] = search_res["Pathway"]
|
||||
if "Pathways" in search_res:
|
||||
res["pathway"] = search_res["Pathways"]
|
||||
|
||||
if "Rules" in search_res:
|
||||
res["rule"] = search_res["Rules"]
|
||||
@ -292,7 +392,7 @@ class PackageSchema(Schema):
|
||||
).values_list("user", flat=True)
|
||||
).distinct()
|
||||
|
||||
return [{u.id: u.name} for u in users]
|
||||
return [{u.id: u.get_name()} for u in users]
|
||||
|
||||
@staticmethod
|
||||
def resolve_writers(obj: Package):
|
||||
@ -302,7 +402,7 @@ class PackageSchema(Schema):
|
||||
).values_list("user", flat=True)
|
||||
).distinct()
|
||||
|
||||
return [{u.id: u.name} for u in users]
|
||||
return [{u.id: u.get_name()} for u in users]
|
||||
|
||||
@staticmethod
|
||||
def resolve_review_comment(obj):
|
||||
@ -373,7 +473,7 @@ class UpdatePackage(Schema):
|
||||
@router.post("/package/{uuid:package_uuid}", response={200: PackageSchema | Any, 400: Error})
|
||||
def update_package(request, package_uuid, pack: Form[UpdatePackage]):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
p = get_package_for_write(request.user, package_uuid)
|
||||
|
||||
if pack.hiddenMethod:
|
||||
if pack.hiddenMethod == "DELETE":
|
||||
@ -469,21 +569,42 @@ class CompoundSchema(Schema):
|
||||
|
||||
@staticmethod
|
||||
def resolve_halflifes(obj: Compound):
|
||||
return []
|
||||
res = []
|
||||
for scen, hls in obj.half_lifes().items():
|
||||
for hl in hls:
|
||||
res.append(
|
||||
{
|
||||
"hl": str(hl.dt50),
|
||||
"hlComment": hl.comment,
|
||||
"hlFit": hl.fit,
|
||||
"hlModel": hl.model,
|
||||
"scenarioId": scen.url,
|
||||
"scenarioName": scen.name,
|
||||
"scenarioType": scen.scenario_type,
|
||||
"source": hl.source,
|
||||
}
|
||||
)
|
||||
return res
|
||||
|
||||
@staticmethod
|
||||
def resolve_pubchem_compound_references(obj: Compound):
|
||||
# TODO
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def resolve_pathway_scenarios(obj: Compound):
|
||||
return [
|
||||
{
|
||||
"scenarioId": "https://envipath.org/package/5882df9c-dae1-4d80-a40e-db4724271456/scenario/cd8350cd-4249-4111-ba9f-4e2209338501",
|
||||
"scenarioName": "Fritz, R. & Brauner, A. (1989) - (00004)",
|
||||
"scenarioType": "Soil",
|
||||
}
|
||||
]
|
||||
res = []
|
||||
for pw in obj.related_pathways:
|
||||
for scen in pw.scenarios.all():
|
||||
res.append(
|
||||
{
|
||||
"scenarioId": scen.url,
|
||||
"scenarioName": scen.name,
|
||||
"scenarioType": scen.scenario_type,
|
||||
}
|
||||
)
|
||||
|
||||
return res
|
||||
|
||||
|
||||
class CompoundStructureSchema(Schema):
|
||||
@ -536,7 +657,22 @@ class CompoundStructureSchema(Schema):
|
||||
|
||||
@staticmethod
|
||||
def resolve_halflifes(obj: CompoundStructure):
|
||||
return []
|
||||
res = []
|
||||
for scen, hls in obj.half_lifes().items():
|
||||
for hl in hls:
|
||||
res.append(
|
||||
{
|
||||
"hl": str(hl.dt50),
|
||||
"hlComment": hl.comment,
|
||||
"hlFit": hl.fit,
|
||||
"hlModel": hl.model,
|
||||
"scenarioId": scen.url,
|
||||
"scenarioName": scen.name,
|
||||
"scenarioType": scen.scenario_type,
|
||||
"source": hl.source,
|
||||
}
|
||||
)
|
||||
return res
|
||||
|
||||
@staticmethod
|
||||
def resolve_pubchem_compound_references(obj: CompoundStructure):
|
||||
@ -544,13 +680,18 @@ class CompoundStructureSchema(Schema):
|
||||
|
||||
@staticmethod
|
||||
def resolve_pathway_scenarios(obj: CompoundStructure):
|
||||
return [
|
||||
{
|
||||
"scenarioId": "https://envipath.org/package/5882df9c-dae1-4d80-a40e-db4724271456/scenario/cd8350cd-4249-4111-ba9f-4e2209338501",
|
||||
"scenarioName": "Fritz, R. & Brauner, A. (1989) - (00004)",
|
||||
"scenarioType": "Soil",
|
||||
}
|
||||
]
|
||||
res = []
|
||||
for pw in obj.related_pathways:
|
||||
for scen in pw.scenarios.all():
|
||||
res.append(
|
||||
{
|
||||
"scenarioId": scen.url,
|
||||
"scenarioName": scen.name,
|
||||
"scenarioType": scen.scenario_type,
|
||||
}
|
||||
)
|
||||
|
||||
return res
|
||||
|
||||
|
||||
class CompoundStructureWrapper(Schema):
|
||||
@ -635,7 +776,7 @@ def create_package_compound(
|
||||
c: Form[CreateCompound],
|
||||
):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
p = get_package_for_write(request.user, package_uuid)
|
||||
# inchi is not used atm
|
||||
c = Compound.create(
|
||||
p, c.compoundSmiles, c.compoundName, c.compoundDescription, inchi=c.inchi
|
||||
@ -648,14 +789,10 @@ def create_package_compound(
|
||||
@router.delete("/package/{uuid:package_uuid}/compound/{uuid:compound_uuid}")
|
||||
def delete_compound(request, package_uuid, compound_uuid):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
|
||||
if PackageManager.writable(request.user, p):
|
||||
c = Compound.objects.get(package=p, uuid=compound_uuid)
|
||||
c.delete()
|
||||
return redirect(f"{p.url}/compound")
|
||||
else:
|
||||
raise ValueError("You do not have the rights to delete this Compound!")
|
||||
p = get_package_for_write(request.user, package_uuid)
|
||||
c = Compound.objects.get(package=p, uuid=compound_uuid)
|
||||
c.delete()
|
||||
return redirect(f"{p.url}/compound")
|
||||
except ValueError:
|
||||
return 403, {
|
||||
"message": f"Deleting Compound with id {compound_uuid} failed due to insufficient rights!"
|
||||
@ -667,31 +804,29 @@ def delete_compound(request, package_uuid, compound_uuid):
|
||||
)
|
||||
def delete_compound_structure(request, package_uuid, compound_uuid, structure_uuid):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
p = get_package_for_write(request.user, package_uuid)
|
||||
|
||||
if PackageManager.writable(request.user, p):
|
||||
c = Compound.objects.get(package=p, uuid=compound_uuid)
|
||||
cs = CompoundStructure.objects.get(compound=c, uuid=structure_uuid)
|
||||
c = Compound.objects.get(package=p, uuid=compound_uuid)
|
||||
cs = CompoundStructure.objects.get(compound=c, uuid=structure_uuid)
|
||||
|
||||
# Check if we have to delete the compound as no structure is left
|
||||
if len(cs.compound.structures.all()) == 1:
|
||||
# This will delete the structure as well
|
||||
# Check if we have to delete the compound as no structure is left
|
||||
if len(cs.compound.structures.all()) == 1:
|
||||
# This will delete the structure as well
|
||||
c.delete()
|
||||
return redirect(p.url + "/compound")
|
||||
else:
|
||||
if cs.normalized_structure:
|
||||
c.delete()
|
||||
return redirect(p.url + "/compound")
|
||||
else:
|
||||
if cs.normalized_structure:
|
||||
c.delete()
|
||||
return redirect(p.url + "/compound")
|
||||
if c.default_structure == cs:
|
||||
cs.delete()
|
||||
c.default_structure = c.structures.all().first()
|
||||
return redirect(c.url + "/structure")
|
||||
else:
|
||||
if c.default_structure == cs:
|
||||
cs.delete()
|
||||
c.default_structure = c.structures.all().first()
|
||||
return redirect(c.url + "/structure")
|
||||
else:
|
||||
cs.delete()
|
||||
return redirect(c.url + "/structure")
|
||||
else:
|
||||
raise ValueError("You do not have the rights to delete this CompoundStructure!")
|
||||
cs.delete()
|
||||
return redirect(c.url + "/structure")
|
||||
|
||||
except ValueError:
|
||||
return 403, {
|
||||
"message": f"Deleting CompoundStructure with id {compound_uuid} failed due to insufficient rights!"
|
||||
@ -878,13 +1013,18 @@ def create_package_simple_rule(
|
||||
r: Form[CreateSimpleRule],
|
||||
):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
p = get_package_for_write(request.user, package_uuid)
|
||||
|
||||
if r.rdkitrule and r.rdkitrule.strip() == "true":
|
||||
raise ValueError("Not yet implemented!")
|
||||
else:
|
||||
sr = SimpleAmbitRule.create(
|
||||
p, r.name, r.description, r.smirks, r.reactantFilterSmarts, r.productFilterSmarts
|
||||
p,
|
||||
r.name,
|
||||
r.description,
|
||||
r.smirks,
|
||||
r.reactantFilterSmarts,
|
||||
r.productFilterSmarts,
|
||||
)
|
||||
|
||||
return redirect(sr.url)
|
||||
@ -909,7 +1049,7 @@ def create_package_parallel_rule(
|
||||
r: Form[CreateParallelRule],
|
||||
):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
p = get_package_for_write(request.user, package_uuid)
|
||||
|
||||
srs = SimpleRule.objects.filter(package=p, url__in=r.simpleRules)
|
||||
|
||||
@ -953,7 +1093,7 @@ def post_package_parallel_rule(request, package_uuid, rule_uuid, compound: Form[
|
||||
|
||||
def _post_package_rule(request, package_uuid, rule_uuid, compound: Form[str]):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
p = get_package_for_write(request.user, package_uuid)
|
||||
r = Rule.objects.get(package=p, uuid=rule_uuid)
|
||||
|
||||
if compound is not None:
|
||||
@ -998,14 +1138,11 @@ def delete_parallel_rule(request, package_uuid, rule_uuid):
|
||||
|
||||
def _delete_rule(request, package_uuid, rule_uuid):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
p = get_package_for_write(request.user, package_uuid)
|
||||
r = Rule.objects.get(package=p, uuid=rule_uuid)
|
||||
r.delete()
|
||||
return redirect(f"{p.url}/rule")
|
||||
|
||||
if PackageManager.writable(request.user, p):
|
||||
r = Rule.objects.get(package=p, uuid=rule_uuid)
|
||||
r.delete()
|
||||
return redirect(f"{p.url}/rule")
|
||||
else:
|
||||
raise ValueError("You do not have the rights to delete this Rule!")
|
||||
except ValueError:
|
||||
return 403, {
|
||||
"message": f"Deleting Rule with id {rule_uuid} failed due to insufficient rights!"
|
||||
@ -1037,7 +1174,7 @@ class ReactionSchema(Schema):
|
||||
name: str = Field(None, alias="name")
|
||||
pathways: List["SimplePathway"] = Field([], alias="related_pathways")
|
||||
products: List["ReactionCompoundStructure"] = Field([], alias="products")
|
||||
references: List[Dict[str, List[str]]] = Field([], alias="references")
|
||||
references: Dict[str, List[str]] = Field({}, alias="references")
|
||||
reviewStatus: str = Field(None, alias="review_status")
|
||||
scenarios: List["SimpleScenario"] = Field([], alias="scenarios")
|
||||
smirks: str = Field("", alias="smirks")
|
||||
@ -1053,8 +1190,12 @@ class ReactionSchema(Schema):
|
||||
|
||||
@staticmethod
|
||||
def resolve_references(obj: Reaction):
|
||||
# TODO
|
||||
return []
|
||||
rhea_refs = []
|
||||
for rhea in obj.get_rhea_identifiers():
|
||||
rhea_refs.append(f"{rhea.identifier_value}")
|
||||
|
||||
# TODO UniProt
|
||||
return {"rheaReferences": rhea_refs, "uniprotCount": []}
|
||||
|
||||
@staticmethod
|
||||
def resolve_medline_references(obj: Reaction):
|
||||
@ -1116,7 +1257,7 @@ def create_package_reaction(
|
||||
r: Form[CreateReaction],
|
||||
):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
p = get_package_for_write(request.user, package_uuid)
|
||||
|
||||
if r.smirks is None and (r.educt is None or r.product is None):
|
||||
raise ValueError("Either SMIRKS or educt/product must be provided")
|
||||
@ -1162,14 +1303,11 @@ def create_package_reaction(
|
||||
@router.delete("/package/{uuid:package_uuid}/reaction/{uuid:reaction_uuid}")
|
||||
def delete_reaction(request, package_uuid, reaction_uuid):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
p = get_package_for_write(request.user, package_uuid)
|
||||
|
||||
if PackageManager.writable(request.user, p):
|
||||
r = Reaction.objects.get(package=p, uuid=reaction_uuid)
|
||||
r.delete()
|
||||
return redirect(f"{p.url}/reaction")
|
||||
else:
|
||||
raise ValueError("You do not have the rights to delete this Reaction!")
|
||||
r = Reaction.objects.get(package=p, uuid=reaction_uuid)
|
||||
r.delete()
|
||||
return redirect(f"{p.url}/reaction")
|
||||
except ValueError:
|
||||
return 403, {
|
||||
"message": f"Deleting Reaction with id {reaction_uuid} failed due to insufficient rights!"
|
||||
@ -1200,7 +1338,14 @@ class ScenarioSchema(Schema):
|
||||
|
||||
@staticmethod
|
||||
def resolve_collection(obj: Scenario):
|
||||
return obj.additional_information
|
||||
res = defaultdict(list)
|
||||
|
||||
for ai in obj.get_additional_information(direct_only=False):
|
||||
data = ai.data
|
||||
data["related"] = ai.content_object.simple_json() if ai.content_object else None
|
||||
res[ai.type].append(data)
|
||||
|
||||
return res
|
||||
|
||||
@staticmethod
|
||||
def resolve_review_status(obj: Rule):
|
||||
@ -1241,17 +1386,57 @@ def get_package_scenario(request, package_uuid, scenario_uuid):
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/package/{uuid:package_uuid}/scenario")
|
||||
def delete_scenarios(request, package_uuid, scenario_uuid):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
@router.post("/package/{uuid:package_uuid}/scenario", response={200: str | Any, 403: Error})
|
||||
def create_package_scenario(request, package_uuid):
|
||||
from utilities.legacy import build_additional_information_from_request
|
||||
|
||||
try:
|
||||
p = get_package_for_write(request.user, package_uuid)
|
||||
|
||||
scen_date = None
|
||||
date_year = request.POST.get("dateYear")
|
||||
date_month = request.POST.get("dateMonth")
|
||||
date_day = request.POST.get("dateDay")
|
||||
|
||||
if date_year:
|
||||
scen_date = date_year
|
||||
if date_month:
|
||||
scen_date += f"-{date_month}"
|
||||
if date_day:
|
||||
scen_date += f"-{date_day}"
|
||||
|
||||
name = request.POST.get("studyname")
|
||||
description = request.POST.get("studydescription")
|
||||
study_type = request.POST.get("type")
|
||||
|
||||
ais = []
|
||||
types = request.POST.get("adInfoTypes[]", [])
|
||||
|
||||
if types:
|
||||
types = types.split(",")
|
||||
|
||||
for t in types:
|
||||
ais.append(build_additional_information_from_request(request, t))
|
||||
|
||||
new_s = Scenario.create(p, name, description, scen_date, study_type, ais)
|
||||
|
||||
return JsonResponse({"scenarioLocation": new_s.url})
|
||||
|
||||
except ValueError:
|
||||
return 403, {
|
||||
"message": f"Getting Package with id {package_uuid} failed due to insufficient rights!"
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/package/{uuid:package_uuid}/scenario")
|
||||
def delete_scenarios(request, package_uuid):
|
||||
try:
|
||||
p = get_package_for_write(request.user, package_uuid)
|
||||
|
||||
scens = Scenario.objects.filter(package=p)
|
||||
scens.delete()
|
||||
return redirect(f"{p.url}/scenario")
|
||||
|
||||
if PackageManager.writable(request.user, p):
|
||||
scens = Scenario.objects.filter(package=p)
|
||||
scens.delete()
|
||||
return redirect(f"{p.url}/scenario")
|
||||
else:
|
||||
raise ValueError("You do not have the rights to delete Scenarios!")
|
||||
except ValueError:
|
||||
return 403, {"message": "Deleting Scenarios failed due to insufficient rights!"}
|
||||
|
||||
@ -1259,20 +1444,61 @@ def delete_scenarios(request, package_uuid, scenario_uuid):
|
||||
@router.delete("/package/{uuid:package_uuid}/scenario/{uuid:scenario_uuid}")
|
||||
def delete_scenario(request, package_uuid, scenario_uuid):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
p = get_package_for_write(request.user, package_uuid)
|
||||
|
||||
scen = Scenario.objects.get(package=p, uuid=scenario_uuid)
|
||||
scen.delete()
|
||||
return redirect(f"{p.url}/scenario")
|
||||
|
||||
if PackageManager.writable(request.user, p):
|
||||
scen = Scenario.objects.get(package=p, uuid=scenario_uuid)
|
||||
scen.delete()
|
||||
return redirect(f"{p.url}/scenario")
|
||||
else:
|
||||
raise ValueError("You do not have the rights to delete this Scenario!")
|
||||
except ValueError:
|
||||
return 403, {
|
||||
"message": f"Deleting Scenario with id {scenario_uuid} failed due to insufficient rights!"
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/package/{uuid:package_uuid}/additional-information", response={200: str | Any, 403: Error}
|
||||
)
|
||||
def create_package_additional_information(request, package_uuid):
|
||||
from utilities.legacy import build_additional_information_from_request
|
||||
|
||||
try:
|
||||
p = get_package_for_write(request.user, package_uuid)
|
||||
|
||||
scen = request.POST.get("scenario")
|
||||
scenario = Scenario.objects.get(package=p, url=scen)
|
||||
|
||||
url_parser = EPDBURLParser(request.POST.get("attach_obj"))
|
||||
attach_obj = url_parser.get_object()
|
||||
|
||||
if not hasattr(attach_obj, "additional_information"):
|
||||
raise ValueError("Can't attach additional information to this object!")
|
||||
|
||||
if not attach_obj.url.startswith(p.url):
|
||||
raise ValueError(
|
||||
"Additional Information can only be set to objects stored in the same package!"
|
||||
)
|
||||
|
||||
types = request.POST.get("adInfoTypes[]", "").split(",")
|
||||
|
||||
for t in types:
|
||||
ai = build_additional_information_from_request(request, t)
|
||||
|
||||
AdditionalInformation.create(
|
||||
p,
|
||||
ai,
|
||||
scenario=scenario,
|
||||
content_object=attach_obj,
|
||||
)
|
||||
|
||||
# TODO implement additional information endpoint ?
|
||||
return redirect(f"{scenario.url}")
|
||||
except ValueError:
|
||||
return 403, {
|
||||
"message": f"Getting Package with id {package_uuid} failed due to insufficient rights!"
|
||||
}
|
||||
|
||||
|
||||
###########
|
||||
# Pathway #
|
||||
###########
|
||||
@ -1289,8 +1515,8 @@ class PathwayEdge(Schema):
|
||||
pseudo: bool = False
|
||||
rule: Optional[str] = Field(None, alias="rule")
|
||||
scenarios: List[SimpleScenario] = Field([], alias="scenarios")
|
||||
source: int = -1
|
||||
target: int = -1
|
||||
source: int = Field(-1)
|
||||
target: int = Field(-1)
|
||||
|
||||
@staticmethod
|
||||
def resolve_rule(obj: Edge):
|
||||
@ -1303,7 +1529,7 @@ class PathwayEdge(Schema):
|
||||
|
||||
class PathwayNode(Schema):
|
||||
atomCount: int = Field(None, alias="atom_count")
|
||||
depth: int = Field(None, alias="depth")
|
||||
depth: float = Field(None, alias="depth")
|
||||
dt50s: List[Dict[str, str]] = Field([], alias="dt50s")
|
||||
engineeredIntermediate: bool = Field(None, alias="engineered_intermediate")
|
||||
id: str = Field(None, alias="url")
|
||||
@ -1353,9 +1579,9 @@ class PathwaySchema(Schema):
|
||||
isIncremental: bool = Field(None, alias="is_incremental")
|
||||
isPredicted: bool = Field(None, alias="is_predicted")
|
||||
lastModified: int = Field(None, alias="last_modified")
|
||||
links: List[PathwayEdge] = Field([], alias="edges")
|
||||
links: List[PathwayEdge] = Field([])
|
||||
name: str = Field(None, alias="name")
|
||||
nodes: List[PathwayNode] = Field([], alias="nodes")
|
||||
nodes: List[PathwayNode] = Field([])
|
||||
pathwayName: str = Field(None, alias="name")
|
||||
reviewStatus: str = Field(None, alias="review_status")
|
||||
scenarios: List["SimpleScenario"] = Field([], alias="scenarios")
|
||||
@ -1377,6 +1603,14 @@ class PathwaySchema(Schema):
|
||||
def resolve_last_modified(obj: Pathway):
|
||||
return int(obj.modified.timestamp())
|
||||
|
||||
@staticmethod
|
||||
def resolve_links(obj: Pathway):
|
||||
return obj.d3_json().get("links", [])
|
||||
|
||||
@staticmethod
|
||||
def resolve_nodes(obj: Pathway):
|
||||
return obj.d3_json().get("nodes", [])
|
||||
|
||||
|
||||
@router.get("/pathway", response={200: PathwayWrapper, 403: Error})
|
||||
def get_pathways(request):
|
||||
@ -1420,16 +1654,16 @@ class CreatePathway(Schema):
|
||||
selectedSetting: str | None = None
|
||||
|
||||
|
||||
@router.post("/package/{uuid:package_uuid}/pathway")
|
||||
def create_pathway(
|
||||
@router.post("/package/{uuid:package_uuid}/pathway", response={200: Any, 403: Error})
|
||||
def create_package_pathway(
|
||||
request,
|
||||
package_uuid,
|
||||
pw: Form[CreatePathway],
|
||||
):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
p = get_package_for_write(request.user, package_uuid)
|
||||
|
||||
stand_smiles = FormatConverter.standardize(pw.smilesinput.strip())
|
||||
stand_smiles = FormatConverter.standardize(pw.smilesinput.strip(), remove_stereo=True)
|
||||
|
||||
new_pw = Pathway.create(p, stand_smiles, name=pw.name, description=pw.description)
|
||||
|
||||
@ -1447,6 +1681,7 @@ def create_pathway(
|
||||
setting = SettingManager.get_setting_by_url(request.user, pw.selectedSetting)
|
||||
|
||||
new_pw.setting = setting
|
||||
new_pw.kv.update({"status": "running"})
|
||||
new_pw.save()
|
||||
|
||||
from .tasks import dispatch, predict
|
||||
@ -1455,20 +1690,18 @@ def create_pathway(
|
||||
|
||||
return redirect(new_pw.url)
|
||||
except ValueError as e:
|
||||
return 400, {"message": str(e)}
|
||||
return 403, {"message": str(e)}
|
||||
|
||||
|
||||
@router.delete("/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}")
|
||||
def delete_pathway(request, package_uuid, pathway_uuid):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
p = get_package_for_write(request.user, package_uuid)
|
||||
|
||||
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
|
||||
pw.delete()
|
||||
return redirect(f"{p.url}/pathway")
|
||||
|
||||
if PackageManager.writable(request.user, p):
|
||||
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
|
||||
pw.delete()
|
||||
return redirect(f"{p.url}/pathway")
|
||||
else:
|
||||
raise ValueError("You do not have the rights to delete this pathway!")
|
||||
except ValueError:
|
||||
return 403, {
|
||||
"message": f"Deleting Pathway with id {pathway_uuid} failed due to insufficient rights!"
|
||||
@ -1576,7 +1809,7 @@ class CreateNode(Schema):
|
||||
)
|
||||
def add_pathway_node(request, package_uuid, pathway_uuid, n: Form[CreateNode]):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
p = get_package_for_write(request.user, package_uuid)
|
||||
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
|
||||
|
||||
if n.nodeDepth is not None and n.nodeDepth.strip() != "":
|
||||
@ -1594,15 +1827,13 @@ def add_pathway_node(request, package_uuid, pathway_uuid, n: Form[CreateNode]):
|
||||
@router.delete("/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}/node/{uuid:node_uuid}")
|
||||
def delete_node(request, package_uuid, pathway_uuid, node_uuid):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
p = get_package_for_write(request.user, package_uuid)
|
||||
|
||||
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
|
||||
n = Node.objects.get(pathway=pw, uuid=node_uuid)
|
||||
n.delete()
|
||||
return redirect(f"{pw.url}/node")
|
||||
|
||||
if PackageManager.writable(request.user, p):
|
||||
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
|
||||
n = Node.objects.get(pathway=pw, uuid=node_uuid)
|
||||
n.delete()
|
||||
return redirect(f"{pw.url}/node")
|
||||
else:
|
||||
raise ValueError("You do not have the rights to delete this Node!")
|
||||
except ValueError:
|
||||
return 403, {
|
||||
"message": f"Deleting Node with id {node_uuid} failed due to insufficient rights!"
|
||||
@ -1632,14 +1863,14 @@ class EdgeSchema(Schema):
|
||||
id: str = Field(None, alias="url")
|
||||
identifier: str = "edge"
|
||||
name: str = Field(None, alias="name")
|
||||
reactionName: str = Field(None, alias="edge_label.name")
|
||||
reactionName: str = Field(None, alias="edge_label.get_name")
|
||||
reactionURI: str = Field(None, alias="edge_label.url")
|
||||
reviewStatus: str = Field(None, alias="review_status")
|
||||
scenarios: List["SimpleScenario"] = Field([], alias="scenarios")
|
||||
startNodes: List["EdgeNode"] = Field([], alias="start_nodes")
|
||||
|
||||
@staticmethod
|
||||
def resolve_review_status(obj: Node):
|
||||
def resolve_review_status(obj: Edge):
|
||||
return "reviewed" if obj.pathway.package.reviewed else "unreviewed"
|
||||
|
||||
|
||||
@ -1681,12 +1912,12 @@ class CreateEdge(Schema):
|
||||
|
||||
|
||||
@router.post(
|
||||
"/package/{uuid:package_uuid}/üathway/{uuid:pathway_uuid}/edge",
|
||||
"/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}/edge",
|
||||
response={200: str | Any, 403: Error},
|
||||
)
|
||||
def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
p = get_package_for_write(request.user, package_uuid)
|
||||
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
|
||||
|
||||
if e.edgeAsSmirks is None and (e.educts is None or e.products is None):
|
||||
@ -1700,10 +1931,26 @@ def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
|
||||
|
||||
if e.edgeAsSmirks:
|
||||
for ed in e.edgeAsSmirks.split(">>")[0].split("\\."):
|
||||
educts.append(Node.objects.get(pathway=pw, default_node_label__smiles=ed))
|
||||
stand_ed = FormatConverter.standardize(ed, remove_stereo=True)
|
||||
educts.append(
|
||||
Node.objects.get(
|
||||
pathway=pw,
|
||||
default_node_label=CompoundStructure.objects.get(
|
||||
compound__package=p, smiles=stand_ed
|
||||
).compound.default_structure,
|
||||
)
|
||||
)
|
||||
|
||||
for pr in e.edgeAsSmirks.split(">>")[1].split("\\."):
|
||||
products.append(Node.objects.get(pathway=pw, default_node_label__smiles=pr))
|
||||
stand_pr = FormatConverter.standardize(pr, remove_stereo=True)
|
||||
products.append(
|
||||
Node.objects.get(
|
||||
pathway=pw,
|
||||
default_node_label=CompoundStructure.objects.get(
|
||||
compound__package=p, smiles=stand_pr
|
||||
).compound.default_structure,
|
||||
)
|
||||
)
|
||||
else:
|
||||
for ed in e.educts.split(","):
|
||||
educts.append(Node.objects.get(pathway=pw, url=ed.strip()))
|
||||
@ -1716,27 +1963,28 @@ def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
|
||||
start_nodes=educts,
|
||||
end_nodes=products,
|
||||
rule=None,
|
||||
name=e.name,
|
||||
name=None,
|
||||
description=e.edgeReason,
|
||||
)
|
||||
|
||||
# Update depths as sideeffect of above operation
|
||||
pw.update_depths()
|
||||
|
||||
return redirect(new_e.url)
|
||||
except ValueError:
|
||||
return 403, {"message": "Adding node failed!"}
|
||||
return 403, {"message": "Adding Edge failed!"}
|
||||
|
||||
|
||||
@router.delete("/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}/edge/{uuid:edge_uuid}")
|
||||
def delete_edge(request, package_uuid, pathway_uuid, edge_uuid):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
p = get_package_for_write(request.user, package_uuid)
|
||||
|
||||
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
|
||||
e = Edge.objects.get(pathway=pw, uuid=edge_uuid)
|
||||
e.delete()
|
||||
return redirect(f"{pw.url}/edge")
|
||||
|
||||
if PackageManager.writable(request.user, p):
|
||||
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
|
||||
e = Edge.objects.get(pathway=pw, uuid=edge_uuid)
|
||||
e.delete()
|
||||
return redirect(f"{pw.url}/edge")
|
||||
else:
|
||||
raise ValueError("You do not have the rights to delete this Edge!")
|
||||
except ValueError:
|
||||
return 403, {
|
||||
"message": f"Deleting Edge with id {edge_uuid} failed due to insufficient rights!"
|
||||
@ -1753,26 +2001,46 @@ class ModelWrapper(Schema):
|
||||
class ModelSchema(Schema):
|
||||
aliases: List[str] = Field([], alias="aliases")
|
||||
description: str = Field(None, alias="description")
|
||||
evalPackages: List["SimplePackage"] = Field([])
|
||||
evalPackages: List["SimplePackage"] = Field([], alias="eval_packages")
|
||||
id: str = Field(None, alias="url")
|
||||
identifier: str = "relative-reasoning"
|
||||
# "info" : {
|
||||
# "Accuracy (Single-Gen)" : "0.5932962678936605" ,
|
||||
# "Area under PR-Curve (Single-Gen)" : "0.5654653182134282" ,
|
||||
# "Area under ROC-Curve (Single-Gen)" : "0.8178302405034772" ,
|
||||
# "Precision (Single-Gen)" : "0.6978730822873083" ,
|
||||
# "Probability Threshold" : "0.5" ,
|
||||
# "Recall/Sensitivity (Single-Gen)" : "0.4484149210261006"
|
||||
# } ,
|
||||
info: dict = Field({}, alias="info")
|
||||
name: str = Field(None, alias="name")
|
||||
pathwayPackages: List["SimplePackage"] = Field([])
|
||||
pathwayPackages: List["SimplePackage"] = Field([], alias="pathway_packages")
|
||||
reviewStatus: str = Field(None, alias="review_status")
|
||||
rulePackages: List["SimplePackage"] = Field([])
|
||||
rulePackages: List["SimplePackage"] = Field([], alias="rule_packages")
|
||||
scenarios: List["SimpleScenario"] = Field([], alias="scenarios")
|
||||
status: str
|
||||
statusMessage: str
|
||||
threshold: str
|
||||
type: str
|
||||
status: str = Field(None, alias="model_status")
|
||||
statusMessage: str = Field(None, alias="status_message")
|
||||
threshold: str = Field(None, alias="threshold")
|
||||
type: str = Field(None, alias="model_type")
|
||||
|
||||
@staticmethod
|
||||
def resolve_info(obj: EPModel):
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def resolve_status_message(obj: EPModel):
|
||||
for k, v in PackageBasedModel.PROGRESS_STATUS_CHOICES.items():
|
||||
if k == obj.model_status:
|
||||
return v
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def resolve_threshold(obj: EPModel):
|
||||
return f"{obj.threshold:.2f}"
|
||||
|
||||
@staticmethod
|
||||
def resolve_model_type(obj: EPModel):
|
||||
if isinstance(obj, RuleBasedRelativeReasoning):
|
||||
return "RULEBASED"
|
||||
elif isinstance(obj, MLRelativeReasoning):
|
||||
return "ECC"
|
||||
elif isinstance(obj, EnviFormer):
|
||||
return "ENVIFORMER"
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/model", response={200: ModelWrapper, 403: Error})
|
||||
@ -1809,7 +2077,7 @@ def get_model(request, package_uuid, model_uuid, c: Query[Classify]):
|
||||
return 400, {"message": "Received empty SMILES"}
|
||||
|
||||
try:
|
||||
stand_smiles = FormatConverter.standardize(c.smiles)
|
||||
stand_smiles = FormatConverter.standardize(c.smiles, remove_stereo=True)
|
||||
except ValueError:
|
||||
return 400, {"message": f'"{c.smiles}" is not a valid SMILES'}
|
||||
|
||||
@ -1833,7 +2101,7 @@ def get_model(request, package_uuid, model_uuid, c: Query[Classify]):
|
||||
if pr.rule:
|
||||
res["id"] = pr.rule.url
|
||||
res["identifier"] = pr.rule.get_rule_identifier()
|
||||
res["name"] = pr.rule.name
|
||||
res["name"] = pr.rule.get_name()
|
||||
res["reviewStatus"] = (
|
||||
"reviewed" if pr.rule.package.reviewed else "unreviewed"
|
||||
)
|
||||
@ -1852,14 +2120,11 @@ def get_model(request, package_uuid, model_uuid, c: Query[Classify]):
|
||||
@router.delete("/package/{uuid:package_uuid}/model/{uuid:model_uuid}")
|
||||
def delete_model(request, package_uuid, model_uuid):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
p = get_package_for_write(request.user, package_uuid)
|
||||
|
||||
if PackageManager.writable(request.user, p):
|
||||
m = EPModel.objects.get(package=p, uuid=model_uuid)
|
||||
m.delete()
|
||||
return redirect(f"{p.url}/model")
|
||||
else:
|
||||
raise ValueError("You do not have the rights to delete this Model!")
|
||||
m = EPModel.objects.get(package=p, uuid=model_uuid)
|
||||
m.delete()
|
||||
return redirect(f"{p.url}/model")
|
||||
except ValueError:
|
||||
return 403, {
|
||||
"message": f"Deleting Model with id {model_uuid} failed due to insufficient rights!"
|
||||
|
||||
217
epdb/logic.py
217
epdb/logic.py
@ -1,4 +1,3 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Set, Union, Tuple
|
||||
@ -11,6 +10,7 @@ from django.db import transaction
|
||||
from pydantic import ValidationError
|
||||
|
||||
from epdb.models import (
|
||||
AdditionalInformation,
|
||||
Compound,
|
||||
CompoundStructure,
|
||||
Edge,
|
||||
@ -22,6 +22,7 @@ from epdb.models import (
|
||||
Node,
|
||||
Pathway,
|
||||
Permission,
|
||||
PropertyPluginModel,
|
||||
Reaction,
|
||||
Rule,
|
||||
Setting,
|
||||
@ -194,8 +195,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 +213,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 +264,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 +345,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):
|
||||
@ -475,7 +439,9 @@ 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()
|
||||
|
||||
return qs
|
||||
|
||||
@staticmethod
|
||||
def get_all_writeable_packages(user):
|
||||
@ -519,7 +485,9 @@ class PackageManager(object):
|
||||
|
||||
qs = qs.filter(reviewed=False)
|
||||
|
||||
return qs.distinct()
|
||||
qs = qs.distinct()
|
||||
|
||||
return qs
|
||||
|
||||
@staticmethod
|
||||
def get_packages():
|
||||
@ -639,15 +607,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 +643,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 +656,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
|
||||
@ -719,6 +689,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 +904,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 +976,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 +995,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 +1077,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 +1102,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.")
|
||||
17
epdb/migrations/0015_user_is_reviewer.py
Normal file
17
epdb/migrations/0015_user_is_reviewer.py
Normal file
@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-19 19:26
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("epdb", "0014_rename_expansion_schema_setting_expansion_scheme"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="is_reviewer",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
179
epdb/migrations/0016_remove_enviformer_model_status_and_more.py
Normal file
179
epdb/migrations/0016_remove_enviformer_model_status_and_more.py
Normal file
@ -0,0 +1,179 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-12 09:38
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("epdb", "0015_user_is_reviewer"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="enviformer",
|
||||
name="model_status",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="mlrelativereasoning",
|
||||
name="model_status",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="rulebasedrelativereasoning",
|
||||
name="model_status",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="epmodel",
|
||||
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.AlterField(
|
||||
model_name="enviformer",
|
||||
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.AlterField(
|
||||
model_name="enviformer",
|
||||
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.AlterField(
|
||||
model_name="mlrelativereasoning",
|
||||
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.AlterField(
|
||||
model_name="mlrelativereasoning",
|
||||
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.AlterField(
|
||||
model_name="rulebasedrelativereasoning",
|
||||
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.AlterField(
|
||||
model_name="rulebasedrelativereasoning",
|
||||
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="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)),
|
||||
(
|
||||
"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(
|
||||
blank=True,
|
||||
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="setting",
|
||||
name="property_models",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="settings",
|
||||
to="epdb.propertypluginmodel",
|
||||
verbose_name="Setting Property Models",
|
||||
),
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="PluginModel",
|
||||
),
|
||||
]
|
||||
93
epdb/migrations/0017_additionalinformation.py
Normal file
93
epdb/migrations/0017_additionalinformation.py
Normal file
@ -0,0 +1,93 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-20 12:02
|
||||
|
||||
import django.db.models.deletion
|
||||
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", "0016_remove_enviformer_model_status_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
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",
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
132
epdb/migrations/0018_auto_20260220_1203.py
Normal file
132
epdb/migrations/0018_auto_20260220_1203.py
Normal file
@ -0,0 +1,132 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-20 12:03
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def get_additional_information(scenario):
|
||||
from envipy_additional_information import registry
|
||||
from envipy_additional_information.parsers import TypeOfAerationParser
|
||||
|
||||
for k, vals in scenario.additional_information.items():
|
||||
if k == "enzyme":
|
||||
continue
|
||||
|
||||
if k == "SpikeConentration":
|
||||
k = "SpikeConcentration"
|
||||
|
||||
if k == "AerationType":
|
||||
k = "TypeOfAeration"
|
||||
|
||||
for v in vals:
|
||||
# Per default additional fields are ignored
|
||||
MAPPING = {c.__name__: c for c in registry.list_models().values()}
|
||||
try:
|
||||
inst = MAPPING[k](**v)
|
||||
except Exception:
|
||||
if k == "TypeOfAeration":
|
||||
toa = TypeOfAerationParser()
|
||||
inst = toa.from_string(v["type"])
|
||||
|
||||
# Add uuid to uniquely identify objects for manipulation
|
||||
if "uuid" in v:
|
||||
inst.__dict__["uuid"] = v["uuid"]
|
||||
|
||||
yield inst
|
||||
|
||||
|
||||
def forward_func(apps, schema_editor):
|
||||
Scenario = apps.get_model("epdb", "Scenario")
|
||||
ContentType = apps.get_model("contenttypes", "ContentType")
|
||||
AdditionalInformation = apps.get_model("epdb", "AdditionalInformation")
|
||||
|
||||
bulk = []
|
||||
related = []
|
||||
ctype = {o.model: o for o in ContentType.objects.all()}
|
||||
parents = Scenario.objects.prefetch_related(
|
||||
"compound_set",
|
||||
"compoundstructure_set",
|
||||
"reaction_set",
|
||||
"rule_set",
|
||||
"pathway_set",
|
||||
"node_set",
|
||||
"edge_set",
|
||||
).filter(parent__isnull=True)
|
||||
|
||||
for i, scenario in enumerate(parents):
|
||||
print(f"{i + 1}/{len(parents)}", end="\r")
|
||||
if scenario.parent is not None:
|
||||
related.append(scenario.parent)
|
||||
continue
|
||||
|
||||
for ai in get_additional_information(scenario):
|
||||
bulk.append(
|
||||
AdditionalInformation(
|
||||
package=scenario.package,
|
||||
scenario=scenario,
|
||||
type=ai.__class__.__name__,
|
||||
data=ai.model_dump(mode="json"),
|
||||
)
|
||||
)
|
||||
|
||||
print("\n", len(bulk))
|
||||
|
||||
related = Scenario.objects.prefetch_related(
|
||||
"compound_set",
|
||||
"compoundstructure_set",
|
||||
"reaction_set",
|
||||
"rule_set",
|
||||
"pathway_set",
|
||||
"node_set",
|
||||
"edge_set",
|
||||
).filter(parent__isnull=False)
|
||||
|
||||
for i, scenario in enumerate(related):
|
||||
print(f"{i + 1}/{len(related)}", end="\r")
|
||||
parent = scenario.parent
|
||||
# Check to which objects this scenario is attached to
|
||||
for ai in get_additional_information(scenario):
|
||||
rel_objs = [
|
||||
"compound",
|
||||
"compoundstructure",
|
||||
"reaction",
|
||||
"rule",
|
||||
"pathway",
|
||||
"node",
|
||||
"edge",
|
||||
]
|
||||
for rel_obj in rel_objs:
|
||||
for o in getattr(scenario, f"{rel_obj}_set").all():
|
||||
bulk.append(
|
||||
AdditionalInformation(
|
||||
package=scenario.package,
|
||||
scenario=parent,
|
||||
type=ai.__class__.__name__,
|
||||
data=ai.model_dump(mode="json"),
|
||||
content_type=ctype[rel_obj],
|
||||
object_id=o.pk,
|
||||
)
|
||||
)
|
||||
|
||||
print("Start creating additional information objects...")
|
||||
AdditionalInformation.objects.bulk_create(bulk)
|
||||
print("Done!")
|
||||
print(len(bulk))
|
||||
|
||||
Scenario.objects.filter(parent__isnull=False).delete()
|
||||
# Call ai save to fix urls
|
||||
ais = AdditionalInformation.objects.all()
|
||||
total = ais.count()
|
||||
|
||||
for i, ai in enumerate(ais):
|
||||
print(f"{i + 1}/{total}", end="\r")
|
||||
ai.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("epdb", "0017_additionalinformation"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forward_func, reverse_code=migrations.RunPython.noop),
|
||||
]
|
||||
@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-23 08:45
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("epdb", "0018_auto_20260220_1203"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="scenario",
|
||||
name="additional_information",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="scenario",
|
||||
name="parent",
|
||||
),
|
||||
]
|
||||
@ -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",),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,53 @@
|
||||
# Generated by Django 5.2.7 on 2026-03-25 11:56
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("epdb", "0021_classifierpluginmodel"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="classifierpluginmodel",
|
||||
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.AlterField(
|
||||
model_name="enviformer",
|
||||
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.AlterField(
|
||||
model_name="mlrelativereasoning",
|
||||
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.AlterField(
|
||||
model_name="rulebasedrelativereasoning",
|
||||
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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,49 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-21 11:43
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("epdb", "0022_alter_classifierpluginmodel_data_packages_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="compoundstructure",
|
||||
options={},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="epmodel",
|
||||
options={},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="parallelrule",
|
||||
options={},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="rule",
|
||||
options={},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="sequentialrule",
|
||||
options={},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="simpleambitrule",
|
||||
options={},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="simplerdkitrule",
|
||||
options={},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="simplerule",
|
||||
options={},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="compoundstructure",
|
||||
name="molfile",
|
||||
field=models.TextField(blank=True, null=True, verbose_name="Molfile"),
|
||||
),
|
||||
]
|
||||
17
epdb/migrations/0024_user_contacted.py
Normal file
17
epdb/migrations/0024_user_contacted.py
Normal file
@ -0,0 +1,17 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-21 19:56
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("epdb", "0023_alter_compoundstructure_options_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="contacted",
|
||||
field=models.BooleanField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
56
epdb/migrations/0025_auto_20260511_2025.py
Normal file
56
epdb/migrations/0025_auto_20260511_2025.py
Normal file
@ -0,0 +1,56 @@
|
||||
# Generated by Django 6.0.3 on 2026-05-11 20:25
|
||||
|
||||
from django.db import migrations
|
||||
from envipy_additional_information import HalfLife, HalfLifeModel, HalfLifeWS
|
||||
|
||||
MAPPING = {
|
||||
"": HalfLifeModel.OTHER,
|
||||
"HS-SFO": HalfLifeModel.HS_SFO,
|
||||
"FOMC": HalfLifeModel.FOMC,
|
||||
"FOTC": HalfLifeModel.DFOP,
|
||||
"FMOC": HalfLifeModel.FOMC,
|
||||
"DFOP": HalfLifeModel.DFOP,
|
||||
"SFO + SFO": HalfLifeModel.SFO_SFO,
|
||||
"FOMC-SFO": HalfLifeModel.FOMC_SFO,
|
||||
"first order kinetics": HalfLifeModel.SFO,
|
||||
"SFO²": HalfLifeModel.SFO,
|
||||
"HS": HalfLifeModel.HS,
|
||||
"top down": HalfLifeModel.OTHER,
|
||||
"SFO": HalfLifeModel.SFO,
|
||||
"First Order": HalfLifeModel.SFO,
|
||||
"SFO/SFO": HalfLifeModel.SFO_SFO,
|
||||
"FOMC + SFO": HalfLifeModel.FOMC_SFO,
|
||||
"true": HalfLifeModel.SFO,
|
||||
"SFO-SFO": HalfLifeModel.SFO_SFO,
|
||||
"DFOP-SFO": HalfLifeModel.DFOP_SFO,
|
||||
}
|
||||
|
||||
|
||||
def forward_func(apps, schema_editor):
|
||||
AdditionalInformation = apps.get_model("epdb", "AdditionalInformation")
|
||||
|
||||
hls = AdditionalInformation.objects.filter(type="HalfLife")
|
||||
|
||||
for hl in hls:
|
||||
data = hl.data
|
||||
data["model"] = MAPPING[data["model"]].value
|
||||
hl.data = HalfLife(**data).model_dump(mode="json")
|
||||
hl.save()
|
||||
|
||||
hlws = AdditionalInformation.objects.filter(type="HalfLifeWS")
|
||||
|
||||
for hl in hlws:
|
||||
data = hl.data
|
||||
data["model"] = MAPPING[data["model"]].value
|
||||
hl.data = HalfLifeWS(**data).model_dump(mode="json")
|
||||
hl.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("epdb", "0024_user_contacted"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forward_func, reverse_code=migrations.RunPython.noop),
|
||||
]
|
||||
1075
epdb/models.py
1075
epdb/models.py
File diff suppressed because it is too large
Load Diff
@ -7,10 +7,21 @@ from uuid import uuid4
|
||||
from celery import shared_task
|
||||
from celery.utils.functional import LRUCache
|
||||
from django.conf import settings as s
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.utils import timezone
|
||||
|
||||
from epdb.logic import SPathway
|
||||
from epdb.models import Edge, EPModel, JobLog, Node, Pathway, Rule, Setting, User
|
||||
from epdb.models import (
|
||||
AdditionalInformation,
|
||||
Edge,
|
||||
EPModel,
|
||||
JobLog,
|
||||
Node,
|
||||
Pathway,
|
||||
Rule,
|
||||
Setting,
|
||||
User,
|
||||
)
|
||||
from utilities.chem import FormatConverter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -65,15 +76,39 @@ def mul(a, b):
|
||||
|
||||
|
||||
@shared_task(queue="predict")
|
||||
def predict_simple(model_pk: int, smiles: str):
|
||||
def predict_simple(model_pk: int, smiles: str, *args, **kwargs):
|
||||
mod = get_ml_model(model_pk)
|
||||
res = mod.predict(smiles)
|
||||
res = mod.predict(smiles, *args, **kwargs)
|
||||
return res
|
||||
|
||||
|
||||
@shared_task(queue="background")
|
||||
def send_registration_mail(user_pk: int):
|
||||
pass
|
||||
u = User.objects.get(id=user_pk)
|
||||
|
||||
tpl = """Welcome {username}!,
|
||||
|
||||
Thank you for your interest in enviPath.
|
||||
|
||||
The public system is intended for non-commercial use only.
|
||||
We will review your account details and usually activate your account within 24 hours.
|
||||
Once activated, you will be notified by email.
|
||||
|
||||
If we have any questions, we will contact you at this email address.
|
||||
|
||||
Best regards,
|
||||
|
||||
enviPath team"""
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
"Your enviPath account",
|
||||
tpl.format(username=u.username),
|
||||
"admin@envipath.org",
|
||||
[u.email],
|
||||
bcc=["admin@envipath.org"],
|
||||
)
|
||||
|
||||
msg.send(fail_silently=False)
|
||||
|
||||
|
||||
@shared_task(bind=True, queue="model")
|
||||
@ -204,9 +239,28 @@ def predict(
|
||||
if JobLog.objects.filter(task_id=self.request.id).exists():
|
||||
JobLog.objects.filter(task_id=self.request.id).update(status="SUCCESS", task_result=pw.url)
|
||||
|
||||
# dispatch property job
|
||||
compute_properties.delay(pw_pk, pred_setting_pk)
|
||||
|
||||
return pw.url
|
||||
|
||||
|
||||
@shared_task(bind=True, queue="background")
|
||||
def compute_properties(self, pathway_pk: int, setting_pk: int):
|
||||
pw = Pathway.objects.get(id=pathway_pk)
|
||||
setting = Setting.objects.get(id=setting_pk)
|
||||
|
||||
nodes = [n for n in pw.nodes]
|
||||
smiles = [n.default_node_label.smiles for n in nodes]
|
||||
|
||||
for prop_mod in setting.property_models.all():
|
||||
if prop_mod.instance().is_heavy():
|
||||
rr = prop_mod.predict_batch(smiles)
|
||||
for idx, pred in enumerate(rr.result):
|
||||
n = nodes[idx]
|
||||
_ = AdditionalInformation.create(pw.package, ai=pred, content_object=n)
|
||||
|
||||
|
||||
@shared_task(bind=True, queue="background")
|
||||
def identify_missing_rules(
|
||||
self,
|
||||
@ -395,7 +449,7 @@ def batch_predict(
|
||||
standardized_substrates_and_smiles = []
|
||||
for substrate in substrate_and_names:
|
||||
try:
|
||||
stand_smiles = FormatConverter.standardize(substrate[0])
|
||||
stand_smiles = FormatConverter.standardize(substrate[0], remove_stereo=True)
|
||||
standardized_substrates_and_smiles.append([stand_smiles, substrate[1]])
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
|
||||
17
epdb/template_registry.py
Normal file
17
epdb/template_registry.py
Normal file
@ -0,0 +1,17 @@
|
||||
from collections import defaultdict
|
||||
from threading import Lock
|
||||
|
||||
_registry = defaultdict(list)
|
||||
_lock = Lock()
|
||||
|
||||
|
||||
def register_template(slot: str, template_name: str, *, order: int = 100):
|
||||
item = (order, template_name)
|
||||
with _lock:
|
||||
if item not in _registry[slot]:
|
||||
_registry[slot].append(item)
|
||||
_registry[slot].sort(key=lambda x: x[0])
|
||||
|
||||
|
||||
def get_templates(slot: str):
|
||||
return [template_name for _, template_name in _registry.get(slot, [])]
|
||||
@ -2,6 +2,8 @@ from django import template
|
||||
from pydantic import AnyHttpUrl, ValidationError
|
||||
from pydantic.type_adapter import TypeAdapter
|
||||
|
||||
from epdb.template_registry import get_templates
|
||||
|
||||
register = template.Library()
|
||||
|
||||
url_adapter = TypeAdapter(AnyHttpUrl)
|
||||
@ -19,3 +21,8 @@ def is_url(value):
|
||||
return True
|
||||
except ValidationError:
|
||||
return False
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def epdb_slot_templates(slot):
|
||||
return get_templates(slot)
|
||||
|
||||
@ -209,5 +209,4 @@ urlpatterns = [
|
||||
re_path(r"^contact$", v.static_contact_support, name="contact_support"),
|
||||
re_path(r"^careers$", v.static_careers, name="careers"),
|
||||
re_path(r"^cite$", v.static_cite, name="cite"),
|
||||
re_path(r"^legal$", v.static_legal, name="legal"),
|
||||
]
|
||||
|
||||
666
epdb/views.py
666
epdb/views.py
File diff suppressed because it is too large
Load Diff
0
epiuclid/__init__.py
Normal file
0
epiuclid/__init__.py
Normal file
22
epiuclid/api.py
Normal file
22
epiuclid/api.py
Normal file
@ -0,0 +1,22 @@
|
||||
from uuid import UUID
|
||||
|
||||
from django.http import HttpResponse
|
||||
from ninja import Router
|
||||
from epapi.v1.interfaces.iuclid.projections import get_pathway_for_iuclid_export
|
||||
|
||||
from .serializers.i6z import I6ZSerializer
|
||||
from .serializers.pathway_mapper import PathwayMapper
|
||||
|
||||
router = Router(tags=["iuclid"])
|
||||
|
||||
|
||||
@router.get("/pathway/{uuid:pathway_uuid}/export/iuclid")
|
||||
def export_pathway_iuclid(request, pathway_uuid: UUID):
|
||||
export = get_pathway_for_iuclid_export(request.user, pathway_uuid)
|
||||
bundle = PathwayMapper().map(export)
|
||||
i6z_bytes = I6ZSerializer().serialize(bundle)
|
||||
return HttpResponse(
|
||||
i6z_bytes,
|
||||
content_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="pathway-{pathway_uuid}.i6z"'},
|
||||
)
|
||||
6
epiuclid/apps.py
Normal file
6
epiuclid/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class EpiuclidConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "epiuclid"
|
||||
0
epiuclid/builders/__init__.py
Normal file
0
epiuclid/builders/__init__.py
Normal file
105
epiuclid/builders/base.py
Normal file
105
epiuclid/builders/base.py
Normal file
@ -0,0 +1,105 @@
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# IUCLID 6 XML namespaces
|
||||
NS_PLATFORM_CONTAINER = "http://iuclid6.echa.europa.eu/namespaces/platform-container/v2"
|
||||
NS_PLATFORM_METADATA = "http://iuclid6.echa.europa.eu/namespaces/platform-metadata/v1"
|
||||
NS_PLATFORM_FIELDS = "http://iuclid6.echa.europa.eu/namespaces/platform-fields/v1"
|
||||
NS_XLINK = "http://www.w3.org/1999/xlink"
|
||||
|
||||
# Register namespace prefixes for clean output
|
||||
ET.register_namespace("i6c", NS_PLATFORM_CONTAINER)
|
||||
ET.register_namespace("i6m", NS_PLATFORM_METADATA)
|
||||
ET.register_namespace("i6", NS_PLATFORM_FIELDS)
|
||||
ET.register_namespace("xlink", NS_XLINK)
|
||||
|
||||
IUCLID_VERSION = "6.0.0"
|
||||
DEFINITION_VERSION = "10.0"
|
||||
CREATION_TOOL = "enviPath"
|
||||
|
||||
|
||||
def _tag(ns: str, local: str) -> str:
|
||||
return f"{{{ns}}}{local}"
|
||||
|
||||
|
||||
def _sub(parent: ET.Element, ns: str, local: str, text: str | None = None) -> ET.Element:
|
||||
"""Create a sub-element under parent. Only sets text if not None."""
|
||||
elem = ET.SubElement(parent, _tag(ns, local))
|
||||
if text is not None:
|
||||
elem.text = str(text)
|
||||
return elem
|
||||
|
||||
|
||||
def _sub_if(parent: ET.Element, ns: str, local: str, text: str | None = None) -> ET.Element | None:
|
||||
"""Create a sub-element only when text is not None."""
|
||||
if text is None:
|
||||
return None
|
||||
return _sub(parent, ns, local, text)
|
||||
|
||||
|
||||
def build_platform_metadata(
|
||||
document_key: str,
|
||||
document_type: str,
|
||||
name: str,
|
||||
document_sub_type: str | None = None,
|
||||
parent_document_key: str | None = None,
|
||||
order_in_section_no: int | None = None,
|
||||
) -> ET.Element:
|
||||
"""Build the <i6c:PlatformMetadata> element for an i6d document."""
|
||||
pm = ET.Element(_tag(NS_PLATFORM_CONTAINER, "PlatformMetadata"))
|
||||
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
_sub(pm, NS_PLATFORM_METADATA, "iuclidVersion", IUCLID_VERSION)
|
||||
_sub(pm, NS_PLATFORM_METADATA, "documentKey", document_key)
|
||||
_sub(pm, NS_PLATFORM_METADATA, "documentType", document_type)
|
||||
_sub(pm, NS_PLATFORM_METADATA, "definitionVersion", DEFINITION_VERSION)
|
||||
_sub(pm, NS_PLATFORM_METADATA, "creationDate", now)
|
||||
_sub(pm, NS_PLATFORM_METADATA, "lastModificationDate", now)
|
||||
_sub(pm, NS_PLATFORM_METADATA, "name", name)
|
||||
if document_sub_type:
|
||||
_sub(pm, NS_PLATFORM_METADATA, "documentSubType", document_sub_type)
|
||||
if parent_document_key:
|
||||
_sub(pm, NS_PLATFORM_METADATA, "parentDocumentKey", parent_document_key)
|
||||
if order_in_section_no is not None:
|
||||
_sub(pm, NS_PLATFORM_METADATA, "orderInSectionNo", str(order_in_section_no))
|
||||
_sub(pm, NS_PLATFORM_METADATA, "i5Origin", "false")
|
||||
_sub(pm, NS_PLATFORM_METADATA, "creationTool", CREATION_TOOL)
|
||||
|
||||
return pm
|
||||
|
||||
|
||||
def build_document(
|
||||
document_key: str,
|
||||
document_type: str,
|
||||
name: str,
|
||||
content_element: ET.Element,
|
||||
document_sub_type: str | None = None,
|
||||
parent_document_key: str | None = None,
|
||||
order_in_section_no: int | None = None,
|
||||
) -> str:
|
||||
"""Build a complete i6d document XML string."""
|
||||
root = ET.Element(_tag(NS_PLATFORM_CONTAINER, "Document"))
|
||||
|
||||
pm = build_platform_metadata(
|
||||
document_key=document_key,
|
||||
document_type=document_type,
|
||||
name=name,
|
||||
document_sub_type=document_sub_type,
|
||||
parent_document_key=parent_document_key,
|
||||
order_in_section_no=order_in_section_no,
|
||||
)
|
||||
root.append(pm)
|
||||
|
||||
content_wrapper = _sub(root, NS_PLATFORM_CONTAINER, "Content")
|
||||
content_wrapper.append(content_element)
|
||||
|
||||
_sub(root, NS_PLATFORM_CONTAINER, "Attachments")
|
||||
_sub(root, NS_PLATFORM_CONTAINER, "ModificationHistory")
|
||||
|
||||
return ET.tostring(root, encoding="unicode", xml_declaration=True)
|
||||
|
||||
|
||||
def document_key(uuid) -> str:
|
||||
"""Format a UUID as an IUCLID document key (uuid/0 for raw data)."""
|
||||
return f"{uuid}/0"
|
||||
259
epiuclid/builders/endpoint_study.py
Normal file
259
epiuclid/builders/endpoint_study.py
Normal file
@ -0,0 +1,259 @@
|
||||
import xml.etree.ElementTree as ET
|
||||
from uuid import uuid4
|
||||
|
||||
from epiuclid.serializers.pathway_mapper import IUCLIDEndpointStudyRecordData, SoilPropertiesData
|
||||
|
||||
from .base import (
|
||||
NS_PLATFORM_FIELDS,
|
||||
_sub,
|
||||
_tag,
|
||||
build_document,
|
||||
document_key,
|
||||
)
|
||||
|
||||
NS_ESR_BIODEG = (
|
||||
"http://iuclid6.echa.europa.eu/namespaces/ENDPOINT_STUDY_RECORD-BiodegradationInSoil/10.0"
|
||||
)
|
||||
ET.register_namespace("", NS_ESR_BIODEG)
|
||||
|
||||
DOC_SUBTYPE = "BiodegradationInSoil"
|
||||
PICKLIST_OTHER_CODE = "1342"
|
||||
SOIL_TYPE_CODE_BY_KEY = {
|
||||
"CLAY": "257",
|
||||
"CLAY_LOAM": "258",
|
||||
"LOAM": "1026",
|
||||
"LOAMY_SAND": "1027",
|
||||
"SAND": "1522",
|
||||
"SANDY_CLAY_LOAM": "1523",
|
||||
"SANDY_LOAM": "1524",
|
||||
"SANDY_CLAY": "1525",
|
||||
"SILT": "1549",
|
||||
"SILT_LOAM": "1550",
|
||||
"SILTY_CLAY": "1551",
|
||||
"SILTY_CLAY_LOAM": "1552",
|
||||
}
|
||||
SOIL_CLASSIFICATION_CODE_BY_KEY = {
|
||||
"USDA": "1649",
|
||||
"DE": "314",
|
||||
"INTERNATIONAL": "1658",
|
||||
}
|
||||
|
||||
|
||||
class EndpointStudyRecordBuilder:
|
||||
def build(self, data: IUCLIDEndpointStudyRecordData) -> str:
|
||||
esr = ET.Element(f"{{{NS_ESR_BIODEG}}}ENDPOINT_STUDY_RECORD.{DOC_SUBTYPE}")
|
||||
|
||||
soil_entries = list(data.soil_properties_entries)
|
||||
if not soil_entries and data.soil_properties is not None:
|
||||
soil_entries = [data.soil_properties]
|
||||
|
||||
has_materials = bool(
|
||||
data.model_name_and_version
|
||||
or data.software_name_and_version
|
||||
or data.model_remarks
|
||||
or soil_entries
|
||||
)
|
||||
if has_materials:
|
||||
materials = _sub(esr, NS_ESR_BIODEG, "MaterialsAndMethods")
|
||||
|
||||
if soil_entries:
|
||||
self._build_soil_structured_full(materials, soil_entries)
|
||||
|
||||
if data.model_name_and_version or data.software_name_and_version or data.model_remarks:
|
||||
model_info = _sub(materials, NS_ESR_BIODEG, "ModelAndSoftware")
|
||||
|
||||
for model_name in data.model_name_and_version:
|
||||
_sub(model_info, NS_ESR_BIODEG, "ModelNameAndVersion", model_name)
|
||||
|
||||
for software_name in data.software_name_and_version:
|
||||
_sub(model_info, NS_ESR_BIODEG, "SoftwareNameAndVersion", software_name)
|
||||
|
||||
for remark in data.model_remarks:
|
||||
_sub(model_info, NS_ESR_BIODEG, "Remarks", remark)
|
||||
|
||||
has_results = (
|
||||
data.half_lives or data.transformation_products or data.temperature is not None
|
||||
)
|
||||
if has_results:
|
||||
results = _sub(esr, NS_ESR_BIODEG, "ResultsAndDiscussion")
|
||||
|
||||
if data.half_lives or data.temperature is not None:
|
||||
dt_parent = _sub(results, NS_ESR_BIODEG, "DTParentCompound")
|
||||
|
||||
if data.half_lives:
|
||||
for hl in data.half_lives:
|
||||
entry = ET.SubElement(dt_parent, _tag(NS_ESR_BIODEG, "entry"))
|
||||
entry.set(_tag(NS_PLATFORM_FIELDS, "uuid"), str(uuid4()))
|
||||
|
||||
if hl.soil_no_code:
|
||||
soil_no = _sub(entry, NS_ESR_BIODEG, "SoilNo")
|
||||
_sub(soil_no, NS_ESR_BIODEG, "value", hl.soil_no_code)
|
||||
|
||||
value_range = _sub(entry, NS_ESR_BIODEG, "Value")
|
||||
_sub(value_range, NS_ESR_BIODEG, "unitCode", "2329") # days
|
||||
_sub(value_range, NS_ESR_BIODEG, "lowerValue", str(hl.dt50_start))
|
||||
_sub(value_range, NS_ESR_BIODEG, "upperValue", str(hl.dt50_end))
|
||||
|
||||
temperature = (
|
||||
hl.temperature if hl.temperature is not None else data.temperature
|
||||
)
|
||||
if temperature is not None:
|
||||
temp_range = _sub(entry, NS_ESR_BIODEG, "Temp")
|
||||
_sub(temp_range, NS_ESR_BIODEG, "unitCode", "2493") # degree Celsius
|
||||
_sub(temp_range, NS_ESR_BIODEG, "lowerValue", str(temperature[0]))
|
||||
_sub(temp_range, NS_ESR_BIODEG, "upperValue", str(temperature[1]))
|
||||
|
||||
_sub(entry, NS_ESR_BIODEG, "KineticParameters", hl.model)
|
||||
else:
|
||||
# Temperature without half-lives: single entry with only Temp
|
||||
assert data.temperature is not None
|
||||
entry = ET.SubElement(dt_parent, _tag(NS_ESR_BIODEG, "entry"))
|
||||
entry.set(_tag(NS_PLATFORM_FIELDS, "uuid"), str(uuid4()))
|
||||
temp_range = _sub(entry, NS_ESR_BIODEG, "Temp")
|
||||
_sub(temp_range, NS_ESR_BIODEG, "unitCode", "2493") # degree Celsius
|
||||
_sub(temp_range, NS_ESR_BIODEG, "lowerValue", str(data.temperature[0]))
|
||||
_sub(temp_range, NS_ESR_BIODEG, "upperValue", str(data.temperature[1]))
|
||||
|
||||
if data.transformation_products:
|
||||
tp_details = _sub(results, NS_ESR_BIODEG, "TransformationProductsDetails")
|
||||
for tp in data.transformation_products:
|
||||
entry = ET.SubElement(tp_details, _tag(NS_ESR_BIODEG, "entry"))
|
||||
entry.set(_tag(NS_PLATFORM_FIELDS, "uuid"), str(tp.uuid))
|
||||
|
||||
_sub(
|
||||
entry,
|
||||
NS_ESR_BIODEG,
|
||||
"IdentityOfCompound",
|
||||
document_key(tp.product_reference_uuid),
|
||||
)
|
||||
|
||||
if tp.parent_reference_uuids:
|
||||
parents = _sub(entry, NS_ESR_BIODEG, "ParentCompoundS")
|
||||
for parent_uuid in tp.parent_reference_uuids:
|
||||
_sub(parents, NS_PLATFORM_FIELDS, "key", document_key(parent_uuid))
|
||||
|
||||
if tp.kinetic_formation_fraction is not None:
|
||||
_sub(
|
||||
entry,
|
||||
NS_ESR_BIODEG,
|
||||
"KineticFormationFraction",
|
||||
str(tp.kinetic_formation_fraction),
|
||||
)
|
||||
|
||||
doc_key = document_key(data.uuid)
|
||||
return build_document(
|
||||
document_key=doc_key,
|
||||
document_type="ENDPOINT_STUDY_RECORD",
|
||||
document_sub_type=DOC_SUBTYPE,
|
||||
name=data.name,
|
||||
content_element=esr,
|
||||
parent_document_key=document_key(data.substance_uuid),
|
||||
order_in_section_no=1,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_soil_structured_full(
|
||||
materials: ET.Element,
|
||||
props_entries: list[SoilPropertiesData],
|
||||
) -> None:
|
||||
study_design = _sub(materials, NS_ESR_BIODEG, "StudyDesign")
|
||||
|
||||
soil_classification = None
|
||||
for props in props_entries:
|
||||
soil_classification = EndpointStudyRecordBuilder._soil_classification(props)
|
||||
if soil_classification:
|
||||
break
|
||||
|
||||
if soil_classification:
|
||||
soil_classification_el = _sub(study_design, NS_ESR_BIODEG, "SoilClassification")
|
||||
value, other = EndpointStudyRecordBuilder._picklist_value_and_other(
|
||||
soil_classification,
|
||||
SOIL_CLASSIFICATION_CODE_BY_KEY,
|
||||
)
|
||||
if value:
|
||||
_sub(soil_classification_el, NS_ESR_BIODEG, "value", value)
|
||||
if other:
|
||||
_sub(soil_classification_el, NS_ESR_BIODEG, "other", other)
|
||||
|
||||
soil_props = _sub(study_design, NS_ESR_BIODEG, "SoilProperties")
|
||||
|
||||
for props in props_entries:
|
||||
entry = ET.SubElement(soil_props, _tag(NS_ESR_BIODEG, "entry"))
|
||||
entry.set(_tag(NS_PLATFORM_FIELDS, "uuid"), str(uuid4()))
|
||||
|
||||
if props.soil_no_code:
|
||||
soil_no = _sub(entry, NS_ESR_BIODEG, "SoilNo")
|
||||
_sub(soil_no, NS_ESR_BIODEG, "value", props.soil_no_code)
|
||||
|
||||
soil_type = props.soil_type.strip() if props.soil_type else None
|
||||
if soil_type:
|
||||
soil_type_el = _sub(entry, NS_ESR_BIODEG, "SoilType")
|
||||
value, other = EndpointStudyRecordBuilder._picklist_value_and_other(
|
||||
soil_type,
|
||||
SOIL_TYPE_CODE_BY_KEY,
|
||||
)
|
||||
if value:
|
||||
_sub(soil_type_el, NS_ESR_BIODEG, "value", value)
|
||||
if other:
|
||||
_sub(soil_type_el, NS_ESR_BIODEG, "other", other)
|
||||
|
||||
if props.clay is not None:
|
||||
clay_el = _sub(entry, NS_ESR_BIODEG, "Clay")
|
||||
_sub(clay_el, NS_ESR_BIODEG, "lowerValue", str(props.clay))
|
||||
|
||||
if props.silt is not None:
|
||||
silt_el = _sub(entry, NS_ESR_BIODEG, "Silt")
|
||||
_sub(silt_el, NS_ESR_BIODEG, "lowerValue", str(props.silt))
|
||||
|
||||
if props.sand is not None:
|
||||
sand_el = _sub(entry, NS_ESR_BIODEG, "Sand")
|
||||
_sub(sand_el, NS_ESR_BIODEG, "lowerValue", str(props.sand))
|
||||
|
||||
if props.org_carbon is not None:
|
||||
orgc_el = _sub(entry, NS_ESR_BIODEG, "OrgC")
|
||||
_sub(orgc_el, NS_ESR_BIODEG, "lowerValue", str(props.org_carbon))
|
||||
|
||||
if props.ph_lower is not None or props.ph_upper is not None:
|
||||
ph_el = _sub(entry, NS_ESR_BIODEG, "Ph")
|
||||
if props.ph_lower is not None:
|
||||
_sub(ph_el, NS_ESR_BIODEG, "lowerValue", str(props.ph_lower))
|
||||
if props.ph_upper is not None:
|
||||
_sub(ph_el, NS_ESR_BIODEG, "upperValue", str(props.ph_upper))
|
||||
|
||||
ph_method = props.ph_method.strip() if props.ph_method else None
|
||||
if ph_method:
|
||||
_sub(entry, NS_ESR_BIODEG, "PHMeasuredIn", ph_method)
|
||||
|
||||
if props.cec is not None:
|
||||
cec_el = _sub(entry, NS_ESR_BIODEG, "CEC")
|
||||
_sub(cec_el, NS_ESR_BIODEG, "lowerValue", str(props.cec))
|
||||
|
||||
if props.moisture_content is not None:
|
||||
moisture_el = _sub(entry, NS_ESR_BIODEG, "MoistureContent")
|
||||
_sub(moisture_el, NS_ESR_BIODEG, "lowerValue", str(props.moisture_content))
|
||||
|
||||
@staticmethod
|
||||
def _soil_classification(props: SoilPropertiesData) -> str | None:
|
||||
if props.soil_classification:
|
||||
value = props.soil_classification.strip()
|
||||
if value:
|
||||
return value
|
||||
if props.soil_type:
|
||||
return "USDA"
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _picklist_value_and_other(
|
||||
raw_value: str,
|
||||
code_map: dict[str, str],
|
||||
) -> tuple[str | None, str | None]:
|
||||
value = raw_value.strip()
|
||||
if not value:
|
||||
return None, None
|
||||
|
||||
key = value.upper().replace("-", "_").replace(" ", "_")
|
||||
code = code_map.get(key)
|
||||
if code is not None:
|
||||
return code, None
|
||||
|
||||
return PICKLIST_OTHER_CODE, value.replace("_", " ")
|
||||
54
epiuclid/builders/reference_substance.py
Normal file
54
epiuclid/builders/reference_substance.py
Normal file
@ -0,0 +1,54 @@
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from epiuclid.serializers.pathway_mapper import IUCLIDReferenceSubstanceData
|
||||
|
||||
from .base import (
|
||||
_sub,
|
||||
_sub_if,
|
||||
build_document,
|
||||
document_key,
|
||||
)
|
||||
|
||||
NS_REFERENCE_SUBSTANCE = "http://iuclid6.echa.europa.eu/namespaces/REFERENCE_SUBSTANCE/10.0"
|
||||
ET.register_namespace("", NS_REFERENCE_SUBSTANCE)
|
||||
|
||||
|
||||
class ReferenceSubstanceBuilder:
|
||||
def build(self, data: IUCLIDReferenceSubstanceData) -> str:
|
||||
ref = ET.Element(f"{{{NS_REFERENCE_SUBSTANCE}}}REFERENCE_SUBSTANCE")
|
||||
|
||||
_sub(ref, NS_REFERENCE_SUBSTANCE, "ReferenceSubstanceName", data.name)
|
||||
_sub_if(ref, NS_REFERENCE_SUBSTANCE, "IupacName", data.iupac_name)
|
||||
if data.cas_number:
|
||||
inventory = _sub(ref, NS_REFERENCE_SUBSTANCE, "Inventory")
|
||||
_sub(inventory, NS_REFERENCE_SUBSTANCE, "CASNumber", data.cas_number)
|
||||
|
||||
has_structural_info = any(
|
||||
[
|
||||
data.molecular_formula,
|
||||
data.molecular_weight is not None,
|
||||
data.smiles,
|
||||
data.inchi,
|
||||
data.inchi_key,
|
||||
]
|
||||
)
|
||||
if has_structural_info:
|
||||
structural = _sub(ref, NS_REFERENCE_SUBSTANCE, "MolecularStructuralInfo")
|
||||
_sub_if(structural, NS_REFERENCE_SUBSTANCE, "MolecularFormula", data.molecular_formula)
|
||||
|
||||
if data.molecular_weight is not None:
|
||||
mw = _sub(structural, NS_REFERENCE_SUBSTANCE, "MolecularWeightRange")
|
||||
_sub(mw, NS_REFERENCE_SUBSTANCE, "lowerValue", f"{data.molecular_weight:.2f}")
|
||||
_sub(mw, NS_REFERENCE_SUBSTANCE, "upperValue", f"{data.molecular_weight:.2f}")
|
||||
|
||||
_sub_if(structural, NS_REFERENCE_SUBSTANCE, "SmilesNotation", data.smiles)
|
||||
_sub_if(structural, NS_REFERENCE_SUBSTANCE, "InChl", data.inchi)
|
||||
_sub_if(structural, NS_REFERENCE_SUBSTANCE, "InChIKey", data.inchi_key)
|
||||
|
||||
doc_key = document_key(data.uuid)
|
||||
return build_document(
|
||||
document_key=doc_key,
|
||||
document_type="REFERENCE_SUBSTANCE",
|
||||
name=data.name,
|
||||
content_element=ref,
|
||||
)
|
||||
37
epiuclid/builders/substance.py
Normal file
37
epiuclid/builders/substance.py
Normal file
@ -0,0 +1,37 @@
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from epiuclid.serializers.pathway_mapper import IUCLIDSubstanceData
|
||||
|
||||
from .base import (
|
||||
_sub,
|
||||
build_document,
|
||||
document_key,
|
||||
)
|
||||
|
||||
NS_SUBSTANCE = "http://iuclid6.echa.europa.eu/namespaces/SUBSTANCE/10.0"
|
||||
ET.register_namespace("", NS_SUBSTANCE)
|
||||
|
||||
|
||||
class SubstanceBuilder:
|
||||
def build(self, data: IUCLIDSubstanceData) -> str:
|
||||
substance = ET.Element(f"{{{NS_SUBSTANCE}}}SUBSTANCE")
|
||||
|
||||
_sub(substance, NS_SUBSTANCE, "Templates")
|
||||
_sub(substance, NS_SUBSTANCE, "ChemicalName", data.name)
|
||||
|
||||
if data.reference_substance_uuid:
|
||||
ref_sub = _sub(substance, NS_SUBSTANCE, "ReferenceSubstance")
|
||||
_sub(
|
||||
ref_sub,
|
||||
NS_SUBSTANCE,
|
||||
"ReferenceSubstance",
|
||||
document_key(data.reference_substance_uuid),
|
||||
)
|
||||
|
||||
doc_key = document_key(data.uuid)
|
||||
return build_document(
|
||||
document_key=doc_key,
|
||||
document_type="SUBSTANCE",
|
||||
name=data.name,
|
||||
content_element=substance,
|
||||
)
|
||||
90
epiuclid/schemas/loader.py
Normal file
90
epiuclid/schemas/loader.py
Normal file
@ -0,0 +1,90 @@
|
||||
"""Load and cache IUCLID XSD schemas with cross-reference resolution.
|
||||
|
||||
The bundled XSD schemas use bare ``schemaLocation`` filenames (e.g.
|
||||
``platform-fields.xsd``, ``commonTypesDomainV10.xsd``) that don't match the
|
||||
actual directory layout. This module builds an explicit namespace → file-path
|
||||
mapping so that ``xmlschema`` can resolve every import.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import xmlschema
|
||||
|
||||
_SCHEMA_ROOT = Path(__file__).resolve().parent / "v10"
|
||||
|
||||
# Namespace → relative file-path (from _SCHEMA_ROOT) for schemas that are
|
||||
# referenced by bare filename from subdirectories that don't contain them.
|
||||
_NS_LOCATIONS: dict[str, str] = {
|
||||
"http://iuclid6.echa.europa.eu/namespaces/platform-fields/v1": "platform-fields.xsd",
|
||||
"http://iuclid6.echa.europa.eu/namespaces/platform-metadata/v1": "platform-metadata.xsd",
|
||||
"http://iuclid6.echa.europa.eu/namespaces/platform-container/v2": "platform-container-v2.xsd",
|
||||
"http://iuclid6.echa.europa.eu/namespaces/platform-attachment/v1": "platform-attachment.xsd",
|
||||
"http://iuclid6.echa.europa.eu/namespaces/platform-modification-history/v1": (
|
||||
"platform-modification-history.xsd"
|
||||
),
|
||||
"http://www.w3.org/1999/xlink": "xlink.xsd",
|
||||
"http://www.w3.org/XML/1998/namespace": "xml.xsd",
|
||||
"http://iuclid6.echa.europa.eu/namespaces/domain/v10": ("domain/v10/commonTypesDomainV10.xsd"),
|
||||
"http://iuclid6.echa.europa.eu/namespaces/oecd/v10": ("oecd/v10/commonTypesOecdV10.xsd"),
|
||||
}
|
||||
|
||||
# doc_type → (subdir, filename-pattern)
|
||||
_DOC_TYPE_PATHS: dict[str, tuple[str, str]] = {
|
||||
"SUBSTANCE": ("domain/v10", "SUBSTANCE-10.0.xsd"),
|
||||
"REFERENCE_SUBSTANCE": ("domain/v10", "REFERENCE_SUBSTANCE-10.0.xsd"),
|
||||
}
|
||||
|
||||
|
||||
def _absolute_locations() -> list[tuple[str, str]]:
|
||||
"""Return (namespace, absolute-file-URI) pairs for all known schemas."""
|
||||
return [(ns, (_SCHEMA_ROOT / rel).as_uri()) for ns, rel in _NS_LOCATIONS.items()]
|
||||
|
||||
|
||||
def _esr_path(subtype: str) -> Path:
|
||||
"""Return the path to an Endpoint Study Record schema."""
|
||||
return _SCHEMA_ROOT / "oecd" / "v10" / f"ENDPOINT_STUDY_RECORD-{subtype}-10.0.xsd"
|
||||
|
||||
|
||||
def _doc_type_path(doc_type: str, subtype: str | None = None) -> Path:
|
||||
if doc_type == "ENDPOINT_STUDY_RECORD":
|
||||
if not subtype:
|
||||
raise ValueError("subtype is required for ENDPOINT_STUDY_RECORD schemas")
|
||||
return _esr_path(subtype)
|
||||
info = _DOC_TYPE_PATHS.get(doc_type)
|
||||
if info is None:
|
||||
raise ValueError(f"Unknown document type: {doc_type}")
|
||||
subdir, filename = info
|
||||
return _SCHEMA_ROOT / subdir / filename
|
||||
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def get_content_schema(doc_type: str, subtype: str | None = None) -> xmlschema.XMLSchema:
|
||||
"""Return a compiled XSD schema for validating content elements.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
doc_type:
|
||||
IUCLID document type (``SUBSTANCE``, ``REFERENCE_SUBSTANCE``,
|
||||
``ENDPOINT_STUDY_RECORD``).
|
||||
subtype:
|
||||
Required for ``ENDPOINT_STUDY_RECORD`` (e.g. ``BiodegradationInSoil``).
|
||||
"""
|
||||
path = _doc_type_path(doc_type, subtype)
|
||||
return xmlschema.XMLSchema(str(path), locations=_absolute_locations())
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_document_schema() -> xmlschema.XMLSchema:
|
||||
"""Return a compiled XSD schema for the ``platform-container-v2`` wrapper.
|
||||
|
||||
This validates the full ``<Document>`` element (PlatformMetadata + Content +
|
||||
Attachments + ModificationHistory). Content is validated with
|
||||
``processContents="strict"`` via ``xs:any``, but only if the content
|
||||
namespace has been loaded. For full content validation, use
|
||||
:func:`get_content_schema` separately.
|
||||
"""
|
||||
path = _SCHEMA_ROOT / "platform-container-v2.xsd"
|
||||
return xmlschema.XMLSchema(str(path), locations=_absolute_locations())
|
||||
237
epiuclid/schemas/v10/domain/v10/REFERENCE_SUBSTANCE-10.0.xsd
Normal file
237
epiuclid/schemas/v10/domain/v10/REFERENCE_SUBSTANCE-10.0.xsd
Normal file
@ -0,0 +1,237 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns="http://iuclid6.echa.europa.eu/namespaces/REFERENCE_SUBSTANCE/10.0" xmlns:ct="http://iuclid6.echa.europa.eu/namespaces/domain/v10" xmlns:i6="http://iuclid6.echa.europa.eu/namespaces/platform-fields/v1" attributeFormDefault="qualified" elementFormDefault="qualified" targetNamespace="http://iuclid6.echa.europa.eu/namespaces/REFERENCE_SUBSTANCE/10.0">
|
||||
<xs:import namespace="http://iuclid6.echa.europa.eu/namespaces/platform-fields/v1" schemaLocation="platform-fields.xsd"/>
|
||||
<xs:import namespace="http://iuclid6.echa.europa.eu/namespaces/domain/v10" schemaLocation="commonTypesDomainV10.xsd"/>
|
||||
<xs:element name="REFERENCE_SUBSTANCE">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="DataProtection">
|
||||
<xs:complexType>
|
||||
<xs:complexContent>
|
||||
<xs:extension base="i6:baseDataProtectionField">
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="confidentiality" type="ct:N64"/>
|
||||
<xs:element minOccurs="0" name="justification" type="i6:textField"/>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="legislation">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="value" type="ct:N78"/>
|
||||
<xs:element minOccurs="0" name="other" type="i6:textFieldSmall"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:sequence>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element name="ReferenceSubstanceName">
|
||||
<xs:simpleType>
|
||||
<xs:restriction base="i6:textFieldMultiLine">
|
||||
<xs:minLength value="1"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" name="IupacName" type="i6:textFieldMultiLine"/>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="Description" type="i6:multilingualTextFieldLarge"/>
|
||||
<xs:element minOccurs="0" name="Inventory">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="InventoryEntry">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="entry" type="i6:inventoryEntry"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" name="InventoryEntryJustification">
|
||||
<xs:complexType>
|
||||
<xs:complexContent>
|
||||
<xs:extension base="i6:basePicklistField">
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="value" type="ct:N95"/>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="other" type="i6:multilingualTextFieldSmall"/>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="remarks" type="i6:multilingualTextField"/>
|
||||
</xs:sequence>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" name="CASNumber" type="i6:textFieldSmall"/>
|
||||
<xs:element minOccurs="0" name="CASName" type="i6:textFieldMultiLine"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" name="Synonyms">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="Synonyms">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="entry">
|
||||
<xs:complexType>
|
||||
<xs:complexContent>
|
||||
<xs:extension base="i6:repeatableEntryType">
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="DataProtection">
|
||||
<xs:complexType>
|
||||
<xs:complexContent>
|
||||
<xs:extension base="i6:baseDataProtectionField">
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="confidentiality" type="ct:N64"/>
|
||||
<xs:element minOccurs="0" name="justification" type="i6:textField"/>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="legislation">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="value" type="ct:N78"/>
|
||||
<xs:element minOccurs="0" name="other" type="i6:textFieldSmall"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:sequence>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" name="Identifier">
|
||||
<xs:complexType>
|
||||
<xs:complexContent>
|
||||
<xs:extension base="i6:basePicklistField">
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="value" type="ct:PG6_60192"/>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="other" type="i6:multilingualTextFieldSmall"/>
|
||||
</xs:sequence>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" name="Name" type="i6:textFieldMultiLine"/>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="Remarks" type="i6:multilingualTextFieldSmall"/>
|
||||
</xs:sequence>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" name="MolecularStructuralInfo">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="DataProtection">
|
||||
<xs:complexType>
|
||||
<xs:complexContent>
|
||||
<xs:extension base="i6:baseDataProtectionField">
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="confidentiality" type="ct:N64"/>
|
||||
<xs:element minOccurs="0" name="justification" type="i6:textField"/>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="legislation">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="value" type="ct:N78"/>
|
||||
<xs:element minOccurs="0" name="other" type="i6:textFieldSmall"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:sequence>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" name="MolecularFormula" type="i6:textFieldMultiLine"/>
|
||||
<xs:element minOccurs="0" name="MolecularWeightRange">
|
||||
<xs:complexType>
|
||||
<xs:complexContent>
|
||||
<xs:extension base="i6:basePhysicalQuantityRangeField">
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="lowerQualifier" type="i6:lowerQualifier"/>
|
||||
<xs:element minOccurs="0" name="upperQualifier" type="i6:upperQualifier"/>
|
||||
<xs:element minOccurs="0" name="lowerValue" type="xs:decimal"/>
|
||||
<xs:element minOccurs="0" name="upperValue" type="xs:decimal"/>
|
||||
</xs:sequence>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" name="SmilesNotation" type="i6:textFieldMultiLine"/>
|
||||
<xs:element minOccurs="0" name="InChl" type="i6:textFieldMultiLine"/>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="InChIKey" type="i6:multilingualTextFieldSmall"/>
|
||||
<xs:element minOccurs="0" name="StructuralFormula" type="i6:attachmentField"/>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="Remarks" type="i6:multilingualTextFieldLarge"/>
|
||||
<xs:element minOccurs="0" name="ChemicalStructureFiles">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="entry">
|
||||
<xs:complexType>
|
||||
<xs:complexContent>
|
||||
<xs:extension base="i6:repeatableEntryType">
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="StructureFile" type="i6:attachmentField"/>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="RemarksChemStruct" type="i6:multilingualTextFieldSmall"/>
|
||||
</xs:sequence>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" name="RelatedSubstances">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="RelatedSubstances">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="entry">
|
||||
<xs:complexType>
|
||||
<xs:complexContent>
|
||||
<xs:extension base="i6:repeatableEntryType">
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="Identifier">
|
||||
<xs:complexType>
|
||||
<xs:complexContent>
|
||||
<xs:extension base="i6:basePicklistField">
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="value" type="ct:PG6_60192"/>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="other" type="i6:multilingualTextFieldSmall"/>
|
||||
</xs:sequence>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" name="Identity" type="i6:textFieldLarge"/>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="Remarks" type="i6:multilingualTextFieldSmall"/>
|
||||
<xs:element minOccurs="0" name="Relation">
|
||||
<xs:complexType>
|
||||
<xs:complexContent>
|
||||
<xs:extension base="i6:basePicklistField">
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="value" type="ct:N05"/>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="other" type="i6:multilingualTextFieldSmall"/>
|
||||
</xs:sequence>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:sequence>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="GroupCategoryInfo" type="i6:multilingualTextFieldMultiLine"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:schema>
|
||||
266
epiuclid/schemas/v10/domain/v10/SUBSTANCE-10.0.xsd
Normal file
266
epiuclid/schemas/v10/domain/v10/SUBSTANCE-10.0.xsd
Normal file
@ -0,0 +1,266 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns="http://iuclid6.echa.europa.eu/namespaces/SUBSTANCE/10.0" xmlns:ct="http://iuclid6.echa.europa.eu/namespaces/domain/v10" xmlns:i6="http://iuclid6.echa.europa.eu/namespaces/platform-fields/v1" attributeFormDefault="qualified" elementFormDefault="qualified" targetNamespace="http://iuclid6.echa.europa.eu/namespaces/SUBSTANCE/10.0">
|
||||
<xs:import namespace="http://iuclid6.echa.europa.eu/namespaces/platform-fields/v1" schemaLocation="platform-fields.xsd"/>
|
||||
<xs:import namespace="http://iuclid6.echa.europa.eu/namespaces/domain/v10" schemaLocation="commonTypesDomainV10.xsd"/>
|
||||
<xs:element name="SUBSTANCE">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element name="Templates">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="Template" type="xs:string"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element name="ChemicalName">
|
||||
<xs:simpleType>
|
||||
<xs:restriction base="i6:textFieldMultiLine">
|
||||
<xs:minLength value="1"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" name="PublicName" type="i6:textFieldMultiLine"/>
|
||||
<xs:element minOccurs="0" name="OtherNames">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="entry">
|
||||
<xs:complexType>
|
||||
<xs:complexContent>
|
||||
<xs:extension base="i6:repeatableEntryType">
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="DataProtection">
|
||||
<xs:complexType>
|
||||
<xs:complexContent>
|
||||
<xs:extension base="i6:baseDataProtectionField">
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="confidentiality" type="ct:N64"/>
|
||||
<xs:element minOccurs="0" name="justification" type="i6:textField"/>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="legislation">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="value" type="ct:N78"/>
|
||||
<xs:element minOccurs="0" name="other" type="i6:textFieldSmall"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:sequence>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" name="NameType">
|
||||
<xs:complexType>
|
||||
<xs:complexContent>
|
||||
<xs:extension base="i6:basePicklistField">
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="value" type="ct:N97"/>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="other" type="i6:multilingualTextFieldSmall"/>
|
||||
</xs:sequence>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" name="Name" type="i6:textFieldMultiLine"/>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="Country">
|
||||
<xs:complexType>
|
||||
<xs:complexContent>
|
||||
<xs:extension base="i6:basePicklistField">
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="value" type="ct:A31"/>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="other" type="i6:multilingualTextFieldSmall"/>
|
||||
</xs:sequence>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" name="Relation">
|
||||
<xs:complexType>
|
||||
<xs:complexContent>
|
||||
<xs:extension base="i6:basePicklistField">
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="value" type="ct:PG6_60200"/>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="other" type="i6:multilingualTextFieldSmall"/>
|
||||
</xs:sequence>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="Remarks" type="i6:multilingualTextFieldLarge"/>
|
||||
</xs:sequence>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" name="OwnerLegalEntityProtection">
|
||||
<xs:complexType>
|
||||
<xs:complexContent>
|
||||
<xs:extension base="i6:baseDataProtectionField">
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="confidentiality" type="ct:N64"/>
|
||||
<xs:element minOccurs="0" name="justification" type="i6:textField"/>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="legislation">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="value" type="ct:N78"/>
|
||||
<xs:element minOccurs="0" name="other" type="i6:textFieldSmall"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:sequence>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" name="OwnerLegalEntity" type="i6:documentReferenceField"/>
|
||||
<xs:element minOccurs="0" name="ThirdPartyProtection">
|
||||
<xs:complexType>
|
||||
<xs:complexContent>
|
||||
<xs:extension base="i6:baseDataProtectionField">
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="confidentiality" type="ct:N64"/>
|
||||
<xs:element minOccurs="0" name="justification" type="i6:textField"/>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="legislation">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="value" type="ct:N78"/>
|
||||
<xs:element minOccurs="0" name="other" type="i6:textFieldSmall"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:sequence>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" name="ThirdParty" type="i6:documentReferenceField"/>
|
||||
<xs:element minOccurs="0" name="ContactPersons">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="entry">
|
||||
<xs:complexType>
|
||||
<xs:complexContent>
|
||||
<xs:extension base="i6:repeatableEntryType">
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="DataProtection">
|
||||
<xs:complexType>
|
||||
<xs:complexContent>
|
||||
<xs:extension base="i6:baseDataProtectionField">
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="confidentiality" type="ct:N64"/>
|
||||
<xs:element minOccurs="0" name="justification" type="i6:textField"/>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="legislation">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="value" type="ct:N78"/>
|
||||
<xs:element minOccurs="0" name="other" type="i6:textFieldSmall"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:sequence>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" name="ContactPerson" type="i6:documentReferenceField"/>
|
||||
</xs:sequence>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" name="ReferenceSubstance">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="Protection">
|
||||
<xs:complexType>
|
||||
<xs:complexContent>
|
||||
<xs:extension base="i6:baseDataProtectionField">
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="confidentiality" type="ct:N64"/>
|
||||
<xs:element minOccurs="0" name="justification" type="i6:textField"/>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="legislation">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="value" type="ct:N78"/>
|
||||
<xs:element minOccurs="0" name="other" type="i6:textFieldSmall"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:sequence>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" name="ReferenceSubstance" type="i6:documentReferenceField"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" name="TypeOfSubstance">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="Composition">
|
||||
<xs:complexType>
|
||||
<xs:complexContent>
|
||||
<xs:extension base="i6:basePicklistField">
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="value" type="ct:N08"/>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="other" type="i6:multilingualTextFieldSmall"/>
|
||||
</xs:sequence>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" name="Origin">
|
||||
<xs:complexType>
|
||||
<xs:complexContent>
|
||||
<xs:extension base="i6:basePicklistField">
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="value" type="ct:N58"/>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="other" type="i6:multilingualTextFieldSmall"/>
|
||||
</xs:sequence>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" name="RoleInSupplyChain">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="RoleProtection">
|
||||
<xs:complexType>
|
||||
<xs:complexContent>
|
||||
<xs:extension base="i6:baseDataProtectionField">
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="confidentiality" type="ct:N64"/>
|
||||
<xs:element minOccurs="0" name="justification" type="i6:textField"/>
|
||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="legislation">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element minOccurs="0" name="value" type="ct:N78"/>
|
||||
<xs:element minOccurs="0" name="other" type="i6:textFieldSmall"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:sequence>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" name="Manufacturer" nillable="true" type="i6:booleanField"/>
|
||||
<xs:element minOccurs="0" name="Importer" nillable="true" type="i6:booleanField"/>
|
||||
<xs:element minOccurs="0" name="OnlyRepresentative" nillable="true" type="i6:booleanField"/>
|
||||
<xs:element minOccurs="0" name="DownstreamUser" nillable="true" type="i6:booleanField"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:schema>
|
||||
24395
epiuclid/schemas/v10/domain/v10/commonTypesDomainV10.xsd
Normal file
24395
epiuclid/schemas/v10/domain/v10/commonTypesDomainV10.xsd
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
34308
epiuclid/schemas/v10/oecd/v10/commonTypesOecdV10.xsd
Normal file
34308
epiuclid/schemas/v10/oecd/v10/commonTypesOecdV10.xsd
Normal file
File diff suppressed because it is too large
Load Diff
73
epiuclid/schemas/v10/platform-attachment.xsd
Normal file
73
epiuclid/schemas/v10/platform-attachment.xsd
Normal file
@ -0,0 +1,73 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xs:schema xmlns="http://iuclid6.echa.europa.eu/namespaces/platform-attachment/v1"
|
||||
xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
targetNamespace="http://iuclid6.echa.europa.eu/namespaces/platform-attachment/v1"
|
||||
elementFormDefault="qualified" attributeFormDefault="qualified">
|
||||
<xs:annotation>
|
||||
<xs:appinfo>XML Schema Definition of the IUCLID6 Attachment entity</xs:appinfo>
|
||||
</xs:annotation>
|
||||
<xs:import namespace="http://www.w3.org/1999/xlink" schemaLocation="xlink.xsd"/>
|
||||
<xs:element name="Attachment">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Defines the attachment metadata information</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:complexType>
|
||||
<xs:all>
|
||||
<xs:element name="documentKey" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The unique identifier of the attachment</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element name="name" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The name of the uploaded attachment</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element name="creationDate" type="xs:dateTime">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The date that the attachment was created</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element name="lastModificationDate" type="xs:dateTime">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The last modification date of the attachment</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element name="remarks" type="xs:string" minOccurs="0">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The remarks provided by the user during the attachment uploading</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element name="md5" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The MD5 hash of the uploaded attachment content</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element name="mimetype" type="xs:string" minOccurs="0">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The media type of the attachment content</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element type="xs:boolean" name="symbolic" minOccurs="0">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Indicates that the actual attachment file is not included in the i6z file</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element name="content" minOccurs="0">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The name/location of the attachment binary under the "attachments" directory inside the i6z archive file</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:complexType>
|
||||
<xs:attribute ref="xlink:type"/>
|
||||
<xs:attribute ref="xlink:href"/>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:all>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element name="AttachmentRef" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Specifies the unique identifier of an attachment that is directly linked to a IUCLID6 document</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
</xs:schema>
|
||||
92
epiuclid/schemas/v10/platform-container-v2.xsd
Normal file
92
epiuclid/schemas/v10/platform-container-v2.xsd
Normal file
@ -0,0 +1,92 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xs:schema xmlns="http://iuclid6.echa.europa.eu/namespaces/platform-container/v2"
|
||||
xmlns:xs="http://www.w3.org/2001/XMLSchema"
|
||||
xmlns:pm="http://iuclid6.echa.europa.eu/namespaces/platform-metadata/v1"
|
||||
xmlns:att="http://iuclid6.echa.europa.eu/namespaces/platform-attachment/v1"
|
||||
xmlns:mh="http://iuclid6.echa.europa.eu/namespaces/platform-modification-history/v1"
|
||||
targetNamespace="http://iuclid6.echa.europa.eu/namespaces/platform-container/v2"
|
||||
elementFormDefault="qualified" attributeFormDefault="qualified">
|
||||
<xs:import namespace="http://iuclid6.echa.europa.eu/namespaces/platform-metadata/v1" schemaLocation="platform-metadata.xsd"/>
|
||||
<xs:import namespace="http://iuclid6.echa.europa.eu/namespaces/platform-attachment/v1" schemaLocation="platform-attachment.xsd"/>
|
||||
<xs:import namespace="http://iuclid6.echa.europa.eu/namespaces/platform-modification-history/v1" schemaLocation="platform-modification-history.xsd"/>
|
||||
<xs:annotation>
|
||||
<xs:appinfo>XML Schema Definition of the IUCLID6 Document entity</xs:appinfo>
|
||||
</xs:annotation>
|
||||
<xs:element name="Document">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Contains top-level information concerning the IUCLID6 document along with the document's actual chemical information content</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element name="PlatformMetadata">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Contains the top-level information of a IUCLID6 document such as document identifier, name, type and subtype, etc.
|
||||
<br/><br/>
|
||||
The elements are included in the platform-metadata.xsd</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:complexType >
|
||||
<xs:all minOccurs="0">
|
||||
<xs:element ref="pm:iuclidVersion" minOccurs="0" />
|
||||
<xs:element ref="pm:documentKey" minOccurs="0" />
|
||||
<xs:element ref="pm:documentType" />
|
||||
<xs:element ref="pm:definitionVersion" minOccurs="0" />
|
||||
<xs:element ref="pm:creationDate" />
|
||||
<xs:element ref="pm:lastModificationDate" />
|
||||
<xs:element ref="pm:name" minOccurs="0" />
|
||||
<xs:element ref="pm:documentSubType" minOccurs="0" />
|
||||
<xs:element ref="pm:parentDocumentKey" minOccurs="0" />
|
||||
<xs:element ref="pm:orderInSectionNo" minOccurs="0" />
|
||||
<xs:element ref="pm:submissionType" minOccurs="0" />
|
||||
<xs:element ref="pm:submissionTypeVersion" minOccurs="0" />
|
||||
<xs:element ref="pm:submittingLegalEntity" minOccurs="0" />
|
||||
<xs:element ref="pm:dossierSubject" minOccurs="0" />
|
||||
<xs:element ref="pm:i5Origin" minOccurs="0" />
|
||||
<xs:element ref="pm:creationTool" minOccurs="0" />
|
||||
<xs:element ref="pm:snapshotCreationTool" minOccurs="0" />
|
||||
</xs:all>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element name="Content">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Contains the chemical information of the specific IUCLID6 document.
|
||||
<br/><br/>
|
||||
The content is dynamic and is defined in the corresponding .xsd per document definition identifier.
|
||||
in the form of "document_type"-"document_subtype"-"version".xsd.
|
||||
Example: ENDPOINT_STUDY_RECORD-Density-4.0.xsd</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:any namespace="##other" processContents="strict" minOccurs="0"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element name="Attachments" nillable="true">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Lists the attachments that are directly linked to the document. The content of this section is an unbounded list of references to attachment identifiers that this document is linked to.
|
||||
<br/><br/>
|
||||
The elements are included in the platform-attachment.xsd</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:complexType>
|
||||
<xs:sequence minOccurs="0" maxOccurs="unbounded" >
|
||||
<xs:element ref="att:Attachment" minOccurs="0" />
|
||||
<xs:element ref="att:AttachmentRef" minOccurs="0" />
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element name="ModificationHistory" nillable="true">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Lists the entries of the document's modification history. Every entry is a single operation that took place on the specific document and specifies the date of the action, the user that run the action, the submitting legal entity of the user and the modification remarks if any.
|
||||
|
||||
<br/><br/>
|
||||
The elements are included in the platform-modification-history.xsd</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:complexType>
|
||||
<xs:sequence minOccurs="0" maxOccurs="unbounded">
|
||||
<xs:element ref="mh:Modification" minOccurs="0" />
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:schema>
|
||||
611
epiuclid/schemas/v10/platform-fields.xsd
Normal file
611
epiuclid/schemas/v10/platform-fields.xsd
Normal file
@ -0,0 +1,611 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xs:schema xmlns="http://iuclid6.echa.europa.eu/namespaces/platform-fields/v1"
|
||||
xmlns:xs="http://www.w3.org/2001/XMLSchema"
|
||||
targetNamespace="http://iuclid6.echa.europa.eu/namespaces/platform-fields/v1"
|
||||
elementFormDefault="qualified" attributeFormDefault="qualified">
|
||||
|
||||
<xs:annotation>
|
||||
<xs:appinfo>XML Schema Definition of the main IUCLID6 data types</xs:appinfo>
|
||||
</xs:annotation>
|
||||
<xs:import namespace="http://www.w3.org/XML/1998/namespace" schemaLocation="xml.xsd"/>
|
||||
|
||||
<xs:complexType name="sectionTypesField">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Specifies the content of the section types field under Category document</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="documentDefinitionIdentifier" minOccurs="0" maxOccurs="unbounded">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element name="documentType" type="xs:string"/>
|
||||
<xs:element name="documentSubType" type="xs:string"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="inventoryEntry">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Specifies the content of the chemical inventory field</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="inventoryCode" type="xs:string"/>
|
||||
<xs:element name="numberInInventory" type="xs:string"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="addressField">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Contains the elements constituting the AddressField type</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="city" type="xs:string"/>
|
||||
<xs:element name="country" type="picklistField"/>
|
||||
<xs:element name="email" type="xs:string"/>
|
||||
<xs:element name="fax" type="xs:string"/>
|
||||
<xs:element name="phone" type="xs:string"/>
|
||||
<xs:element name="state" type="xs:string"/>
|
||||
<xs:element name="street1" type="xs:string"/>
|
||||
<xs:element name="street2" type="xs:string"/>
|
||||
<xs:element name="website" type="xs:string"/>
|
||||
<xs:element name="zipcode" type="xs:string"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:simpleType name="attachmentField">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Holds the key of the attachment content attached to the specific field</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:restriction base="xs:string"/>
|
||||
</xs:simpleType>
|
||||
|
||||
<xs:complexType name="attachmentListField">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Holds the list of the attachment content identifiers/keys attached to the specific field</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="key" type="xs:string" minOccurs="0" maxOccurs="unbounded"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:simpleType name="booleanField">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The value of IUCLID6 boolean fields</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:restriction base="xs:boolean"/>
|
||||
</xs:simpleType>
|
||||
|
||||
<xs:complexType name="legislation">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Elements that constitute the regulatory programme legislation information of a data protection field</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="value" type="xs:string"/>
|
||||
<xs:element name="other" minOccurs="0" maxOccurs="1" type="textFieldSmall"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="multilingualLegislation">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The multilingual version of the legislation type</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="value" type="xs:string"/>
|
||||
<xs:element name="other" minOccurs="0" maxOccurs="unbounded"
|
||||
type="multilingualTextFieldSmall"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="dataProtectionField">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The elements constituting the data protection field type</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="confidentiality" type="xs:string"/>
|
||||
<xs:element name="justification" type="textField"/>
|
||||
<xs:element name="legislation" minOccurs="0" maxOccurs="unbounded" type="legislation"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="multilingualDataProtectionField">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The multilingual version of the data protection field type</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="confidentiality" type="xs:string"/>
|
||||
<xs:element name="justification" maxOccurs="unbounded" type="multilingualTextField"/>
|
||||
<xs:element name="legislation" minOccurs="0" maxOccurs="unbounded"
|
||||
type="multilingualLegislation"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:simpleType name="dateField">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The value of IUCLID6 date/timestamp fields</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:restriction base="xs:dateTime"/>
|
||||
</xs:simpleType>
|
||||
|
||||
<xs:simpleType name="documentReferenceField">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Holds the key of the IUCLID6 document that is referenced by the specific field</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:restriction base="xs:string"/>
|
||||
</xs:simpleType>
|
||||
|
||||
<xs:complexType name="documentReferenceMultipleField">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Multilingual version of the document reference field</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="key" type="xs:string" minOccurs="0" maxOccurs="unbounded"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:simpleType name="numericField">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The value of IUCLID6 numeric fields</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:restriction base="xs:decimal"/>
|
||||
</xs:simpleType>
|
||||
|
||||
<xs:complexType name="physicalQuantityField">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Specifies the elements constituting the IUCLID6 physical quantity fields</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence/>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:simpleType name="lowerQualifier">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Restricts the eligible values of the "lowerQualifier" element</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:enumeration value=">"/>
|
||||
<xs:enumeration value=">="/>
|
||||
<xs:enumeration value="ca."/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
|
||||
<xs:simpleType name="upperQualifier">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Restricts the eligible values of the "upperQualifier" element</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:enumeration value="<"/>
|
||||
<xs:enumeration value="<="/>
|
||||
<xs:enumeration value="ca."/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
|
||||
<xs:simpleType name="halfBoundedQualifier">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Restricts the eligible values of the "halfBoundedQualifier" element</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:enumeration value=">"/>
|
||||
<xs:enumeration value=">="/>
|
||||
<xs:enumeration value="<"/>
|
||||
<xs:enumeration value="<="/>
|
||||
<xs:enumeration value="ca."/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
|
||||
<xs:group name="rangeQualifierDecimalGroup">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Groups the qualifiers along with the decimal values of the physical quantity range field</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="lowerQualifier" minOccurs="0" type="lowerQualifier"/>
|
||||
<xs:element name="upperQualifier" minOccurs="0" type="upperQualifier"/>
|
||||
<xs:element name="lowerValue" type="xs:decimal" minOccurs="0"/>
|
||||
<xs:element name="upperValue" type="xs:decimal" minOccurs="0"/>
|
||||
</xs:sequence>
|
||||
</xs:group>
|
||||
|
||||
<xs:group name="rangeQualifierIntegerGroup">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Groups the qualifiers along with the integer values of the physical quantity range field</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="lowerQualifier" minOccurs="0" type="lowerQualifier"/>
|
||||
<xs:element name="upperQualifier" minOccurs="0" type="upperQualifier"/>
|
||||
<xs:element name="lowerValue" type="xs:integer" minOccurs="0"/>
|
||||
<xs:element name="upperValue" type="xs:integer" minOccurs="0"/>
|
||||
</xs:sequence>
|
||||
</xs:group>
|
||||
|
||||
<xs:group name="halfBoundedRangeQualifierDecimalGroup">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Groups the qualifier along with the decimal value of the half bounded physical quantity range field</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="qualifier" minOccurs="0" type="halfBoundedQualifier"/>
|
||||
<xs:element name="value" type="xs:decimal" minOccurs="0"/>
|
||||
</xs:sequence>
|
||||
</xs:group>
|
||||
|
||||
<xs:group name="halfBoundedRangeQualifierIntegerGroup">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Groups the qualifier along with the integer value of the half bounded physical quantity range field</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="qualifier" minOccurs="0" type="halfBoundedQualifier"/>
|
||||
<xs:element name="value" type="xs:integer" minOccurs="0"/>
|
||||
</xs:sequence>
|
||||
</xs:group>
|
||||
|
||||
<xs:complexType name="physicalQuantityRangeField">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Lists the elements constituting the decimal physical quantity range field</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="unitCode" minOccurs="0" type="textFieldSmall"/>
|
||||
<xs:element name="unitOther" minOccurs="0" type="textFieldSmall"/>
|
||||
<xs:group ref="rangeQualifierDecimalGroup"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="multilingualPhysicalQuantityRangeField">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The multilingual version of the physical quantity range field</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="unitCode" minOccurs="0" type="textFieldSmall"/>
|
||||
<xs:element name="unitOther" minOccurs="0" maxOccurs="unbounded"
|
||||
type="multilingualTextFieldSmall"/>
|
||||
<xs:group ref="rangeQualifierDecimalGroup"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="physicalQuantityIntegerRangeField">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Lists the elements constituting the integer physical quantity range field</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="unitCode" minOccurs="0" type="textFieldSmall"/>
|
||||
<xs:element name="unitOther" minOccurs="0" type="textFieldSmall"/>
|
||||
<xs:group ref="rangeQualifierIntegerGroup"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="multilingualPhysicalQuantityIntegerRangeField">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The multilingual version of the physical quantity range field with integer value</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="unitCode" minOccurs="0" type="textFieldSmall"/>
|
||||
<xs:element name="unitOther" minOccurs="0" maxOccurs="unbounded"
|
||||
type="multilingualTextFieldSmall"/>
|
||||
<xs:group ref="rangeQualifierIntegerGroup"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="physicalQuantityHalfBoundedField">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Lists the elements constituting the decimal, hald-bounded physical quantity range field</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="unitCode" minOccurs="0" type="textFieldSmall"/>
|
||||
<xs:element name="unitOther" minOccurs="0" type="textFieldSmall"/>
|
||||
<xs:group ref="halfBoundedRangeQualifierDecimalGroup"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="multilingualPhysicalQuantityHalfBoundedField">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The multilingual version of the half bounded physical quantity range field</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="unitCode" minOccurs="0" type="textFieldSmall"/>
|
||||
<xs:element name="unitOther" minOccurs="0" maxOccurs="unbounded"
|
||||
type="multilingualTextFieldSmall"/>
|
||||
<xs:group ref="halfBoundedRangeQualifierDecimalGroup"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="physicalQuantityIntegerHalfBoundedField">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Lists the elements constituting the integer, half bounded physical quantity range field</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="unitCode" minOccurs="0" type="textFieldSmall"/>
|
||||
<xs:element name="unitOther" minOccurs="0" type="textFieldSmall"/>
|
||||
<xs:group ref="halfBoundedRangeQualifierIntegerGroup"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="multilingualPhysicalQuantityIntegerHalfBoundedField">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The multilingual version of the half bounded physical quantity range field with integer value</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="unitCode" minOccurs="0" type="textFieldSmall"/>
|
||||
<xs:element name="unitOther" minOccurs="0" maxOccurs="unbounded"
|
||||
type="multilingualTextFieldSmall"/>
|
||||
<xs:group ref="halfBoundedRangeQualifierIntegerGroup"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:simpleType name="textField">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Indicates that a field holds textual content</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:restriction base="xs:string"/>
|
||||
</xs:simpleType>
|
||||
|
||||
<xs:complexType name="multilingualTextField">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Indicates that a field holds multilingual textual content</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:simpleContent>
|
||||
<xs:extension base="textField">
|
||||
<xs:attribute ref="xml:lang" use="optional"/>
|
||||
</xs:extension>
|
||||
</xs:simpleContent>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:simpleType name="textFieldSmall">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Indicates that a field holds textual content with a maximum of 255 characters</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:maxLength value="255"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
|
||||
<xs:complexType name="multilingualTextFieldSmall">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Indicates that a field holds multilingual textual content with a maximum of 255 characters</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:simpleContent>
|
||||
<xs:extension base="textFieldSmall">
|
||||
<xs:attribute ref="xml:lang" use="optional"/>
|
||||
</xs:extension>
|
||||
</xs:simpleContent>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:simpleType name="textFieldLarge">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Indicates that a field holds textual content with a maximum of 32768 characters</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:maxLength value="32768"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
|
||||
<xs:complexType name="multilingualTextFieldLarge">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Indicates that a field holds multilingual textual content with a maximum of 32768 characters</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:simpleContent>
|
||||
<xs:extension base="textFieldLarge">
|
||||
<xs:attribute ref="xml:lang" use="optional"/>
|
||||
</xs:extension>
|
||||
</xs:simpleContent>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:simpleType name="textFieldMultiLine">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Indicates that a field holds textual content with a maximum of 2000 characters</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:maxLength value="2000"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
|
||||
<xs:complexType name="multilingualTextFieldMultiLine">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Indicates that a field holds multilingual textual content with a maximum of 2000 characters</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:simpleContent>
|
||||
<xs:extension base="textFieldMultiLine">
|
||||
<xs:attribute ref="xml:lang" use="optional"/>
|
||||
</xs:extension>
|
||||
</xs:simpleContent>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="picklistField">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Lists the elements (phrase code and other text) constituting the IUCLID6 picklist field</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="value" minOccurs="0" type="textFieldSmall"/>
|
||||
<xs:element name="other" minOccurs="0" type="textFieldSmall"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="multilingualPicklistField">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The multilingual version of the picklist field</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="value" minOccurs="0" type="textFieldSmall"/>
|
||||
<xs:element name="other" minOccurs="0" maxOccurs="unbounded"
|
||||
type="multilingualTextFieldSmall"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="picklistFieldWithSmallTextRemarks">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Lists the elements (phrase code, other text and remarks) constituting the IUCLID6 picklist field - remarks information can be up to 255 characters</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="value" minOccurs="0" type="textFieldSmall"/>
|
||||
<xs:element name="other" minOccurs="0" type="textFieldSmall"/>
|
||||
<xs:element name="remarks" minOccurs="0" type="textFieldSmall"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="multilingualPicklistFieldWithSmallTextRemarks">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The multilingual version of the picklist field including remarks information up to 255 characters</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="value" minOccurs="0" type="textFieldSmall"/>
|
||||
<xs:element name="other" minOccurs="0" maxOccurs="unbounded"
|
||||
type="multilingualTextFieldSmall"/>
|
||||
<xs:element name="remarks" minOccurs="0" maxOccurs="unbounded"
|
||||
type="multilingualTextFieldSmall"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="picklistFieldWithLargeTextRemarks">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Lists the elements (phrase code, other text and remarks) constituting the IUCLID6 picklist field - remarks information can be up to 32768 characters</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="value" minOccurs="0" type="textFieldSmall"/>
|
||||
<xs:element name="other" minOccurs="0" type="textFieldSmall"/>
|
||||
<xs:element name="remarks" minOccurs="0" type="textFieldLarge"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="multilingualPicklistFieldWithLargeTextRemarks">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The multilingual version of the picklist field including remarks information up to 32768 characters</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="value" minOccurs="0" type="textFieldSmall"/>
|
||||
<xs:element name="other" minOccurs="0" maxOccurs="unbounded"
|
||||
type="multilingualTextFieldSmall"/>
|
||||
<xs:element name="remarks" minOccurs="0" maxOccurs="unbounded"
|
||||
type="multilingualTextFieldLarge"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="picklistFieldWithMultiLineTextRemarks">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Lists the elements (phrase code, other text and remarks) constituting the IUCLID6 picklist field - remarks information can be up to 2000 characters</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="value" minOccurs="0" type="textFieldSmall"/>
|
||||
<xs:element name="other" minOccurs="0" type="textFieldSmall"/>
|
||||
<xs:element name="remarks" minOccurs="0" type="textFieldMultiLine"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="multilingualPicklistFieldWithMultiLineTextRemarks">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The multilingual version of the picklist field including remarks information up to 2000 characters</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="value" minOccurs="0" type="textFieldSmall"/>
|
||||
<xs:element name="other" minOccurs="0" maxOccurs="unbounded"
|
||||
type="multilingualTextFieldSmall"/>
|
||||
<xs:element name="remarks" minOccurs="0" maxOccurs="unbounded"
|
||||
type="multilingualTextFieldMultiLine"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="repeatableEntryType">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Specifies the multiplicity and attribute of a repeatable block</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence/>
|
||||
<xs:attribute name="uuid" type="uuidAttribute" use="required"/>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:simpleType name="uuidAttribute">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Attribute used to hold unique identifier information</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:restriction base="xs:string"/>
|
||||
</xs:simpleType>
|
||||
|
||||
<xs:complexType name="basePicklistField">
|
||||
<xs:annotation>
|
||||
<xs:documentation>An empty complex type that is extended by the picklist fields which are defined inline in the auto-generated document xsds.
|
||||
<br/><br/>
|
||||
The picklist fields contain the following elements:
|
||||
<ul>
|
||||
<li>value</li>
|
||||
<li>other</li>
|
||||
<li>remarks</li>
|
||||
</ul>
|
||||
<br/><br/>
|
||||
The inline definition of the fields take place in order to:
|
||||
<ul>
|
||||
<li>restrict the eligible phrase codes per picklist field</li>
|
||||
<li>conditionally define or omit the "other" element based on the configured phrasegroup (open, close)</li>
|
||||
<li>based on the picklist definition, properly define the multilingual behavior of the textual elements "other" and "remarks" elements </li>
|
||||
<li>based on the picklist definition, properly define the length restriction of the "remarks" elements </li>
|
||||
</ul></xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence/>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="basePhysicalQuantityField">
|
||||
<xs:annotation>
|
||||
<xs:documentation>An empty complex type that is extended by the physical quantity fields which are defined inline in the auto-generated document xsds.
|
||||
<br/><br/>
|
||||
The physical quantity fields contain the following elements:
|
||||
<ul>
|
||||
<li>unitCode</li>
|
||||
<li>unitOther</li>
|
||||
<li>value</li>
|
||||
</ul>
|
||||
<br/><br/>
|
||||
The inline definition of the fields take place in order to:
|
||||
<ul>
|
||||
<li>restrict the eligible phrase codes for the "unitCode" element</li>
|
||||
<li>conditionally define or omit the "unitOther" element based on the configured phrasegroup (open, close)</li>
|
||||
<li>based on the field definition, properly define the multilingual behavior of the textual "unitOther" element</li>
|
||||
</ul></xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence/>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="basePhysicalQuantityRangeField">
|
||||
<xs:annotation>
|
||||
<xs:documentation>An empty complex type that is extended by the physical quantity range fields which are defined inline in the auto-generated document xsds.
|
||||
<br/><br/>
|
||||
The physical quantity range fields contain the following elements:
|
||||
<ul>
|
||||
<li>unitCode</li>
|
||||
<li>unitOther</li>
|
||||
<li>lowerQualifier</li>
|
||||
<li>upperQualifier</li>
|
||||
<li>lowerValue</li>
|
||||
<li>upperValue</li>
|
||||
<li>qualifier: in case of half-bounded</li>
|
||||
<li>value: in case of half-bounded</li>
|
||||
</ul>
|
||||
<br/><br/>
|
||||
The inline definition of the fields take place in order to:
|
||||
<ul>
|
||||
<li>restrict the eligible phrase codes for the "unitCode" element</li>
|
||||
<li>conditionally define or omit the "unitOther" element based on the configured phrasegroup (open, close)</li>
|
||||
<li>based on the field definition, properly define the multilingual behavior of the textual "unitOther" element</li>
|
||||
<li>based on the field definition, dynamically setup the bounded- or half-boudnded-related elements</li>
|
||||
</ul></xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence/>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="baseDataProtectionField">
|
||||
<xs:annotation>
|
||||
<xs:documentation>An empty complex type that is extended by the data protection fields which are defined inline in the auto-generated document xsds.
|
||||
<br/><br/>
|
||||
The data protection fields contain the following elements:
|
||||
<ul>
|
||||
<li>confidentiality</li>
|
||||
<li>justification</li>
|
||||
<li>legislation</li>
|
||||
<ul>
|
||||
<li>value</li>
|
||||
<li>other</li>
|
||||
</ul>
|
||||
</ul>
|
||||
<br/><br/>
|
||||
The inline definition of the fields take place in order to:
|
||||
<ul>
|
||||
<li>restrict the eligible phrase codes for the "confidentiality" and "value" element</li>
|
||||
<li>based on the field definition, properly define the multilingual behavior of the textual "justification" and "other" elements</li>
|
||||
</ul> </xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence/>
|
||||
</xs:complexType>
|
||||
|
||||
</xs:schema>
|
||||
138
epiuclid/schemas/v10/platform-metadata.xsd
Normal file
138
epiuclid/schemas/v10/platform-metadata.xsd
Normal file
@ -0,0 +1,138 @@
|
||||
<xs:schema xmlns="http://iuclid6.echa.europa.eu/namespaces/platform-metadata/v1"
|
||||
xmlns:xs="http://www.w3.org/2001/XMLSchema"
|
||||
targetNamespace="http://iuclid6.echa.europa.eu/namespaces/platform-metadata/v1"
|
||||
elementFormDefault="qualified" attributeFormDefault="qualified">
|
||||
<xs:annotation>
|
||||
<xs:appinfo>XML Schema Definition of the "PlatformMetadata" section</xs:appinfo>
|
||||
</xs:annotation>
|
||||
<xs:element name="iuclidVersion" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The current iuclid version used for exporting the .i6z archive</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element name="documentKey" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The unique identifier of the document</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element name="documentType" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The type of the document. Eligible values are:
|
||||
<ul>
|
||||
<li>ANNOTATION</li>
|
||||
<li>ARTICLE</li>
|
||||
<li>CATEGORY</li>
|
||||
<li>DOSSIER</li>
|
||||
<li>FIXED_RECORD</li>
|
||||
<li>FLEXIBLE_RECORD</li>
|
||||
<li>ENDPOINT_STUDY_RECORD</li>
|
||||
<li>FLEXIBLE_SUMMARY</li>
|
||||
<li>ENDPOINT_SUMMARY</li>
|
||||
<li>ASSESSMENT_ENTITY</li>
|
||||
<li>LEGAL_ENTITY</li>
|
||||
<li>MIXTURE</li>
|
||||
<li>REFERENCE_SUBSTANCE</li>
|
||||
<li>SITE</li>
|
||||
<li>CONTACT</li>
|
||||
<li>LITERATURE</li>
|
||||
<li>SUBSTANCE</li>
|
||||
<li>TEMPLATE</li>
|
||||
<li>TEST_MATERIAL_INFORMATION</li>
|
||||
<li>INVENTORY</li>
|
||||
<li>CUSTOM_ENTITY</li>
|
||||
<li>CUSTOM_SECTION</li>
|
||||
</ul></xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element name="definitionVersion" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The definition version of the exported document. This value is used:
|
||||
<ul>
|
||||
<li>indicates that the content section follows the document format of the specified version</li>
|
||||
<li>during import operation, this value drives the resolution of the proper document's .xsd to run the validation with</li>
|
||||
</ul></xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element name="creationDate" type="xs:dateTime">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The date that the document was created</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element name="lastModificationDate" type="xs:dateTime">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The last modification date of the document</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element name="name" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>It is the name of the document as specified by the user.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element name="documentSubType" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The subtype in case of section document. This information is not applicable to entity documents. "type"."subtype" uniquely identify the section document type and represent the document definition identifier</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element name="parentDocumentKey" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>In case this document is a section document, this element keeps the unique identifier of its parent document</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element name="orderInSectionNo">
|
||||
<xs:annotation>
|
||||
<xs:documentation>In case this is a section document, the order of the document with the specific definition identifier (type, subtype) under the provided parent entity</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:simpleType>
|
||||
<xs:union>
|
||||
<xs:simpleType>
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:length value="0"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
<xs:simpleType>
|
||||
<xs:restriction base="xs:nonNegativeInteger"/>
|
||||
</xs:simpleType>
|
||||
</xs:union>
|
||||
</xs:simpleType>
|
||||
</xs:element>
|
||||
<xs:element name="submissionType" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Applicable only to dossier archives. Indicates the submission type used during dossier generation. The value is specified in case the XML concerns:
|
||||
<ul>
|
||||
<li>the dossier document</li>
|
||||
<li>the composite documents (SUBSTANCE/MIXTURE) under the dossier with a submission type different than the one of the dossier document</li>
|
||||
</ul></xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element name="submissionTypeVersion" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The version of the submission type used to generate the dossier for</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element name="submittingLegalEntity" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The legal entity document identifier that originated toe dossier creation</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element name="dossierSubject" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>In case this is the dossier document, it contains the document key (unique identifier)
|
||||
of the dossier subject document (SUBSTANCE, MIXTURE, CATEGORY, ARTICLE) which is the document the dossier was created from</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element name="i5Origin" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Flag indicating whether this document originated from a IUCLID5 instance and migrated to the current IUCLID6 format or not</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element name="creationTool" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Element that specifies the application this document was first created with. Default value "IUC6" should be provided for IUCLID6-documents</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element name="snapshotCreationTool" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>In case of dossier archive, element that specifies the application this dossier was created from. Upon dossier creation this is filled in with "IUC6"</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
</xs:schema>
|
||||
39
epiuclid/schemas/v10/platform-modification-history.xsd
Normal file
39
epiuclid/schemas/v10/platform-modification-history.xsd
Normal file
@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xs:schema xmlns="http://iuclid6.echa.europa.eu/namespaces/platform-modification-history/v1"
|
||||
xmlns:xs="http://www.w3.org/2001/XMLSchema"
|
||||
targetNamespace="http://iuclid6.echa.europa.eu/namespaces/platform-modification-history/v1"
|
||||
elementFormDefault="qualified" attributeFormDefault="qualified">
|
||||
<xs:annotation>
|
||||
<xs:appinfo>XML Schema Definition of the "ModificationHistory" section</xs:appinfo>
|
||||
<xs:documentation>This section lists the entries of the document's modification history. Every entry is a single operation that took place on the specific document and specifies the date of the action, the user that run the action, the submitting legal entity of the user and the modification remarks if any</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:element name="Modification">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Holds the information concerning the document's modification. Every entry is a single operation that took place on the specific document and specifies the date of the action, the user that run the action, the submitting legal entity of the user and the modification remarks if any</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:complexType>
|
||||
<xs:all>
|
||||
<xs:element name="Date" type="xs:dateTime">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The date the action was performed on the document</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element name="Author" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The userName of the user that performed the modification</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element name="LegalEntity" type="xs:string" minOccurs="0">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The description of the submitting legal entity of the user. This information contains the concatenated value of the LE name, city and localized country information</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element name="Remarks" type="xs:string" minOccurs="0">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The modification comment</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
</xs:all>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:schema>
|
||||
231
epiuclid/schemas/v10/xlink.xsd
Normal file
231
epiuclid/schemas/v10/xlink.xsd
Normal file
@ -0,0 +1,231 @@
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink" targetNamespace="http://www.w3.org/1999/xlink">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
This schema document provides attribute declarations and attribute
|
||||
group, complex type and simple type definitions which can be used in
|
||||
the construction of user schemas to define the structure of
|
||||
particular linking constructs, e.g.
|
||||
<![CDATA[
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
|
||||
xmlns:xl="http://www.w3.org/1999/xlink">
|
||||
|
||||
<xs:import namespace="http://www.w3.org/1999/xlink"
|
||||
location="http://www.w3.org/1999/xlink.xsd">
|
||||
|
||||
<xs:element name="mySimple">
|
||||
<xs:complexType>
|
||||
...
|
||||
<xs:attributeGroup ref="xl:simpleAttrs"/>
|
||||
...
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
...
|
||||
</xs:schema>
|
||||
]]>
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:import namespace="http://www.w3.org/XML/1998/namespace"
|
||||
schemaLocation="xml.xsd" />
|
||||
<xs:attribute name="type" type="xlink:typeType" />
|
||||
<xs:simpleType name="typeType">
|
||||
<xs:restriction base="xs:token">
|
||||
<xs:enumeration value="simple" />
|
||||
<xs:enumeration value="extended" />
|
||||
<xs:enumeration value="title" />
|
||||
<xs:enumeration value="resource" />
|
||||
<xs:enumeration value="locator" />
|
||||
<xs:enumeration value="arc" />
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
<xs:attribute name="href" type="xlink:hrefType" />
|
||||
<xs:simpleType name="hrefType">
|
||||
<xs:restriction base="xs:anyURI" />
|
||||
</xs:simpleType>
|
||||
<xs:attribute name="role" type="xlink:roleType" />
|
||||
<xs:simpleType name="roleType">
|
||||
<xs:restriction base="xs:anyURI">
|
||||
<xs:minLength value="1" />
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
<xs:attribute name="arcrole" type="xlink:arcroleType" />
|
||||
<xs:simpleType name="arcroleType">
|
||||
<xs:restriction base="xs:anyURI">
|
||||
<xs:minLength value="1" />
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
<xs:attribute name="title" type="xlink:titleAttrType" />
|
||||
<xs:simpleType name="titleAttrType">
|
||||
<xs:restriction base="xs:string" />
|
||||
</xs:simpleType>
|
||||
<xs:attribute name="show" type="xlink:showType" />
|
||||
<xs:simpleType name="showType">
|
||||
<xs:restriction base="xs:token">
|
||||
<xs:enumeration value="new" />
|
||||
<xs:enumeration value="replace" />
|
||||
<xs:enumeration value="embed" />
|
||||
<xs:enumeration value="other" />
|
||||
<xs:enumeration value="none" />
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
<xs:attribute name="actuate" type="xlink:actuateType" />
|
||||
<xs:simpleType name="actuateType">
|
||||
<xs:restriction base="xs:token">
|
||||
<xs:enumeration value="onLoad" />
|
||||
<xs:enumeration value="onRequest" />
|
||||
<xs:enumeration value="other" />
|
||||
<xs:enumeration value="none" />
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
<xs:attribute name="label" type="xlink:labelType" />
|
||||
<xs:simpleType name="labelType">
|
||||
<xs:restriction base="xs:NCName" />
|
||||
</xs:simpleType>
|
||||
<xs:attribute name="from" type="xlink:fromType" />
|
||||
<xs:simpleType name="fromType">
|
||||
<xs:restriction base="xs:NCName" />
|
||||
</xs:simpleType>
|
||||
<xs:attribute name="to" type="xlink:toType" />
|
||||
<xs:simpleType name="toType">
|
||||
<xs:restriction base="xs:NCName" />
|
||||
</xs:simpleType>
|
||||
<xs:attributeGroup name="simpleAttrs">
|
||||
<xs:attribute ref="xlink:type" fixed="simple" />
|
||||
<xs:attribute ref="xlink:href" />
|
||||
<xs:attribute ref="xlink:role" />
|
||||
<xs:attribute ref="xlink:arcrole" />
|
||||
<xs:attribute ref="xlink:title" />
|
||||
<xs:attribute ref="xlink:show" />
|
||||
<xs:attribute ref="xlink:actuate" />
|
||||
</xs:attributeGroup>
|
||||
<xs:group name="simpleModel">
|
||||
<xs:sequence>
|
||||
<xs:any processContents="lax" minOccurs="0" maxOccurs="unbounded" />
|
||||
</xs:sequence>
|
||||
</xs:group>
|
||||
<xs:complexType mixed="true" name="simple">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
Intended for use as the type of user-declared elements to make them simple
|
||||
links.
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:group ref="xlink:simpleModel" />
|
||||
<xs:attributeGroup ref="xlink:simpleAttrs" />
|
||||
</xs:complexType>
|
||||
<xs:attributeGroup name="extendedAttrs">
|
||||
<xs:attribute ref="xlink:type" fixed="extended" use="required" />
|
||||
<xs:attribute ref="xlink:role" />
|
||||
<xs:attribute ref="xlink:title" />
|
||||
</xs:attributeGroup>
|
||||
<xs:group name="extendedModel">
|
||||
<xs:choice>
|
||||
<xs:element ref="xlink:title" />
|
||||
<xs:element ref="xlink:resource" />
|
||||
<xs:element ref="xlink:locator" />
|
||||
<xs:element ref="xlink:arc" />
|
||||
</xs:choice>
|
||||
</xs:group>
|
||||
<xs:complexType name="extended">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
Intended for use as the type of user-declared elements to make them extended
|
||||
links. Note that the elements referenced in the content model are
|
||||
all abstract. The intention is that by simply declaring elements
|
||||
with these as their substitutionGroup, all the right things will
|
||||
happen.
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:group ref="xlink:extendedModel" minOccurs="0" maxOccurs="unbounded" />
|
||||
<xs:attributeGroup ref="xlink:extendedAttrs" />
|
||||
</xs:complexType>
|
||||
<xs:element name="title" type="xlink:titleEltType" abstract="true" />
|
||||
<xs:attributeGroup name="titleAttrs">
|
||||
<xs:attribute ref="xlink:type" fixed="title" use="required" />
|
||||
<xs:attribute ref="xml:lang">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
xml:lang is not required, but provides much of the motivation for title
|
||||
elements in addition to attributes, and so is provided here for
|
||||
convenience.
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:attributeGroup>
|
||||
<xs:group name="titleModel">
|
||||
<xs:sequence>
|
||||
<xs:any processContents="lax" minOccurs="0" maxOccurs="unbounded" />
|
||||
</xs:sequence>
|
||||
</xs:group>
|
||||
<xs:complexType mixed="true" name="titleEltType">
|
||||
<xs:group ref="xlink:titleModel" />
|
||||
<xs:attributeGroup ref="xlink:titleAttrs" />
|
||||
</xs:complexType>
|
||||
<xs:element name="resource" type="xlink:resourceType"
|
||||
abstract="true" />
|
||||
<xs:attributeGroup name="resourceAttrs">
|
||||
<xs:attribute ref="xlink:type" fixed="resource" use="required" />
|
||||
<xs:attribute ref="xlink:role" />
|
||||
<xs:attribute ref="xlink:title" />
|
||||
<xs:attribute ref="xlink:label" />
|
||||
</xs:attributeGroup>
|
||||
<xs:group name="resourceModel">
|
||||
<xs:sequence>
|
||||
<xs:any processContents="lax" minOccurs="0" maxOccurs="unbounded" />
|
||||
</xs:sequence>
|
||||
</xs:group>
|
||||
<xs:complexType mixed="true" name="resourceType">
|
||||
<xs:group ref="xlink:resourceModel" />
|
||||
<xs:attributeGroup ref="xlink:resourceAttrs" />
|
||||
</xs:complexType>
|
||||
<xs:element name="locator" type="xlink:locatorType"
|
||||
abstract="true" />
|
||||
<xs:attributeGroup name="locatorAttrs">
|
||||
<xs:attribute ref="xlink:type" fixed="locator" use="required" />
|
||||
<xs:attribute ref="xlink:href" use="required" />
|
||||
<xs:attribute ref="xlink:role" />
|
||||
<xs:attribute ref="xlink:title" />
|
||||
<xs:attribute ref="xlink:label">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
label is not required, but locators have no particular XLink function if
|
||||
they are not labeled.
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:attributeGroup>
|
||||
<xs:group name="locatorModel">
|
||||
<xs:sequence>
|
||||
<xs:element ref="xlink:title" minOccurs="0" maxOccurs="unbounded" />
|
||||
</xs:sequence>
|
||||
</xs:group>
|
||||
<xs:complexType name="locatorType">
|
||||
<xs:group ref="xlink:locatorModel" />
|
||||
<xs:attributeGroup ref="xlink:locatorAttrs" />
|
||||
</xs:complexType>
|
||||
<xs:element name="arc" type="xlink:arcType" abstract="true" />
|
||||
<xs:attributeGroup name="arcAttrs">
|
||||
<xs:attribute ref="xlink:type" fixed="arc" use="required" />
|
||||
<xs:attribute ref="xlink:arcrole" />
|
||||
<xs:attribute ref="xlink:title" />
|
||||
<xs:attribute ref="xlink:show" />
|
||||
<xs:attribute ref="xlink:actuate" />
|
||||
<xs:attribute ref="xlink:from" />
|
||||
<xs:attribute ref="xlink:to">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
from and to have default behavior when values are missing
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:attributeGroup>
|
||||
<xs:group name="arcModel">
|
||||
<xs:sequence>
|
||||
<xs:element ref="xlink:title" minOccurs="0" maxOccurs="unbounded" />
|
||||
</xs:sequence>
|
||||
</xs:group>
|
||||
<xs:complexType name="arcType">
|
||||
<xs:group ref="xlink:arcModel" />
|
||||
<xs:attributeGroup ref="xlink:arcAttrs" />
|
||||
</xs:complexType>
|
||||
</xs:schema>
|
||||
145
epiuclid/schemas/v10/xml.xsd
Normal file
145
epiuclid/schemas/v10/xml.xsd
Normal file
@ -0,0 +1,145 @@
|
||||
<?xml version='1.0'?>
|
||||
<xs:schema targetNamespace="http://www.w3.org/XML/1998/namespace" xmlns:xs="http://www.w3.org/2001/XMLSchema" xml:lang="en">
|
||||
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
See http://www.w3.org/XML/1998/namespace.html and
|
||||
http://www.w3.org/TR/REC-xml for information about this namespace.
|
||||
|
||||
This schema document describes the XML namespace, in a form
|
||||
suitable for import by other schema documents.
|
||||
|
||||
Note that local names in this namespace are intended to be defined
|
||||
only by the World Wide Web Consortium or its subgroups. The
|
||||
following names are currently defined in this namespace and should
|
||||
not be used with conflicting semantics by any Working Group,
|
||||
specification, or document instance:
|
||||
|
||||
base (as an attribute name): denotes an attribute whose value
|
||||
provides a URI to be used as the base for interpreting any
|
||||
relative URIs in the scope of the element on which it
|
||||
appears; its value is inherited. This name is reserved
|
||||
by virtue of its definition in the XML Base specification.
|
||||
|
||||
id (as an attribute name): denotes an attribute whose value
|
||||
should be interpreted as if declared to be of type ID.
|
||||
This name is reserved by virtue of its definition in the
|
||||
xml:id specification.
|
||||
|
||||
lang (as an attribute name): denotes an attribute whose value
|
||||
is a language code for the natural language of the content of
|
||||
any element; its value is inherited. This name is reserved
|
||||
by virtue of its definition in the XML specification.
|
||||
|
||||
space (as an attribute name): denotes an attribute whose
|
||||
value is a keyword indicating what whitespace processing
|
||||
discipline is intended for the content of the element; its
|
||||
value is inherited. This name is reserved by virtue of its
|
||||
definition in the XML specification.
|
||||
|
||||
Father (in any context at all): denotes Jon Bosak, the chair of
|
||||
the original XML Working Group. This name is reserved by
|
||||
the following decision of the W3C XML Plenary and
|
||||
XML Coordination groups:
|
||||
|
||||
In appreciation for his vision, leadership and dedication
|
||||
the W3C XML Plenary on this 10th day of February, 2000
|
||||
reserves for Jon Bosak in perpetuity the XML name
|
||||
xml:Father
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
|
||||
<xs:annotation>
|
||||
<xs:documentation>This schema defines attributes and an attribute group
|
||||
suitable for use by
|
||||
schemas wishing to allow xml:base, xml:lang, xml:space or xml:id
|
||||
attributes on elements they define.
|
||||
|
||||
To enable this, such a schema must import this schema
|
||||
for the XML namespace, e.g. as follows:
|
||||
<schema . . .>
|
||||
. . .
|
||||
<import namespace="http://www.w3.org/XML/1998/namespace"
|
||||
schemaLocation="http://www.w3.org/2001/xml.xsd"/>
|
||||
|
||||
Subsequently, qualified reference to any of the attributes
|
||||
or the group defined below will have the desired effect, e.g.
|
||||
|
||||
<type . . .>
|
||||
. . .
|
||||
<attributeGroup ref="xml:specialAttrs"/>
|
||||
|
||||
will define a type which will schema-validate an instance
|
||||
element with any of those attributes</xs:documentation>
|
||||
</xs:annotation>
|
||||
|
||||
<xs:annotation>
|
||||
<xs:documentation>In keeping with the XML Schema WG's standard versioning
|
||||
policy, this schema document will persist at
|
||||
http://www.w3.org/2007/08/xml.xsd.
|
||||
At the date of issue it can also be found at
|
||||
http://www.w3.org/2001/xml.xsd.
|
||||
The schema document at that URI may however change in the future,
|
||||
in order to remain compatible with the latest version of XML Schema
|
||||
itself, or with the XML namespace itself. In other words, if the XML
|
||||
Schema or XML namespaces change, the version of this document at
|
||||
http://www.w3.org/2001/xml.xsd will change
|
||||
accordingly; the version at
|
||||
http://www.w3.org/2007/08/xml.xsd will not change.
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
|
||||
<xs:attribute name="lang">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Attempting to install the relevant ISO 2- and 3-letter
|
||||
codes as the enumerated possible values is probably never
|
||||
going to be a realistic possibility. See
|
||||
RFC 3066 at http://www.ietf.org/rfc/rfc3066.txt and the IANA registry
|
||||
at http://www.iana.org/assignments/lang-tag-apps.htm for
|
||||
further information.
|
||||
|
||||
The union allows for the 'un-declaration' of xml:lang with
|
||||
the empty string.</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:simpleType>
|
||||
<xs:union memberTypes="xs:language">
|
||||
<xs:simpleType>
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:enumeration value=""/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
</xs:union>
|
||||
</xs:simpleType>
|
||||
</xs:attribute>
|
||||
|
||||
<xs:attribute name="space">
|
||||
<xs:simpleType>
|
||||
<xs:restriction base="xs:NCName">
|
||||
<xs:enumeration value="default"/>
|
||||
<xs:enumeration value="preserve"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
</xs:attribute>
|
||||
|
||||
<xs:attribute name="base" type="xs:anyURI">
|
||||
<xs:annotation>
|
||||
<xs:documentation>See http://www.w3.org/TR/xmlbase/ for
|
||||
information about this attribute.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
|
||||
<xs:attribute name="id" type="xs:ID">
|
||||
<xs:annotation>
|
||||
<xs:documentation>See http://www.w3.org/TR/xml-id/ for
|
||||
information about this attribute.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
|
||||
<xs:attributeGroup name="specialAttrs">
|
||||
<xs:attribute ref="xml:base"/>
|
||||
<xs:attribute ref="xml:lang"/>
|
||||
<xs:attribute ref="xml:space"/>
|
||||
<xs:attribute ref="xml:id"/>
|
||||
</xs:attributeGroup>
|
||||
|
||||
</xs:schema>
|
||||
0
epiuclid/serializers/__init__.py
Normal file
0
epiuclid/serializers/__init__.py
Normal file
118
epiuclid/serializers/i6z.py
Normal file
118
epiuclid/serializers/i6z.py
Normal file
@ -0,0 +1,118 @@
|
||||
import io
|
||||
import xml.etree.ElementTree as ET
|
||||
import zipfile
|
||||
|
||||
from epiuclid.builders.base import NS_PLATFORM_CONTAINER, document_key
|
||||
from epiuclid.builders.endpoint_study import EndpointStudyRecordBuilder
|
||||
from epiuclid.builders.reference_substance import ReferenceSubstanceBuilder
|
||||
from epiuclid.builders.substance import SubstanceBuilder
|
||||
from epiuclid.serializers.manifest import ManifestBuilder
|
||||
from epiuclid.serializers.pathway_mapper import IUCLIDDocumentBundle
|
||||
from epiuclid.schemas.loader import get_content_schema
|
||||
|
||||
|
||||
def _i6d_filename(uuid) -> str:
|
||||
return f"{uuid}_0.i6d"
|
||||
|
||||
|
||||
class I6ZSerializer:
|
||||
"""Serialize a IUCLIDDocumentBundle to a ZIP file containing the manifest.xml and the i6d files in memory."""
|
||||
|
||||
def serialize(self, bundle: IUCLIDDocumentBundle, *, validate: bool = False) -> bytes:
|
||||
return self._assemble(bundle, validate=validate)
|
||||
|
||||
def _assemble(self, bundle: IUCLIDDocumentBundle, *, validate: bool = False) -> bytes:
|
||||
sub_builder = SubstanceBuilder()
|
||||
ref_builder = ReferenceSubstanceBuilder()
|
||||
esr_builder = EndpointStudyRecordBuilder()
|
||||
|
||||
# (filename, xml_string, doc_type, uuid, subtype)
|
||||
files: list[tuple[str, str, str, str, str | None]] = []
|
||||
|
||||
for sub in bundle.substances:
|
||||
fname = _i6d_filename(sub.uuid)
|
||||
xml = sub_builder.build(sub)
|
||||
files.append((fname, xml, "SUBSTANCE", str(sub.uuid), None))
|
||||
|
||||
for ref in bundle.reference_substances:
|
||||
fname = _i6d_filename(ref.uuid)
|
||||
xml = ref_builder.build(ref)
|
||||
files.append((fname, xml, "REFERENCE_SUBSTANCE", str(ref.uuid), None))
|
||||
|
||||
for esr in bundle.endpoint_study_records:
|
||||
fname = _i6d_filename(esr.uuid)
|
||||
xml = esr_builder.build(esr)
|
||||
files.append(
|
||||
(fname, xml, "ENDPOINT_STUDY_RECORD", str(esr.uuid), "BiodegradationInSoil")
|
||||
)
|
||||
|
||||
if validate:
|
||||
self._validate_documents(files)
|
||||
|
||||
# Build document relationship links for manifest
|
||||
links = self._build_links(bundle)
|
||||
|
||||
# Build manifest
|
||||
manifest_docs = [(f[0], f[2], f[3], f[4]) for f in files]
|
||||
base_uuid = str(bundle.substances[0].uuid) if bundle.substances else ""
|
||||
manifest_xml = ManifestBuilder().build(manifest_docs, base_uuid, links=links)
|
||||
|
||||
# Assemble ZIP
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("manifest.xml", manifest_xml)
|
||||
for fname, xml, _, _, _ in files:
|
||||
zf.writestr(fname, xml)
|
||||
return buf.getvalue()
|
||||
|
||||
@staticmethod
|
||||
def _validate_documents(
|
||||
files: list[tuple[str, str, str, str, str | None]],
|
||||
) -> None:
|
||||
"""Validate each i6d document against its XSD schema.
|
||||
|
||||
Raises ``xmlschema.XMLSchemaValidationError`` on the first failure.
|
||||
"""
|
||||
|
||||
for fname, xml, doc_type, _uuid, subtype in files:
|
||||
root = ET.fromstring(xml)
|
||||
content = root.find(f"{{{NS_PLATFORM_CONTAINER}}}Content")
|
||||
if content is None or len(content) == 0:
|
||||
continue
|
||||
content_el = list(content)[0]
|
||||
schema = get_content_schema(doc_type, subtype)
|
||||
schema.validate(content_el)
|
||||
|
||||
@staticmethod
|
||||
def _build_links(bundle: IUCLIDDocumentBundle) -> dict[str, list[tuple[str, str]]]:
|
||||
"""Build manifest link relationships between documents.
|
||||
|
||||
Returns a dict mapping document UUID (str) to list of (target_doc_key, ref_type).
|
||||
"""
|
||||
links: dict[str, list[tuple[str, str]]] = {}
|
||||
|
||||
def _add(uuid_str: str, target_key: str, ref_type: str) -> None:
|
||||
doc_links = links.setdefault(uuid_str, [])
|
||||
link = (target_key, ref_type)
|
||||
if link not in doc_links:
|
||||
doc_links.append(link)
|
||||
|
||||
# Substance -> REFERENCE link to its reference substance
|
||||
for sub in bundle.substances:
|
||||
if sub.reference_substance_uuid:
|
||||
ref_key = document_key(sub.reference_substance_uuid)
|
||||
_add(str(sub.uuid), ref_key, "REFERENCE")
|
||||
|
||||
# ESR -> PARENT link to its substance; substance -> CHILD link to ESR
|
||||
for esr in bundle.endpoint_study_records:
|
||||
sub_key = document_key(esr.substance_uuid)
|
||||
esr_key = document_key(esr.uuid)
|
||||
_add(str(esr.uuid), sub_key, "PARENT")
|
||||
_add(str(esr.substance_uuid), esr_key, "CHILD")
|
||||
|
||||
for tp in esr.transformation_products:
|
||||
_add(str(esr.uuid), document_key(tp.product_reference_uuid), "REFERENCE")
|
||||
for parent_ref_uuid in tp.parent_reference_uuids:
|
||||
_add(str(esr.uuid), document_key(parent_ref_uuid), "REFERENCE")
|
||||
|
||||
return links
|
||||
120
epiuclid/serializers/manifest.py
Normal file
120
epiuclid/serializers/manifest.py
Normal file
@ -0,0 +1,120 @@
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from epiuclid.builders.base import document_key
|
||||
|
||||
NS_MANIFEST = "http://iuclid6.echa.europa.eu/namespaces/manifest/v1"
|
||||
NS_XLINK = "http://www.w3.org/1999/xlink"
|
||||
|
||||
ET.register_namespace("", NS_MANIFEST)
|
||||
ET.register_namespace("xlink", NS_XLINK)
|
||||
|
||||
|
||||
def _i6d_filename(uuid) -> str:
|
||||
"""Convert UUID to i6d filename (uuid_0.i6d for raw data)."""
|
||||
return f"{uuid}_0.i6d"
|
||||
|
||||
|
||||
def _tag(local: str) -> str:
|
||||
return f"{{{NS_MANIFEST}}}{local}"
|
||||
|
||||
|
||||
def _add_link(links_elem: ET.Element, ref_uuid: str, ref_type: str) -> None:
|
||||
"""Add a <link> element with ref-uuid and ref-type."""
|
||||
link = ET.SubElement(links_elem, _tag("link"))
|
||||
ref_uuid_elem = ET.SubElement(link, _tag("ref-uuid"))
|
||||
ref_uuid_elem.text = ref_uuid
|
||||
ref_type_elem = ET.SubElement(link, _tag("ref-type"))
|
||||
ref_type_elem.text = ref_type
|
||||
|
||||
|
||||
class ManifestBuilder:
|
||||
def build(
|
||||
self,
|
||||
documents: list[tuple[str, str, str, str | None]],
|
||||
base_document_uuid: str,
|
||||
links: dict[str, list[tuple[str, str]]] | None = None,
|
||||
) -> str:
|
||||
"""Build manifest.xml.
|
||||
|
||||
Args:
|
||||
documents: List of (filename, doc_type, uuid, subtype) tuples.
|
||||
base_document_uuid: UUID of the base document (the substance export started from).
|
||||
links: Optional dict mapping document UUID to list of (target_doc_key, ref_type) tuples.
|
||||
ref_type is one of: PARENT, CHILD, REFERENCE.
|
||||
"""
|
||||
if links is None:
|
||||
links = {}
|
||||
|
||||
root = ET.Element(_tag("manifest"))
|
||||
|
||||
# general-information
|
||||
gi = ET.SubElement(root, _tag("general-information"))
|
||||
title = ET.SubElement(gi, _tag("title"))
|
||||
title.text = "IUCLID 6 container manifest file"
|
||||
|
||||
created = ET.SubElement(gi, _tag("created"))
|
||||
created.text = datetime.now(timezone.utc).strftime("%a %b %d %H:%M:%S %Z %Y")
|
||||
|
||||
author = ET.SubElement(gi, _tag("author"))
|
||||
author.text = "enviPath"
|
||||
|
||||
application = ET.SubElement(gi, _tag("application"))
|
||||
application.text = "enviPath IUCLID Export"
|
||||
|
||||
submission_type = ET.SubElement(gi, _tag("submission-type"))
|
||||
submission_type.text = "R_INT_ONSITE"
|
||||
|
||||
archive_type = ET.SubElement(gi, _tag("archive-type"))
|
||||
archive_type.text = "RAW_DATA"
|
||||
|
||||
legislations = ET.SubElement(gi, _tag("legislations-info"))
|
||||
leg = ET.SubElement(legislations, _tag("legislation"))
|
||||
leg_id = ET.SubElement(leg, _tag("id"))
|
||||
leg_id.text = "core"
|
||||
leg_ver = ET.SubElement(leg, _tag("version"))
|
||||
leg_ver.text = "10.0"
|
||||
|
||||
# base-document-uuid
|
||||
base_doc = ET.SubElement(root, _tag("base-document-uuid"))
|
||||
base_doc.text = document_key(base_document_uuid)
|
||||
|
||||
# contained-documents
|
||||
contained = ET.SubElement(root, _tag("contained-documents"))
|
||||
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
for filename, doc_type, uuid, subtype in documents:
|
||||
doc_key = document_key(uuid)
|
||||
doc_elem = ET.SubElement(contained, _tag("document"))
|
||||
doc_elem.set("id", doc_key)
|
||||
|
||||
type_elem = ET.SubElement(doc_elem, _tag("type"))
|
||||
type_elem.text = doc_type
|
||||
|
||||
if subtype:
|
||||
subtype_elem = ET.SubElement(doc_elem, _tag("subtype"))
|
||||
subtype_elem.text = subtype
|
||||
|
||||
name_elem = ET.SubElement(doc_elem, _tag("name"))
|
||||
name_elem.set(f"{{{NS_XLINK}}}type", "simple")
|
||||
name_elem.set(f"{{{NS_XLINK}}}href", filename)
|
||||
name_elem.text = filename
|
||||
|
||||
first_mod = ET.SubElement(doc_elem, _tag("first-modification-date"))
|
||||
first_mod.text = now
|
||||
|
||||
last_mod = ET.SubElement(doc_elem, _tag("last-modification-date"))
|
||||
last_mod.text = now
|
||||
|
||||
uuid_elem = ET.SubElement(doc_elem, _tag("uuid"))
|
||||
uuid_elem.text = doc_key
|
||||
|
||||
# Add links for this document if any
|
||||
doc_links = links.get(uuid, [])
|
||||
if doc_links:
|
||||
links_elem = ET.SubElement(doc_elem, _tag("links"))
|
||||
for target_key, ref_type in doc_links:
|
||||
_add_link(links_elem, target_key, ref_type)
|
||||
|
||||
return ET.tostring(root, encoding="unicode", xml_declaration=True)
|
||||
493
epiuclid/serializers/pathway_mapper.py
Normal file
493
epiuclid/serializers/pathway_mapper.py
Normal file
@ -0,0 +1,493 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from epapi.v1.interfaces.iuclid.dto import PathwayExportDTO
|
||||
from utilities.chem import FormatConverter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class IUCLIDReferenceSubstanceData:
|
||||
uuid: UUID
|
||||
name: str
|
||||
smiles: str | None = None
|
||||
cas_number: str | None = None
|
||||
ec_number: str | None = None
|
||||
iupac_name: str | None = None
|
||||
molecular_formula: str | None = None
|
||||
molecular_weight: float | None = None
|
||||
inchi: str | None = None
|
||||
inchi_key: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class IUCLIDSubstanceData:
|
||||
uuid: UUID
|
||||
name: str
|
||||
reference_substance_uuid: UUID | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SoilPropertiesData:
|
||||
soil_no_code: str | None = None
|
||||
soil_type: str | None = None
|
||||
sand: float | None = None
|
||||
silt: float | None = None
|
||||
clay: float | None = None
|
||||
org_carbon: float | None = None
|
||||
ph_lower: float | None = None
|
||||
ph_upper: float | None = None
|
||||
ph_method: str | None = None
|
||||
cec: float | None = None
|
||||
moisture_content: float | None = None
|
||||
soil_classification: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class IUCLIDEndpointStudyRecordData:
|
||||
uuid: UUID
|
||||
substance_uuid: UUID
|
||||
name: str
|
||||
half_lives: list[HalfLifeEntry] = field(default_factory=list)
|
||||
temperature: tuple[float, float] | None = None
|
||||
transformation_products: list[IUCLIDTransformationProductEntry] = field(default_factory=list)
|
||||
model_name_and_version: list[str] = field(default_factory=list)
|
||||
software_name_and_version: list[str] = field(default_factory=list)
|
||||
model_remarks: list[str] = field(default_factory=list)
|
||||
soil_properties: SoilPropertiesData | None = None
|
||||
soil_properties_entries: list[SoilPropertiesData] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HalfLifeEntry:
|
||||
model: str
|
||||
dt50_start: float
|
||||
dt50_end: float
|
||||
unit: str
|
||||
source: str
|
||||
soil_no_code: str | None = None
|
||||
temperature: tuple[float, float] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class IUCLIDTransformationProductEntry:
|
||||
uuid: UUID
|
||||
product_reference_uuid: UUID
|
||||
parent_reference_uuids: list[UUID] = field(default_factory=list)
|
||||
kinetic_formation_fraction: float | None = None
|
||||
source_edge_uuid: UUID | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class IUCLIDDocumentBundle:
|
||||
substances: list[IUCLIDSubstanceData] = field(default_factory=list)
|
||||
reference_substances: list[IUCLIDReferenceSubstanceData] = field(default_factory=list)
|
||||
endpoint_study_records: list[IUCLIDEndpointStudyRecordData] = field(default_factory=list)
|
||||
|
||||
|
||||
class PathwayMapper:
|
||||
def map(self, export: PathwayExportDTO) -> IUCLIDDocumentBundle:
|
||||
bundle = IUCLIDDocumentBundle()
|
||||
|
||||
seen_compounds: dict[
|
||||
int, tuple[UUID, UUID]
|
||||
] = {} # compound PK -> (substance UUID, ref UUID)
|
||||
compound_names: dict[int, str] = {}
|
||||
|
||||
for compound in export.compounds:
|
||||
if compound.pk in seen_compounds:
|
||||
continue
|
||||
|
||||
derived = self._compute_derived_properties(compound.smiles)
|
||||
ref_sub_uuid = uuid4()
|
||||
sub_uuid = uuid4()
|
||||
seen_compounds[compound.pk] = (sub_uuid, ref_sub_uuid)
|
||||
compound_names[compound.pk] = compound.name
|
||||
|
||||
ref_sub = IUCLIDReferenceSubstanceData(
|
||||
uuid=ref_sub_uuid,
|
||||
name=compound.name,
|
||||
smiles=compound.smiles,
|
||||
cas_number=compound.cas_number,
|
||||
molecular_formula=derived["molecular_formula"],
|
||||
molecular_weight=derived["molecular_weight"],
|
||||
inchi=derived["inchi"],
|
||||
inchi_key=derived["inchi_key"],
|
||||
)
|
||||
bundle.reference_substances.append(ref_sub)
|
||||
|
||||
sub = IUCLIDSubstanceData(
|
||||
uuid=sub_uuid,
|
||||
name=compound.name,
|
||||
reference_substance_uuid=ref_sub_uuid,
|
||||
)
|
||||
bundle.substances.append(sub)
|
||||
|
||||
if not export.compounds:
|
||||
return bundle
|
||||
|
||||
root_compound_pks: list[int] = []
|
||||
seen_root_pks: set[int] = set()
|
||||
for root_pk in export.root_compound_pks:
|
||||
if root_pk in seen_compounds and root_pk not in seen_root_pks:
|
||||
root_compound_pks.append(root_pk)
|
||||
seen_root_pks.add(root_pk)
|
||||
|
||||
if not root_compound_pks:
|
||||
fallback_root_pk = export.compounds[0].pk
|
||||
if fallback_root_pk in seen_compounds:
|
||||
root_compound_pks = [fallback_root_pk]
|
||||
|
||||
if not root_compound_pks:
|
||||
return bundle
|
||||
|
||||
edge_templates: list[tuple[UUID, frozenset[int], tuple[int, ...], tuple[UUID, ...]]] = []
|
||||
for edge in sorted(export.edges, key=lambda item: str(item.edge_uuid)):
|
||||
parent_compound_pks = sorted(
|
||||
{pk for pk in edge.start_compound_pks if pk in seen_compounds}
|
||||
)
|
||||
product_compound_pks = sorted(
|
||||
{pk for pk in edge.end_compound_pks if pk in seen_compounds}
|
||||
)
|
||||
|
||||
if not parent_compound_pks or not product_compound_pks:
|
||||
continue
|
||||
|
||||
parent_ref_uuids = tuple(
|
||||
sorted({seen_compounds[pk][1] for pk in parent_compound_pks}, key=str)
|
||||
)
|
||||
edge_templates.append(
|
||||
(
|
||||
edge.edge_uuid,
|
||||
frozenset(parent_compound_pks),
|
||||
tuple(product_compound_pks),
|
||||
parent_ref_uuids,
|
||||
)
|
||||
)
|
||||
|
||||
model_names: list[str] = []
|
||||
software_names: list[str] = []
|
||||
model_remarks: list[str] = []
|
||||
if export.model_info:
|
||||
if export.model_info.model_name:
|
||||
model_names.append(export.model_info.model_name)
|
||||
if export.model_info.model_uuid:
|
||||
model_remarks.append(f"Model UUID: {export.model_info.model_uuid}")
|
||||
if export.model_info.software_name:
|
||||
if export.model_info.software_version:
|
||||
software_names.append(
|
||||
f"{export.model_info.software_name} {export.model_info.software_version}"
|
||||
)
|
||||
else:
|
||||
software_names.append(export.model_info.software_name)
|
||||
|
||||
# Aggregate scenario-aware AI from all root nodes for each root compound.
|
||||
# Each entry is (scenario_uuid, scenario_name, effective_ai_list).
|
||||
root_node_ai_by_scenario: dict[int, dict[str, tuple[UUID | None, str | None, list]]] = {}
|
||||
for node in export.nodes:
|
||||
if node.depth == 0 and node.compound_pk in seen_root_pks:
|
||||
scenario_bucket = root_node_ai_by_scenario.setdefault(node.compound_pk, {})
|
||||
if node.scenarios:
|
||||
for scenario in node.scenarios:
|
||||
scenario_key = str(scenario.scenario_uuid)
|
||||
existing = scenario_bucket.get(scenario_key)
|
||||
if existing is None:
|
||||
scenario_bucket[scenario_key] = (
|
||||
scenario.scenario_uuid,
|
||||
scenario.name,
|
||||
list(scenario.additional_info),
|
||||
)
|
||||
else:
|
||||
existing[2].extend(scenario.additional_info)
|
||||
else:
|
||||
# Backward compatibility path for callers that only provide node.additional_info.
|
||||
fallback_key = f"fallback:{node.node_uuid}"
|
||||
scenario_bucket[fallback_key] = (None, None, list(node.additional_info))
|
||||
|
||||
has_multiple_roots = len(root_compound_pks) > 1
|
||||
for root_pk in root_compound_pks:
|
||||
substance_uuid, _ = seen_compounds[root_pk]
|
||||
esr_name = f"Biodegradation in soil - {export.pathway_name}"
|
||||
if has_multiple_roots:
|
||||
root_name = compound_names.get(root_pk)
|
||||
if root_name:
|
||||
esr_name = f"{esr_name} ({root_name})"
|
||||
|
||||
transformation_entries: list[IUCLIDTransformationProductEntry] = []
|
||||
reachable_compound_pks = self._reachable_compounds_from_root(root_pk, edge_templates)
|
||||
seen_transformations: set[tuple[UUID, tuple[UUID, ...]]] = set()
|
||||
for (
|
||||
edge_uuid,
|
||||
parent_compound_pks,
|
||||
product_compound_pks,
|
||||
parent_reference_uuids,
|
||||
) in edge_templates:
|
||||
if not parent_compound_pks.issubset(reachable_compound_pks):
|
||||
continue
|
||||
|
||||
for product_compound_pk in product_compound_pks:
|
||||
if product_compound_pk not in reachable_compound_pks:
|
||||
continue
|
||||
|
||||
product_ref_uuid = seen_compounds[product_compound_pk][1]
|
||||
dedupe_key = (product_ref_uuid, parent_reference_uuids)
|
||||
if dedupe_key in seen_transformations:
|
||||
continue
|
||||
|
||||
seen_transformations.add(dedupe_key)
|
||||
transformation_entries.append(
|
||||
IUCLIDTransformationProductEntry(
|
||||
uuid=uuid4(),
|
||||
product_reference_uuid=product_ref_uuid,
|
||||
parent_reference_uuids=list(parent_reference_uuids),
|
||||
source_edge_uuid=edge_uuid,
|
||||
)
|
||||
)
|
||||
|
||||
scenarios_for_root = list(root_node_ai_by_scenario.get(root_pk, {}).values())
|
||||
if not scenarios_for_root:
|
||||
scenarios_for_root = [(None, None, [])]
|
||||
|
||||
soil_entries: list[SoilPropertiesData] = []
|
||||
soil_no_by_signature: dict[tuple, str] = {}
|
||||
half_lives: list[HalfLifeEntry] = []
|
||||
merged_ai_for_root: list = []
|
||||
|
||||
for _, _, ai_for_scenario in scenarios_for_root:
|
||||
merged_ai_for_root.extend(ai_for_scenario)
|
||||
|
||||
soil = self._extract_soil_properties(ai_for_scenario)
|
||||
temperature = self._extract_temperature(ai_for_scenario)
|
||||
|
||||
soil_no_code: str | None = None
|
||||
if soil is not None:
|
||||
soil_signature = self._soil_signature(soil)
|
||||
soil_no_code = soil_no_by_signature.get(soil_signature)
|
||||
if soil_no_code is None:
|
||||
soil_no_code = self._soil_no_code_for_index(len(soil_entries))
|
||||
if soil_no_code is not None:
|
||||
soil.soil_no_code = soil_no_code
|
||||
soil_entries.append(soil)
|
||||
soil_no_by_signature[soil_signature] = soil_no_code
|
||||
|
||||
for hl in self._extract_half_lives(ai_for_scenario):
|
||||
hl.soil_no_code = soil_no_code
|
||||
hl.temperature = temperature
|
||||
half_lives.append(hl)
|
||||
|
||||
esr = IUCLIDEndpointStudyRecordData(
|
||||
uuid=uuid4(),
|
||||
substance_uuid=substance_uuid,
|
||||
name=esr_name,
|
||||
half_lives=half_lives,
|
||||
temperature=self._extract_temperature(merged_ai_for_root),
|
||||
transformation_products=transformation_entries,
|
||||
model_name_and_version=model_names,
|
||||
software_name_and_version=software_names,
|
||||
model_remarks=model_remarks,
|
||||
soil_properties=soil_entries[0] if soil_entries else None,
|
||||
soil_properties_entries=soil_entries,
|
||||
)
|
||||
bundle.endpoint_study_records.append(esr)
|
||||
|
||||
return bundle
|
||||
|
||||
@staticmethod
|
||||
def _extract_half_lives(ai_list: list) -> list[HalfLifeEntry]:
|
||||
from envipy_additional_information.information import HalfLife
|
||||
|
||||
entries = []
|
||||
for ai in ai_list:
|
||||
if not isinstance(ai, HalfLife):
|
||||
continue
|
||||
start = ai.dt50.start
|
||||
end = ai.dt50.end
|
||||
if start is None or end is None:
|
||||
continue
|
||||
entries.append(
|
||||
HalfLifeEntry(
|
||||
model=ai.model,
|
||||
dt50_start=start,
|
||||
dt50_end=end,
|
||||
unit="d",
|
||||
source=ai.source,
|
||||
)
|
||||
)
|
||||
return entries
|
||||
|
||||
@staticmethod
|
||||
def _extract_temperature(ai_list: list) -> tuple[float, float] | None:
|
||||
from envipy_additional_information.information import Temperature
|
||||
|
||||
for ai in ai_list:
|
||||
if not isinstance(ai, Temperature):
|
||||
continue
|
||||
lower = ai.interval.start
|
||||
upper = ai.interval.end
|
||||
if lower is None or upper is None:
|
||||
continue
|
||||
return (lower, upper)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_soil_properties(ai_list: list) -> SoilPropertiesData | None:
|
||||
from envipy_additional_information.information import (
|
||||
Acidity,
|
||||
BulkDensity,
|
||||
CEC,
|
||||
Humidity,
|
||||
OMContent,
|
||||
SoilClassification,
|
||||
SoilTexture1,
|
||||
SoilTexture2,
|
||||
)
|
||||
|
||||
props = SoilPropertiesData()
|
||||
|
||||
for ai in ai_list:
|
||||
if isinstance(ai, SoilTexture1) and props.soil_type is None:
|
||||
props.soil_type = ai.type.value
|
||||
elif isinstance(ai, SoilTexture2):
|
||||
if props.sand is None:
|
||||
props.sand = ai.sand
|
||||
if props.silt is None:
|
||||
props.silt = ai.silt
|
||||
if props.clay is None:
|
||||
props.clay = ai.clay
|
||||
elif isinstance(ai, OMContent) and props.org_carbon is None:
|
||||
props.org_carbon = ai.in_oc
|
||||
elif isinstance(ai, Acidity) and props.ph_lower is None:
|
||||
props.ph_lower = ai.interval.start
|
||||
props.ph_upper = ai.interval.end
|
||||
if isinstance(ai.method, str):
|
||||
props.ph_method = ai.method.strip() or None
|
||||
else:
|
||||
props.ph_method = ai.method
|
||||
elif isinstance(ai, CEC) and props.cec is None:
|
||||
props.cec = ai.capacity
|
||||
elif isinstance(ai, Humidity) and props.moisture_content is None:
|
||||
props.moisture_content = ai.humiditiy
|
||||
elif isinstance(ai, SoilClassification) and props.soil_classification is None:
|
||||
props.soil_classification = ai.system.value
|
||||
elif isinstance(ai, BulkDensity):
|
||||
pass # BulkDensity.data is a free-text string; not mapped to SoilPropertiesData
|
||||
|
||||
all_none = all(
|
||||
v is None
|
||||
for v in (
|
||||
props.soil_type,
|
||||
props.sand,
|
||||
props.silt,
|
||||
props.clay,
|
||||
props.org_carbon,
|
||||
props.ph_lower,
|
||||
props.ph_upper,
|
||||
props.ph_method,
|
||||
props.cec,
|
||||
props.moisture_content,
|
||||
props.soil_classification,
|
||||
)
|
||||
)
|
||||
return None if all_none else props
|
||||
|
||||
@staticmethod
|
||||
def _reachable_compounds_from_root(
|
||||
root_compound_pk: int,
|
||||
edge_templates: list[tuple[UUID, frozenset[int], tuple[int, ...], tuple[UUID, ...]]],
|
||||
) -> set[int]:
|
||||
reachable: set[int] = {root_compound_pk}
|
||||
changed = True
|
||||
|
||||
while changed:
|
||||
changed = False
|
||||
for _, parent_compound_pks, product_compound_pks, _ in edge_templates:
|
||||
if not parent_compound_pks.issubset(reachable):
|
||||
continue
|
||||
|
||||
for product_compound_pk in product_compound_pks:
|
||||
if product_compound_pk in reachable:
|
||||
continue
|
||||
reachable.add(product_compound_pk)
|
||||
changed = True
|
||||
|
||||
return reachable
|
||||
|
||||
@staticmethod
|
||||
def _soil_signature(props: SoilPropertiesData) -> tuple:
|
||||
return (
|
||||
props.soil_type,
|
||||
props.sand,
|
||||
props.silt,
|
||||
props.clay,
|
||||
props.org_carbon,
|
||||
props.ph_lower,
|
||||
props.ph_upper,
|
||||
props.ph_method,
|
||||
props.cec,
|
||||
props.moisture_content,
|
||||
props.soil_classification,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _soil_no_code_for_index(index: int) -> str | None:
|
||||
f137_codes = [
|
||||
"2",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
"10",
|
||||
"11",
|
||||
"3",
|
||||
"4070",
|
||||
"4071",
|
||||
"4072",
|
||||
"4073",
|
||||
"4074",
|
||||
"4075",
|
||||
"4076",
|
||||
"4077",
|
||||
"4078",
|
||||
"4079",
|
||||
]
|
||||
if 0 <= index < len(f137_codes):
|
||||
return f137_codes[index]
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _compute_derived_properties(smiles: str | None) -> dict:
|
||||
molecular_formula = None
|
||||
molecular_weight = None
|
||||
inchi = None
|
||||
inchi_key = None
|
||||
|
||||
if smiles:
|
||||
try:
|
||||
molecular_formula = FormatConverter.formula(smiles)
|
||||
except Exception:
|
||||
logger.debug("Could not compute formula for %s", smiles)
|
||||
try:
|
||||
molecular_weight = FormatConverter.mass(smiles)
|
||||
except Exception:
|
||||
logger.debug("Could not compute mass for %s", smiles)
|
||||
try:
|
||||
inchi = FormatConverter.InChI(smiles)
|
||||
except Exception:
|
||||
logger.debug("Could not compute InChI for %s", smiles)
|
||||
try:
|
||||
inchi_key = FormatConverter.InChIKey(smiles)
|
||||
except Exception:
|
||||
logger.debug("Could not compute InChIKey for %s", smiles)
|
||||
|
||||
return {
|
||||
"molecular_formula": molecular_formula,
|
||||
"molecular_weight": molecular_weight,
|
||||
"inchi": inchi,
|
||||
"inchi_key": inchi_key,
|
||||
}
|
||||
0
epiuclid/tests/__init__.py
Normal file
0
epiuclid/tests/__init__.py
Normal file
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user