forked from enviPath/enviPy
Compare commits
17 Commits
8cdf91c8fb
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e43c298d2 | |||
| b39fc7eaf8 | |||
| a2fc9f72cb | |||
| 734b02767e | |||
| 9d70db2ca2 | |||
| fec26d0233 | |||
| 689f7998eb | |||
| 8498e59fa1 | |||
| b508511cd6 | |||
| 877804c0ff | |||
| 964574c700 | |||
| 5029a8cda5 | |||
| d06bd0d4fd | |||
| f7c45b8015 | |||
| 68aea97013 | |||
| 3cc7fa9e8b | |||
| 21f3390a43 |
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=
|
ALLOWED_HOSTS=
|
||||||
DEBUG=
|
DEBUG=
|
||||||
LOG_LEVEL=
|
LOG_LEVEL=
|
||||||
|
MODEL_BUILDING_ENABLED=
|
||||||
|
APPLICABILITY_DOMAIN_ENABLED=
|
||||||
ENVIFORMER_PRESENT=
|
ENVIFORMER_PRESENT=
|
||||||
FLAG_CELERY_PRESENT=
|
ENVIFORMER_DEVICE=
|
||||||
SERVER_URL=
|
|
||||||
PLUGINS_ENABLED=
|
PLUGINS_ENABLED=
|
||||||
|
SERVER_URL=
|
||||||
|
SERVER_PATH=
|
||||||
|
ADMIN_APPROVAL_REQUIRED=
|
||||||
|
REGISTRATION_MANDATORY=
|
||||||
|
LOG_DIR=
|
||||||
|
# Celery
|
||||||
|
FLAG_CELERY_PRESENT=
|
||||||
|
CELERY_BROKER_URL=
|
||||||
|
CELERY_RESULT_BACKEND=
|
||||||
# DB
|
# DB
|
||||||
POSTGRES_SERVICE_NAME=
|
POSTGRES_SERVICE_NAME=
|
||||||
POSTGRES_DB=
|
POSTGRES_DB=
|
||||||
@ -16,5 +26,30 @@ POSTGRES_PORT=
|
|||||||
# MAIL
|
# MAIL
|
||||||
EMAIL_HOST_USER=
|
EMAIL_HOST_USER=
|
||||||
EMAIL_HOST_PASSWORD=
|
EMAIL_HOST_PASSWORD=
|
||||||
# MATOMO
|
DEFAULT_FROM_EMAIL=
|
||||||
MATOMO_SITE_ID
|
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=
|
||||||
|
|||||||
@ -5,10 +5,12 @@ repos:
|
|||||||
rev: v3.2.0
|
rev: v3.2.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
|
exclude: epiuclid/schemas/
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
|
exclude: epiuclid/schemas/
|
||||||
- id: check-yaml
|
- id: check-yaml
|
||||||
- id: check-added-large-files
|
- id: check-added-large-files
|
||||||
exclude: ^static/images/|fixtures/
|
exclude: ^static/images/|^epiuclid/schemas/|^fixtures/
|
||||||
|
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.13.3
|
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"]
|
||||||
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"]
|
||||||
@ -1,6 +1,8 @@
|
|||||||
import enum
|
import enum
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from envipy_additional_information import EnviPyModel
|
||||||
|
|
||||||
from .dto import BuildResult, EnviPyDTO, EvaluationResult, RunResult
|
from .dto import BuildResult, EnviPyDTO, EvaluationResult, RunResult
|
||||||
|
|
||||||
|
|
||||||
@ -27,12 +29,14 @@ class Plugin(ABC):
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def identifier(self) -> str:
|
def identifier(cls) -> str:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def name(self) -> str:
|
def name(cls) -> str:
|
||||||
"""
|
"""
|
||||||
Represents an abstract method that provides a contract for implementing a method
|
Represents an abstract method that provides a contract for implementing a method
|
||||||
to return a name as a string. Must be implemented in subclasses.
|
to return a name as a string. Must be implemented in subclasses.
|
||||||
@ -46,8 +50,9 @@ class Plugin(ABC):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def display(self) -> str:
|
def display(cls) -> str:
|
||||||
"""
|
"""
|
||||||
An abstract method that must be implemented by subclasses to display
|
An abstract method that must be implemented by subclasses to display
|
||||||
specific information or behavior. The method ensures that all subclasses
|
specific information or behavior. The method ensures that all subclasses
|
||||||
@ -64,8 +69,9 @@ class Plugin(ABC):
|
|||||||
|
|
||||||
|
|
||||||
class Property(Plugin):
|
class Property(Plugin):
|
||||||
|
@classmethod
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def requires_rule_packages(self) -> bool:
|
def requires_rule_packages(cls) -> bool:
|
||||||
"""
|
"""
|
||||||
Defines an abstract method to determine whether rule packages are required.
|
Defines an abstract method to determine whether rule packages are required.
|
||||||
|
|
||||||
@ -79,8 +85,9 @@ class Property(Plugin):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def requires_data_packages(self) -> bool:
|
def requires_data_packages(cls) -> bool:
|
||||||
"""
|
"""
|
||||||
Defines an abstract method to determine whether data packages are required.
|
Defines an abstract method to determine whether data packages are required.
|
||||||
|
|
||||||
@ -231,3 +238,163 @@ class Property(Plugin):
|
|||||||
NotImplementedError: If the method is not implemented by a subclass.
|
NotImplementedError: If the method is not implemented by a subclass.
|
||||||
"""
|
"""
|
||||||
pass
|
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
|
||||||
|
|||||||
@ -59,10 +59,19 @@ class EnviPyDTO(Protocol):
|
|||||||
) -> List["ProductSet"]: ...
|
) -> List["ProductSet"]: ...
|
||||||
|
|
||||||
|
|
||||||
class PredictedProperty(EnviPyModel):
|
class EnviPyPrediction(EnviPyModel):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PropertyPrediction(EnviPyPrediction):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TransformationProductPrediction(EnviPyPrediction):
|
||||||
|
substrate: str
|
||||||
|
products: dict[str, float]
|
||||||
|
|
||||||
|
|
||||||
@register("buildresult")
|
@register("buildresult")
|
||||||
class BuildResult(EnviPyModel):
|
class BuildResult(EnviPyModel):
|
||||||
data: dict[str, Any] | List[dict[str, Any]] | None
|
data: dict[str, Any] | List[dict[str, Any]] | None
|
||||||
@ -72,7 +81,7 @@ class BuildResult(EnviPyModel):
|
|||||||
class RunResult(EnviPyModel):
|
class RunResult(EnviPyModel):
|
||||||
producer: HttpUrl
|
producer: HttpUrl
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
result: PredictedProperty | List[PredictedProperty]
|
result: EnviPyPrediction | List[EnviPyPrediction]
|
||||||
|
|
||||||
|
|
||||||
@register("evaluationresult")
|
@register("evaluationresult")
|
||||||
|
|||||||
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:
|
||||||
@ -37,7 +37,6 @@ ALLOWED_HOSTS = os.environ["ALLOWED_HOSTS"].split(",")
|
|||||||
|
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
"django.contrib.admin",
|
"django.contrib.admin",
|
||||||
"django.contrib.auth",
|
"django.contrib.auth",
|
||||||
@ -45,6 +44,7 @@ INSTALLED_APPS = [
|
|||||||
"django.contrib.sessions",
|
"django.contrib.sessions",
|
||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
|
"django.contrib.postgres",
|
||||||
# 3rd party
|
# 3rd party
|
||||||
"django_extensions",
|
"django_extensions",
|
||||||
"oauth2_provider",
|
"oauth2_provider",
|
||||||
@ -74,6 +74,7 @@ AUTHENTICATION_BACKENDS = [
|
|||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
"django.middleware.common.CommonMiddleware",
|
"django.middleware.common.CommonMiddleware",
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
@ -190,11 +191,21 @@ ADMIN_APPROVAL_REQUIRED = os.environ.get("ADMIN_APPROVAL_REQUIRED", "False") ==
|
|||||||
# SESAME_MAX_AGE = 300
|
# SESAME_MAX_AGE = 300
|
||||||
# # TODO set to "home"
|
# # TODO set to "home"
|
||||||
# LOGIN_REDIRECT_URL = "/"
|
# 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/"
|
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_HOST]
|
||||||
|
|
||||||
CSRF_TRUSTED_ORIGINS = [SERVER_URL]
|
|
||||||
|
|
||||||
AMBIT_URL = "http://localhost:9001"
|
AMBIT_URL = "http://localhost:9001"
|
||||||
DEFAULT_VALUES = {"description": "no description"}
|
DEFAULT_VALUES = {"description": "no description"}
|
||||||
@ -216,19 +227,20 @@ if not os.path.exists(LOG_DIR):
|
|||||||
os.mkdir(LOG_DIR)
|
os.mkdir(LOG_DIR)
|
||||||
|
|
||||||
PLUGIN_DIR = os.path.join(EP_DATA_DIR, "plugins")
|
PLUGIN_DIR = os.path.join(EP_DATA_DIR, "plugins")
|
||||||
|
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))
|
API_PAGINATION_DEFAULT_PAGE_SIZE = int(os.environ.get("API_PAGINATION_DEFAULT_PAGE_SIZE", 50))
|
||||||
PAGINATION_MAX_PER_PAGE_SIZE = int(
|
PAGINATION_MAX_PER_PAGE_SIZE = int(
|
||||||
os.environ.get("API_PAGINATION_MAX_PAGE_SIZE", 100)
|
os.environ.get("API_PAGINATION_MAX_PAGE_SIZE", 100)
|
||||||
) # Ninja override
|
) # Ninja override
|
||||||
|
|
||||||
if not os.path.exists(PLUGIN_DIR):
|
|
||||||
os.mkdir(PLUGIN_DIR)
|
|
||||||
|
|
||||||
# Set this as our static root dir
|
# Set this as our static root dir
|
||||||
STATIC_ROOT = STATIC_DIR
|
STATIC_ROOT = STATIC_DIR
|
||||||
|
|
||||||
STATIC_URL = "/static/"
|
STATIC_URL = "/static/"
|
||||||
|
if SERVER_PATH:
|
||||||
|
STATIC_URL = f"/{SERVER_PATH}/static/"
|
||||||
|
|
||||||
# Where the sources are stored...
|
# Where the sources are stored...
|
||||||
STATICFILES_DIRS = (BASE_DIR / "static",)
|
STATICFILES_DIRS = (BASE_DIR / "static",)
|
||||||
@ -292,9 +304,8 @@ if not FLAG_CELERY_PRESENT:
|
|||||||
|
|
||||||
# Celery Configuration Options
|
# Celery Configuration Options
|
||||||
CELERY_TIMEZONE = "Europe/Berlin"
|
CELERY_TIMEZONE = "Europe/Berlin"
|
||||||
# Celery Configuration
|
CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
||||||
CELERY_BROKER_URL = "redis://localhost:6379/0" # Use Redis as message broker
|
CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", "redis://localhost:6379/1")
|
||||||
CELERY_RESULT_BACKEND = "redis://localhost:6379/1"
|
|
||||||
CELERY_ACCEPT_CONTENT = ["json"]
|
CELERY_ACCEPT_CONTENT = ["json"]
|
||||||
CELERY_TASK_SERIALIZER = "json"
|
CELERY_TASK_SERIALIZER = "json"
|
||||||
|
|
||||||
@ -332,9 +343,11 @@ DEFAULT_MODEL_THRESHOLD = 0.25
|
|||||||
|
|
||||||
# Loading Plugins
|
# Loading Plugins
|
||||||
PLUGINS_ENABLED = os.environ.get("PLUGINS_ENABLED", "False") == "True"
|
PLUGINS_ENABLED = os.environ.get("PLUGINS_ENABLED", "False") == "True"
|
||||||
BASE_PLUGINS = [
|
BASE_PLUGINS = os.environ.get("BASE_PLUGINS", None)
|
||||||
"pepper.PEPPER",
|
if BASE_PLUGINS:
|
||||||
]
|
BASE_PLUGINS = BASE_PLUGINS.split(",")
|
||||||
|
else:
|
||||||
|
BASE_PLUGINS = []
|
||||||
|
|
||||||
CLASSIFIER_PLUGINS = {}
|
CLASSIFIER_PLUGINS = {}
|
||||||
PROPERTY_PLUGINS = {}
|
PROPERTY_PLUGINS = {}
|
||||||
@ -362,6 +375,10 @@ if SENTRY_ENABLED:
|
|||||||
before_send=before_send,
|
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
|
# compile into digestible flags
|
||||||
FLAGS = {
|
FLAGS = {
|
||||||
"MODEL_BUILDING": MODEL_BUILDING_ENABLED,
|
"MODEL_BUILDING": MODEL_BUILDING_ENABLED,
|
||||||
@ -370,6 +387,7 @@ FLAGS = {
|
|||||||
"SENTRY": SENTRY_ENABLED,
|
"SENTRY": SENTRY_ENABLED,
|
||||||
"ENVIFORMER": ENVIFORMER_PRESENT,
|
"ENVIFORMER": ENVIFORMER_PRESENT,
|
||||||
"APPLICABILITY_DOMAIN": APPLICABILITY_DOMAIN_ENABLED,
|
"APPLICABILITY_DOMAIN": APPLICABILITY_DOMAIN_ENABLED,
|
||||||
|
"IUCLID_EXPORT": IUCLID_EXPORT_ENABLED,
|
||||||
}
|
}
|
||||||
|
|
||||||
# path of the URL are checked via "startswith"
|
# path of the URL are checked via "startswith"
|
||||||
@ -382,7 +400,6 @@ LOGIN_EXEMPT_URLS = [
|
|||||||
"/o/userinfo/",
|
"/o/userinfo/",
|
||||||
"/password_reset/",
|
"/password_reset/",
|
||||||
"/reset/",
|
"/reset/",
|
||||||
"/microsoft/",
|
|
||||||
"/terms",
|
"/terms",
|
||||||
"/privacy",
|
"/privacy",
|
||||||
"/cookie-policy",
|
"/cookie-policy",
|
||||||
@ -391,8 +408,13 @@ LOGIN_EXEMPT_URLS = [
|
|||||||
"/careers",
|
"/careers",
|
||||||
"/cite",
|
"/cite",
|
||||||
"/legal",
|
"/legal",
|
||||||
|
"/entra/",
|
||||||
|
"/auth/",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if SERVER_PATH:
|
||||||
|
LOGIN_EXEMPT_URLS = [f"/{SERVER_PATH}{x}" for x in LOGIN_EXEMPT_URLS]
|
||||||
|
|
||||||
# MS AD/Entra
|
# MS AD/Entra
|
||||||
MS_ENTRA_ENABLED = os.environ.get("MS_ENTRA_ENABLED", "False") == "True"
|
MS_ENTRA_ENABLED = os.environ.get("MS_ENTRA_ENABLED", "False") == "True"
|
||||||
if MS_ENTRA_ENABLED:
|
if MS_ENTRA_ENABLED:
|
||||||
@ -408,3 +430,15 @@ if MS_ENTRA_ENABLED:
|
|||||||
|
|
||||||
# Site ID 10 -> beta.envipath.org
|
# Site ID 10 -> beta.envipath.org
|
||||||
MATOMO_SITE_ID = os.environ.get("MATOMO_SITE_ID", "10")
|
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
|
from .api import api_v1, api_legacy
|
||||||
|
|
||||||
|
PATH_PREFIX = s.SERVER_PATH
|
||||||
|
if PATH_PREFIX and not PATH_PREFIX.endswith("/"):
|
||||||
|
PATH_PREFIX += "/"
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", include("epdb.urls")),
|
path(f"{PATH_PREFIX}", include("epdb.urls")),
|
||||||
path("admin/", admin.site.urls),
|
path(f"{PATH_PREFIX}admin/", admin.site.urls),
|
||||||
path("api/v1/", api_v1.urls),
|
path(f"{PATH_PREFIX}api/v1/", api_v1.urls),
|
||||||
path("api/legacy/", api_legacy.urls),
|
path(f"{PATH_PREFIX}api/legacy/", api_legacy.urls),
|
||||||
path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")),
|
path(f"{PATH_PREFIX}o/", include("oauth2_provider.urls", namespace="oauth2_provider")),
|
||||||
]
|
]
|
||||||
|
|
||||||
if "migration" in s.INSTALLED_APPS:
|
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:
|
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
|
# Custom error handlers
|
||||||
handler400 = "epdb.views.handler400"
|
handler400 = "epdb.views.handler400"
|
||||||
|
|||||||
@ -74,7 +74,6 @@ class TestSchemaGeneration:
|
|||||||
assert all(isinstance(g, str) for g in groups), (
|
assert all(isinstance(g, str) for g in groups), (
|
||||||
f"{model_name}: all groups should be strings, got {groups}"
|
f"{model_name}: all groups should be strings, got {groups}"
|
||||||
)
|
)
|
||||||
assert len(groups) > 0, f"{model_name}: should have at least one group"
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("model_name,model_cls", list(registry.list_models().items()))
|
@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]):
|
def test_form_data_matches_schema(self, model_name: str, model_cls: Type[BaseModel]):
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
from django.db.models import Model
|
|
||||||
from epdb.logic import PackageManager
|
|
||||||
from epdb.models import CompoundStructure, User, Package, Compound, Scenario
|
|
||||||
from uuid import UUID
|
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, Compound, Scenario
|
||||||
|
|
||||||
from .errors import EPAPINotFoundError, EPAPIPermissionDeniedError
|
from .errors import EPAPINotFoundError, EPAPIPermissionDeniedError
|
||||||
|
|
||||||
|
Package = s.GET_PACKAGE_MODEL()
|
||||||
|
|
||||||
|
|
||||||
def get_compound_for_read(user, compound_uuid: UUID):
|
def get_compound_for_read(user, compound_uuid: UUID):
|
||||||
"""
|
"""
|
||||||
|
|||||||
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)
|
||||||
@ -15,9 +15,9 @@ router = Router()
|
|||||||
EnhancedPageNumberPagination,
|
EnhancedPageNumberPagination,
|
||||||
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
|
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
|
||||||
)
|
)
|
||||||
def list_all_pathways(request):
|
def list_all_settings(request):
|
||||||
"""
|
"""
|
||||||
List all pathways from reviewed packages.
|
List all settings the user has access to.
|
||||||
"""
|
"""
|
||||||
user = request.user
|
user = request.user
|
||||||
return SettingManager.get_all_settings(user)
|
return SettingManager.get_all_settings(user)
|
||||||
|
|||||||
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,6 +1,7 @@
|
|||||||
from ninja import Router
|
from ninja import Router
|
||||||
from ninja.security import SessionAuth
|
from ninja.security import SessionAuth
|
||||||
|
|
||||||
|
from envipath import settings as s
|
||||||
from .auth import BearerTokenAuth
|
from .auth import BearerTokenAuth
|
||||||
from .endpoints import (
|
from .endpoints import (
|
||||||
packages,
|
packages,
|
||||||
@ -13,6 +14,7 @@ from .endpoints import (
|
|||||||
structure,
|
structure,
|
||||||
additional_information,
|
additional_information,
|
||||||
settings,
|
settings,
|
||||||
|
groups,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Main router with authentication
|
# Main router with authentication
|
||||||
@ -34,3 +36,9 @@ router.add_router("", models.router)
|
|||||||
router.add_router("", structure.router)
|
router.add_router("", structure.router)
|
||||||
router.add_router("", additional_information.router)
|
router.add_router("", additional_information.router)
|
||||||
router.add_router("", settings.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)
|
||||||
|
|||||||
@ -126,3 +126,10 @@ class SettingOutSchema(Schema):
|
|||||||
url: str = ""
|
url: str = ""
|
||||||
name: str
|
name: str
|
||||||
description: 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
|
from . import views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("microsoft/login/", views.microsoft_login, name="microsoft_login"),
|
path("entra/login/", views.entra_login, name="entra_login"),
|
||||||
path("microsoft/callback/", views.microsoft_callback, name="microsoft_callback"),
|
path("auth/redirect/", views.entra_callback, name="entra_callback"),
|
||||||
]
|
]
|
||||||
|
|||||||
128
epauth/views.py
128
epauth/views.py
@ -1,34 +1,49 @@
|
|||||||
import msal
|
import msal
|
||||||
from django.conf import settings as s
|
from django.conf import settings as s
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth import login
|
from django.contrib.auth import login
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
|
|
||||||
from epdb.logic import UserManager
|
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(
|
msal_app = msal.ConfidentialClientApplication(
|
||||||
client_id=s.MS_ENTRA_CLIENT_ID,
|
client_id=s.MS_ENTRA_CLIENT_ID,
|
||||||
client_credential=s.MS_ENTRA_CLIENT_SECRET,
|
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(
|
flow = msal_app.initiate_auth_code_flow(
|
||||||
scopes=s.MS_ENTRA_SCOPES,
|
scopes=s.MS_ENTRA_SCOPES, redirect_uri=s.MS_ENTRA_REDIRECT_URI
|
||||||
redirect_uri=s.MS_ENTRA_REDIRECT_URI
|
|
||||||
)
|
)
|
||||||
|
|
||||||
request.session["msal_auth_flow"] = flow
|
request.session["msal_auth_flow"] = flow
|
||||||
return redirect(flow["auth_uri"])
|
return redirect(flow["auth_uri"])
|
||||||
|
|
||||||
|
|
||||||
def microsoft_callback(request):
|
def entra_callback(request):
|
||||||
msal_app = msal.ConfidentialClientApplication(
|
msal_app, cache = get_msal_app_with_cache(request)
|
||||||
client_id=s.MS_ENTRA_CLIENT_ID,
|
|
||||||
client_credential=s.MS_ENTRA_CLIENT_SECRET,
|
|
||||||
authority=s.MS_ENTRA_AUTHORITY
|
|
||||||
)
|
|
||||||
|
|
||||||
flow = request.session.pop("msal_auth_flow", None)
|
flow = request.session.pop("msal_auth_flow", None)
|
||||||
if not flow:
|
if not flow:
|
||||||
@ -37,30 +52,79 @@ def microsoft_callback(request):
|
|||||||
# Acquire token using the flow and callback request
|
# Acquire token using the flow and callback request
|
||||||
result = msal_app.acquire_token_by_auth_code_flow(flow, request.GET)
|
result = msal_app.acquire_token_by_auth_code_flow(flow, request.GET)
|
||||||
|
|
||||||
if "access_token" in result:
|
# Save the token cache to session
|
||||||
# Optional: Fetch user info from Microsoft Graph
|
if cache.has_state_changed:
|
||||||
import requests
|
request.session["msal_token_cache"] = cache.serialize()
|
||||||
resp = requests.get(
|
|
||||||
"https://graph.microsoft.com/v1.0/me",
|
|
||||||
headers={"Authorization": f"Bearer {result['access_token']}"}
|
|
||||||
)
|
|
||||||
user_info = resp.json()
|
|
||||||
|
|
||||||
user_name = user_info["displayName"]
|
claims = result["id_token_claims"]
|
||||||
user_email = user_info["mail"]
|
|
||||||
user_oid = user_info["id"]
|
|
||||||
|
|
||||||
# Get implementing class
|
user_name = claims.get("name")
|
||||||
User = get_user_model()
|
user_email = claims.get("emailaddress", claims.get("email"))
|
||||||
|
user_oid = claims.get("oid")
|
||||||
|
|
||||||
if User.objects.filter(uuid=user_oid).exists():
|
if not all([user_name, user_email, user_oid]):
|
||||||
login(request, User.objects.get(uuid=user_oid))
|
raise ValueError("Missing required claims in ID token")
|
||||||
else:
|
|
||||||
u = UserManager.create_user(user_name, user_email, None, uuid=user_oid, is_active=True)
|
|
||||||
login(request, u)
|
|
||||||
|
|
||||||
# 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
|
||||||
|
|||||||
118
epdb/admin.py
118
epdb/admin.py
@ -1,8 +1,12 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
from django.conf import settings as s
|
from django.conf import settings as s
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.contrib import messages
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
AdditionalInformation,
|
AdditionalInformation,
|
||||||
|
ClassifierPluginModel,
|
||||||
Compound,
|
Compound,
|
||||||
CompoundStructure,
|
CompoundStructure,
|
||||||
Edge,
|
Edge,
|
||||||
@ -28,6 +32,8 @@ from .models import (
|
|||||||
|
|
||||||
Package = s.GET_PACKAGE_MODEL()
|
Package = s.GET_PACKAGE_MODEL()
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class AdditionalInformationAdmin(admin.ModelAdmin):
|
class AdditionalInformationAdmin(admin.ModelAdmin):
|
||||||
pass
|
pass
|
||||||
@ -44,6 +50,113 @@ class UserAdmin(admin.ModelAdmin):
|
|||||||
"date_joined",
|
"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):
|
class UserPackagePermissionAdmin(admin.ModelAdmin):
|
||||||
pass
|
pass
|
||||||
@ -83,6 +196,10 @@ class PropertyPluginModelAdmin(admin.ModelAdmin):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ClassifierPluginModelAdmin(admin.ModelAdmin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class LicenseAdmin(admin.ModelAdmin):
|
class LicenseAdmin(admin.ModelAdmin):
|
||||||
list_display = ["cc_string", "link", "image_link"]
|
list_display = ["cc_string", "link", "image_link"]
|
||||||
|
|
||||||
@ -146,6 +263,7 @@ admin.site.register(MLRelativeReasoning, MLRelativeReasoningAdmin)
|
|||||||
admin.site.register(EnviFormer, EnviFormerAdmin)
|
admin.site.register(EnviFormer, EnviFormerAdmin)
|
||||||
admin.site.register(PropertyPluginModel, PropertyPluginModelAdmin)
|
admin.site.register(PropertyPluginModel, PropertyPluginModelAdmin)
|
||||||
admin.site.register(License, LicenseAdmin)
|
admin.site.register(License, LicenseAdmin)
|
||||||
|
admin.site.register(ClassifierPluginModel, ClassifierPluginModelAdmin)
|
||||||
admin.site.register(Compound, CompoundAdmin)
|
admin.site.register(Compound, CompoundAdmin)
|
||||||
admin.site.register(CompoundStructure, CompoundStructureAdmin)
|
admin.site.register(CompoundStructure, CompoundStructureAdmin)
|
||||||
admin.site.register(SimpleAmbitRule, SimpleAmbitRuleAdmin)
|
admin.site.register(SimpleAmbitRule, SimpleAmbitRuleAdmin)
|
||||||
|
|||||||
@ -16,8 +16,13 @@ class EPDBConfig(AppConfig):
|
|||||||
model_name = getattr(settings, "EPDB_PACKAGE_MODEL", "epdb.Package")
|
model_name = getattr(settings, "EPDB_PACKAGE_MODEL", "epdb.Package")
|
||||||
logger.info(f"Using Package model: {model_name}")
|
logger.info(f"Using Package model: {model_name}")
|
||||||
|
|
||||||
|
from .autodiscovery import autodiscover
|
||||||
|
|
||||||
|
autodiscover()
|
||||||
|
|
||||||
if settings.PLUGINS_ENABLED:
|
if settings.PLUGINS_ENABLED:
|
||||||
from bridge.contracts import Property
|
from bridge.contracts import Property, Classifier
|
||||||
from utilities.plugin import discover_plugins
|
from utilities.plugin import discover_plugins
|
||||||
|
|
||||||
settings.PROPERTY_PLUGINS.update(**discover_plugins(_cls=Property))
|
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,3 +1,4 @@
|
|||||||
|
from collections import defaultdict
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
import nh3
|
import nh3
|
||||||
@ -11,8 +12,16 @@ from ninja.security import SessionAuth
|
|||||||
from utilities.chem import FormatConverter
|
from utilities.chem import FormatConverter
|
||||||
from utilities.misc import PackageExporter
|
from utilities.misc import PackageExporter
|
||||||
|
|
||||||
from .logic import GroupManager, PackageManager, SearchManager, SettingManager, UserManager
|
from .logic import (
|
||||||
|
EPDBURLParser,
|
||||||
|
GroupManager,
|
||||||
|
PackageManager,
|
||||||
|
SearchManager,
|
||||||
|
SettingManager,
|
||||||
|
UserManager,
|
||||||
|
)
|
||||||
from .models import (
|
from .models import (
|
||||||
|
AdditionalInformation,
|
||||||
Compound,
|
Compound,
|
||||||
CompoundStructure,
|
CompoundStructure,
|
||||||
Edge,
|
Edge,
|
||||||
@ -1329,7 +1338,14 @@ class ScenarioSchema(Schema):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def resolve_collection(obj: Scenario):
|
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
|
@staticmethod
|
||||||
def resolve_review_status(obj: Rule):
|
def resolve_review_status(obj: Rule):
|
||||||
@ -1394,7 +1410,11 @@ def create_package_scenario(request, package_uuid):
|
|||||||
study_type = request.POST.get("type")
|
study_type = request.POST.get("type")
|
||||||
|
|
||||||
ais = []
|
ais = []
|
||||||
types = request.POST.get("adInfoTypes[]", "").split(",")
|
types = request.POST.get("adInfoTypes[]", [])
|
||||||
|
|
||||||
|
if types:
|
||||||
|
types = types.split(",")
|
||||||
|
|
||||||
for t in types:
|
for t in types:
|
||||||
ais.append(build_additional_information_from_request(request, t))
|
ais.append(build_additional_information_from_request(request, t))
|
||||||
|
|
||||||
@ -1436,6 +1456,49 @@ def delete_scenario(request, package_uuid, scenario_uuid):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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 #
|
# Pathway #
|
||||||
###########
|
###########
|
||||||
@ -1904,9 +1967,12 @@ def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
|
|||||||
description=e.edgeReason,
|
description=e.edgeReason,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Update depths as sideeffect of above operation
|
||||||
|
pw.update_depths()
|
||||||
|
|
||||||
return redirect(new_e.url)
|
return redirect(new_e.url)
|
||||||
except ValueError:
|
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}")
|
@router.delete("/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}/edge/{uuid:edge_uuid}")
|
||||||
|
|||||||
110
epdb/logic.py
110
epdb/logic.py
@ -264,8 +264,12 @@ class GroupManager(object):
|
|||||||
return bool(re.findall(GroupManager.group_pattern, url))
|
return bool(re.findall(GroupManager.group_pattern, url))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create_group(current_user, name, description):
|
def create_group(current_user, name, description, *args, **kwargs):
|
||||||
g = Group()
|
g = Group()
|
||||||
|
|
||||||
|
if "uuid" in kwargs:
|
||||||
|
g.uuid = kwargs["uuid"]
|
||||||
|
|
||||||
# Clean for potential XSS
|
# Clean for potential XSS
|
||||||
g.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
|
g.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
|
||||||
g.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
|
g.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
|
||||||
@ -341,52 +345,17 @@ class PackageManager(object):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def readable(user, package):
|
def readable(user, package):
|
||||||
if (
|
return (
|
||||||
UserPackagePermission.objects.filter(package=package, user=user).exists()
|
PackageManager.has_package_permission(user, package, "read") | package.reviewed is True
|
||||||
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
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def writable(user, package):
|
def writable(user, package):
|
||||||
if (
|
return PackageManager.has_package_permission(user, package, "write")
|
||||||
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
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def administrable(user, package):
|
def administrable(user, package):
|
||||||
if (
|
return PackageManager.has_package_permission(user, package, "all")
|
||||||
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
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def has_package_permission(user: "User", package: Union[str, UUID, "Package"], permission: str):
|
def has_package_permission(user: "User", package: Union[str, UUID, "Package"], permission: str):
|
||||||
@ -470,7 +439,9 @@ class PackageManager(object):
|
|||||||
# remove package if user is owner and package is reviewed e.g. admin
|
# remove package if user is owner and package is reviewed e.g. admin
|
||||||
qs = qs.filter(reviewed=False)
|
qs = qs.filter(reviewed=False)
|
||||||
|
|
||||||
return qs.distinct()
|
qs = qs.distinct()
|
||||||
|
|
||||||
|
return qs
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_all_writeable_packages(user):
|
def get_all_writeable_packages(user):
|
||||||
@ -514,7 +485,9 @@ class PackageManager(object):
|
|||||||
|
|
||||||
qs = qs.filter(reviewed=False)
|
qs = qs.filter(reviewed=False)
|
||||||
|
|
||||||
return qs.distinct()
|
qs = qs.distinct()
|
||||||
|
|
||||||
|
return qs
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_packages():
|
def get_packages():
|
||||||
@ -716,6 +689,10 @@ class PackageManager(object):
|
|||||||
struc.description = structure["description"]
|
struc.description = structure["description"]
|
||||||
struc.aliases = structure.get("aliases", [])
|
struc.aliases = structure.get("aliases", [])
|
||||||
struc.smiles = structure["smiles"]
|
struc.smiles = structure["smiles"]
|
||||||
|
|
||||||
|
if structure.get("molfile"):
|
||||||
|
struc.molfile = structure["molfile"]
|
||||||
|
|
||||||
struc.save()
|
struc.save()
|
||||||
|
|
||||||
for scen in structure["scenarios"]:
|
for scen in structure["scenarios"]:
|
||||||
@ -1018,52 +995,9 @@ class PackageManager(object):
|
|||||||
|
|
||||||
print("Fixing Node depths...")
|
print("Fixing Node depths...")
|
||||||
total_pws = Pathway.objects.filter(package=pack).count()
|
total_pws = Pathway.objects.filter(package=pack).count()
|
||||||
|
|
||||||
for p, pw in enumerate(Pathway.objects.filter(package=pack)):
|
for p, pw in enumerate(Pathway.objects.filter(package=pack)):
|
||||||
in_count = defaultdict(lambda: 0)
|
pw.update_depths()
|
||||||
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:
|
|
||||||
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.", end="\r")
|
print(f"{p + 1}/{total_pws} fixed.", end="\r")
|
||||||
|
|
||||||
return pack
|
return pack
|
||||||
|
|||||||
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),
|
||||||
|
]
|
||||||
280
epdb/models.py
280
epdb/models.py
@ -30,7 +30,7 @@ from sklearn.metrics import jaccard_score, precision_score, recall_score
|
|||||||
from sklearn.model_selection import ShuffleSplit
|
from sklearn.model_selection import ShuffleSplit
|
||||||
|
|
||||||
from bridge.contracts import Property
|
from bridge.contracts import Property
|
||||||
from bridge.dto import RunResult, PredictedProperty
|
from bridge.dto import RunResult, PropertyPrediction
|
||||||
from utilities.chem import FormatConverter, IndigoUtils, PredictionResult, ProductSet
|
from utilities.chem import FormatConverter, IndigoUtils, PredictionResult, ProductSet
|
||||||
from utilities.ml import (
|
from utilities.ml import (
|
||||||
ApplicabilityDomainPCA,
|
ApplicabilityDomainPCA,
|
||||||
@ -75,6 +75,7 @@ class User(AbstractUser):
|
|||||||
blank=False,
|
blank=False,
|
||||||
)
|
)
|
||||||
is_reviewer = models.BooleanField(default=False)
|
is_reviewer = models.BooleanField(default=False)
|
||||||
|
contacted = models.BooleanField(null=True, blank=True)
|
||||||
|
|
||||||
USERNAME_FIELD = "email"
|
USERNAME_FIELD = "email"
|
||||||
REQUIRED_FIELDS = ["username"]
|
REQUIRED_FIELDS = ["username"]
|
||||||
@ -1112,6 +1113,7 @@ class CompoundStructure(
|
|||||||
canonical_smiles = models.TextField(blank=False, null=False, verbose_name="Canonical SMILES")
|
canonical_smiles = models.TextField(blank=False, null=False, verbose_name="Canonical SMILES")
|
||||||
inchikey = models.TextField(max_length=27, blank=False, null=False, verbose_name="InChIKey")
|
inchikey = models.TextField(max_length=27, blank=False, null=False, verbose_name="InChIKey")
|
||||||
normalized_structure = models.BooleanField(null=False, blank=False, default=False)
|
normalized_structure = models.BooleanField(null=False, blank=False, default=False)
|
||||||
|
molfile = models.TextField(blank=True, null=True, verbose_name="Molfile")
|
||||||
|
|
||||||
external_identifiers = GenericRelation("ExternalIdentifier")
|
external_identifiers = GenericRelation("ExternalIdentifier")
|
||||||
|
|
||||||
@ -1208,6 +1210,9 @@ class CompoundStructure(
|
|||||||
|
|
||||||
return dict(hls)
|
return dict(hls)
|
||||||
|
|
||||||
|
def d3_json(self):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
class EnzymeLink(EnviPathModel, KEGGIdentifierMixin):
|
class EnzymeLink(EnviPathModel, KEGGIdentifierMixin):
|
||||||
rule = models.ForeignKey("Rule", on_delete=models.CASCADE, db_index=True)
|
rule = models.ForeignKey("Rule", on_delete=models.CASCADE, db_index=True)
|
||||||
@ -2173,6 +2178,56 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
|
|||||||
):
|
):
|
||||||
return Edge.create(self, start_nodes, end_nodes, rule, name=name, description=description)
|
return Edge.create(self, start_nodes, end_nodes, rule, name=name, description=description)
|
||||||
|
|
||||||
|
def update_depths(self):
|
||||||
|
# Collect number of in and out links per node
|
||||||
|
in_count = defaultdict(lambda: 0)
|
||||||
|
out_count = defaultdict(lambda: 0)
|
||||||
|
|
||||||
|
for e in self.edges:
|
||||||
|
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
|
||||||
|
|
||||||
|
depth_map = {}
|
||||||
|
depth_map[0] = list()
|
||||||
|
|
||||||
|
for n in self.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:
|
||||||
|
depth_map[0].append(n)
|
||||||
|
|
||||||
|
# At most depth len(nodes) is possible
|
||||||
|
for i in range(self.nodes.count()):
|
||||||
|
level_nodes = depth_map.get(i, [])
|
||||||
|
|
||||||
|
if len(level_nodes) == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
unique_next_level = set()
|
||||||
|
for n in level_nodes:
|
||||||
|
for e in self.edges:
|
||||||
|
if n in e.start_nodes.all():
|
||||||
|
for p in e.end_nodes.all():
|
||||||
|
unique_next_level.add(p)
|
||||||
|
|
||||||
|
if len(unique_next_level) > 0:
|
||||||
|
depth_map[i + 1] = list(unique_next_level)
|
||||||
|
|
||||||
|
for depth, nodes in depth_map.items():
|
||||||
|
for n in nodes:
|
||||||
|
if n.depth != depth:
|
||||||
|
n.depth = depth
|
||||||
|
n.save()
|
||||||
|
|
||||||
|
|
||||||
class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin):
|
class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin):
|
||||||
pathway = models.ForeignKey(
|
pathway = models.ForeignKey(
|
||||||
@ -2211,10 +2266,14 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
|
|||||||
|
|
||||||
predicted_properties = defaultdict(list)
|
predicted_properties = defaultdict(list)
|
||||||
for ai in self.additional_information.all():
|
for ai in self.additional_information.all():
|
||||||
if isinstance(ai.get(), PredictedProperty):
|
if isinstance(ai.get(), PropertyPrediction):
|
||||||
predicted_properties[ai.get().__class__.__name__].append(ai.data)
|
predicted_properties[ai.get().__class__.__name__].append(ai.data)
|
||||||
|
|
||||||
return {
|
# If we have Subclasses of a CompoundStructure we can overwrite keys (e.g. images)
|
||||||
|
# by overwriting keys
|
||||||
|
structure_data = self.default_node_label.d3_json()
|
||||||
|
|
||||||
|
res = {
|
||||||
"depth": self.depth,
|
"depth": self.depth,
|
||||||
"stereo_removed": self.stereo_removed,
|
"stereo_removed": self.stereo_removed,
|
||||||
"url": self.url,
|
"url": self.url,
|
||||||
@ -2223,6 +2282,7 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
|
|||||||
"image_svg": IndigoUtils.mol_to_svg(
|
"image_svg": IndigoUtils.mol_to_svg(
|
||||||
self.default_node_label.smiles, width=40, height=40
|
self.default_node_label.smiles, width=40, height=40
|
||||||
),
|
),
|
||||||
|
"image_type": "svg",
|
||||||
"name": self.get_name(),
|
"name": self.get_name(),
|
||||||
"smiles": self.default_node_label.smiles,
|
"smiles": self.default_node_label.smiles,
|
||||||
"scenarios": [{"name": s.get_name(), "url": s.url} for s in self.scenarios.all()],
|
"scenarios": [{"name": s.get_name(), "url": s.url} for s in self.scenarios.all()],
|
||||||
@ -2235,8 +2295,11 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
|
|||||||
"predicted_properties": predicted_properties,
|
"predicted_properties": predicted_properties,
|
||||||
"is_engineered_intermediate": self.kv.get("is_engineered_intermediate", False),
|
"is_engineered_intermediate": self.kv.get("is_engineered_intermediate", False),
|
||||||
"timeseries": self.get_timeseries_data(),
|
"timeseries": self.get_timeseries_data(),
|
||||||
|
**structure_data,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def create(
|
def create(
|
||||||
@ -2499,6 +2562,7 @@ class PackageBasedModel(EPModel):
|
|||||||
s.EPDB_PACKAGE_MODEL,
|
s.EPDB_PACKAGE_MODEL,
|
||||||
verbose_name="Data Packages",
|
verbose_name="Data Packages",
|
||||||
related_name="%(app_label)s_%(class)s_data_packages",
|
related_name="%(app_label)s_%(class)s_data_packages",
|
||||||
|
blank=True,
|
||||||
)
|
)
|
||||||
eval_packages = models.ManyToManyField(
|
eval_packages = models.ManyToManyField(
|
||||||
s.EPDB_PACKAGE_MODEL,
|
s.EPDB_PACKAGE_MODEL,
|
||||||
@ -3821,6 +3885,211 @@ class EnviFormer(PackageBasedModel):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class ClassifierPluginModel(PackageBasedModel):
|
||||||
|
plugin_identifier = models.CharField(max_length=255)
|
||||||
|
plugin_config = JSONField(null=True, blank=True, default=dict)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@transaction.atomic
|
||||||
|
def create(
|
||||||
|
package: "Package",
|
||||||
|
plugin_identifier: str,
|
||||||
|
rule_packages: List["Package"] | None,
|
||||||
|
data_packages: List["Package"] | None,
|
||||||
|
name: "str" = None,
|
||||||
|
description: str = None,
|
||||||
|
config: EnviPyModel | None = None,
|
||||||
|
):
|
||||||
|
mod = ClassifierPluginModel()
|
||||||
|
mod.package = package
|
||||||
|
|
||||||
|
# Clean for potential XSS
|
||||||
|
if name is not None:
|
||||||
|
name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
|
||||||
|
|
||||||
|
if name is None or name == "":
|
||||||
|
name = f"ClassifierPluginModel {ClassifierPluginModel.objects.filter(package=package).count() + 1}"
|
||||||
|
|
||||||
|
mod.name = name
|
||||||
|
|
||||||
|
if description is not None and description.strip() != "":
|
||||||
|
mod.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
|
||||||
|
|
||||||
|
if plugin_identifier is None:
|
||||||
|
raise ValueError("Plugin identifier must be set")
|
||||||
|
|
||||||
|
impl = s.CLASSIFIER_PLUGINS.get(plugin_identifier, None)
|
||||||
|
|
||||||
|
if impl is None:
|
||||||
|
raise ValueError(f"Unknown plugin identifier: {plugin_identifier}")
|
||||||
|
|
||||||
|
mod.plugin_identifier = plugin_identifier
|
||||||
|
mod.plugin_config = config.__class__(
|
||||||
|
**json.loads(nh3.clean(config.model_dump_json()).strip())
|
||||||
|
).model_dump(mode="json")
|
||||||
|
|
||||||
|
if impl.requires_rule_packages() and (rule_packages is None or len(rule_packages) == 0):
|
||||||
|
raise ValueError("Plugin requires rules but none were provided")
|
||||||
|
elif not impl.requires_rule_packages() and (
|
||||||
|
rule_packages is not None and len(rule_packages) > 0
|
||||||
|
):
|
||||||
|
raise ValueError("Plugin does not require rules but some were provided")
|
||||||
|
|
||||||
|
if rule_packages is None:
|
||||||
|
rule_packages = []
|
||||||
|
|
||||||
|
if impl.requires_data_packages() and (data_packages is None or len(data_packages) == 0):
|
||||||
|
raise ValueError("Plugin requires data but none were provided")
|
||||||
|
elif not impl.requires_data_packages() and (
|
||||||
|
data_packages is not None and len(data_packages) > 0
|
||||||
|
):
|
||||||
|
raise ValueError("Plugin does not require data but some were provided")
|
||||||
|
|
||||||
|
if data_packages is None:
|
||||||
|
data_packages = []
|
||||||
|
|
||||||
|
mod.save()
|
||||||
|
|
||||||
|
for p in rule_packages:
|
||||||
|
mod.rule_packages.add(p)
|
||||||
|
|
||||||
|
for p in data_packages:
|
||||||
|
mod.data_packages.add(p)
|
||||||
|
|
||||||
|
mod.save()
|
||||||
|
return mod
|
||||||
|
|
||||||
|
def instance(self) -> "Property":
|
||||||
|
"""
|
||||||
|
Returns an instance of the plugin implementation.
|
||||||
|
|
||||||
|
This method retrieves the implementation of the plugin identified by
|
||||||
|
`self.plugin_identifier` from the `CLASSIFIER_PLUGINS` mapping, then
|
||||||
|
instantiates and returns it.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
object: An instance of the plugin implementation.
|
||||||
|
"""
|
||||||
|
impl = s.CLASSIFIER_PLUGINS[self.plugin_identifier]
|
||||||
|
conf = impl.parse_config(data=self.plugin_config)
|
||||||
|
instance = impl(conf)
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def build_dataset(self):
|
||||||
|
"""
|
||||||
|
Required by general model contract but actual implementation resides in plugin.
|
||||||
|
"""
|
||||||
|
return
|
||||||
|
|
||||||
|
def build_model(self):
|
||||||
|
from bridge.dto import BaseDTO
|
||||||
|
|
||||||
|
self.model_status = self.BUILDING
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
compounds = CompoundStructure.objects.filter(compound__package__in=self.data_packages.all())
|
||||||
|
reactions = Reaction.objects.filter(package__in=self.data_packages.all())
|
||||||
|
rules = Rule.objects.filter(package__in=self.rule_packages.all())
|
||||||
|
|
||||||
|
eP = BaseDTO(str(self.uuid), self.url, s.MODEL_DIR, compounds, reactions, rules)
|
||||||
|
|
||||||
|
instance = self.instance()
|
||||||
|
|
||||||
|
_ = instance.build(eP)
|
||||||
|
|
||||||
|
self.model_status = self.BUILT_NOT_EVALUATED
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def predict(self, smiles, *args, **kwargs) -> List["PredictionResult"]:
|
||||||
|
return self.predict_batch([smiles], *args, **kwargs)[0]
|
||||||
|
|
||||||
|
def predict_batch(self, smiles: List[str], *args, **kwargs) -> List[List["PredictionResult"]]:
|
||||||
|
from bridge.dto import BaseDTO, CompoundProto
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TempCompound(CompoundProto):
|
||||||
|
url = None
|
||||||
|
name = None
|
||||||
|
smiles: str
|
||||||
|
|
||||||
|
batch = [TempCompound(smiles=smi) for smi in smiles]
|
||||||
|
|
||||||
|
reactions = Reaction.objects.filter(package__in=self.data_packages.all())
|
||||||
|
rules = Rule.objects.filter(package__in=self.rule_packages.all())
|
||||||
|
|
||||||
|
eP = BaseDTO(str(self.uuid), self.url, s.MODEL_DIR, batch, reactions, rules)
|
||||||
|
|
||||||
|
instance = self.instance()
|
||||||
|
|
||||||
|
rr: RunResult = instance.run(eP, *args, **kwargs)
|
||||||
|
|
||||||
|
res = []
|
||||||
|
for smi in smiles:
|
||||||
|
pred_res = rr.result
|
||||||
|
|
||||||
|
if not isinstance(pred_res, list):
|
||||||
|
pred_res = [pred_res]
|
||||||
|
|
||||||
|
for r in pred_res:
|
||||||
|
if smi == r.substrate:
|
||||||
|
sub_res = []
|
||||||
|
for prod, prob in r.products.items():
|
||||||
|
sub_res.append(PredictionResult([ProductSet(prod.split("."))], prob, None))
|
||||||
|
|
||||||
|
res.append(sub_res)
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
def evaluate_model(self, multigen: bool, eval_packages: List["Package"] = None, **kwargs):
|
||||||
|
from bridge.dto import BaseDTO
|
||||||
|
|
||||||
|
if self.model_status != self.BUILT_NOT_EVALUATED:
|
||||||
|
raise ValueError("Model must be built before evaluation")
|
||||||
|
|
||||||
|
self.model_status = self.EVALUATING
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
if eval_packages is not None:
|
||||||
|
for p in eval_packages:
|
||||||
|
self.eval_packages.add(p)
|
||||||
|
|
||||||
|
rules = Rule.objects.filter(package__in=self.rule_packages.all())
|
||||||
|
|
||||||
|
if self.eval_packages.count() > 0:
|
||||||
|
reactions = Reaction.objects.filter(package__in=self.data_packages.all())
|
||||||
|
compounds = CompoundStructure.objects.filter(
|
||||||
|
compound__package__in=self.data_packages.all()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
reactions = Reaction.objects.filter(package__in=self.eval_packages.all())
|
||||||
|
compounds = CompoundStructure.objects.filter(
|
||||||
|
compound__package__in=self.eval_packages.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
eP = BaseDTO(str(self.uuid), self.url, s.MODEL_DIR, compounds, reactions, rules)
|
||||||
|
|
||||||
|
instance = self.instance()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.eval_packages.count() > 0:
|
||||||
|
res = instance.evaluate(eP, **kwargs)
|
||||||
|
self.eval_results = res.data
|
||||||
|
else:
|
||||||
|
res = instance.build_and_evaluate(eP)
|
||||||
|
self.eval_results = res.data
|
||||||
|
|
||||||
|
self.model_status = self.FINISHED
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error during evaluation: {type(e).__name__}, {e}")
|
||||||
|
self.model_status = self.ERROR
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
class PropertyPluginModel(PackageBasedModel):
|
class PropertyPluginModel(PackageBasedModel):
|
||||||
plugin_identifier = models.CharField(max_length=255)
|
plugin_identifier = models.CharField(max_length=255)
|
||||||
|
|
||||||
@ -4191,7 +4460,6 @@ class AdditionalInformation(models.Model):
|
|||||||
ai: "EnviPyModel",
|
ai: "EnviPyModel",
|
||||||
scenario=None,
|
scenario=None,
|
||||||
content_object=None,
|
content_object=None,
|
||||||
skip_cleaning=False,
|
|
||||||
):
|
):
|
||||||
add_inf = AdditionalInformation()
|
add_inf = AdditionalInformation()
|
||||||
add_inf.package = package
|
add_inf.package = package
|
||||||
@ -4252,7 +4520,7 @@ class AdditionalInformation(models.Model):
|
|||||||
# Generic FK must be complete or empty
|
# Generic FK must be complete or empty
|
||||||
models.CheckConstraint(
|
models.CheckConstraint(
|
||||||
name="ck_addinfo_gfk_pair",
|
name="ck_addinfo_gfk_pair",
|
||||||
check=(
|
condition=(
|
||||||
(Q(content_type__isnull=True) & Q(object_id__isnull=True))
|
(Q(content_type__isnull=True) & Q(object_id__isnull=True))
|
||||||
| (Q(content_type__isnull=False) & Q(object_id__isnull=False))
|
| (Q(content_type__isnull=False) & Q(object_id__isnull=False))
|
||||||
),
|
),
|
||||||
@ -4260,7 +4528,7 @@ class AdditionalInformation(models.Model):
|
|||||||
# Disallow "floating" info
|
# Disallow "floating" info
|
||||||
models.CheckConstraint(
|
models.CheckConstraint(
|
||||||
name="ck_addinfo_not_both_null",
|
name="ck_addinfo_not_both_null",
|
||||||
check=Q(scenario__isnull=False) | Q(content_type__isnull=False),
|
condition=Q(scenario__isnull=False) | Q(content_type__isnull=False),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
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 import AnyHttpUrl, ValidationError
|
||||||
from pydantic.type_adapter import TypeAdapter
|
from pydantic.type_adapter import TypeAdapter
|
||||||
|
|
||||||
|
from epdb.template_registry import get_templates
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
url_adapter = TypeAdapter(AnyHttpUrl)
|
url_adapter = TypeAdapter(AnyHttpUrl)
|
||||||
@ -19,3 +21,8 @@ def is_url(value):
|
|||||||
return True
|
return True
|
||||||
except ValidationError:
|
except ValidationError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag
|
||||||
|
def epdb_slot_templates(slot):
|
||||||
|
return get_templates(slot)
|
||||||
|
|||||||
135
epdb/views.py
135
epdb/views.py
@ -3,6 +3,7 @@ import logging
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List, Iterable
|
from typing import Any, Dict, List, Iterable
|
||||||
|
|
||||||
|
import requests
|
||||||
import nh3
|
import nh3
|
||||||
from django.conf import settings as s
|
from django.conf import settings as s
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
@ -32,6 +33,7 @@ from .models import (
|
|||||||
APIToken,
|
APIToken,
|
||||||
Compound,
|
Compound,
|
||||||
CompoundStructure,
|
CompoundStructure,
|
||||||
|
ClassifierPluginModel,
|
||||||
Edge,
|
Edge,
|
||||||
EnviFormer,
|
EnviFormer,
|
||||||
EnzymeLink,
|
EnzymeLink,
|
||||||
@ -147,6 +149,11 @@ def handler500(request):
|
|||||||
def login(request):
|
def login(request):
|
||||||
context = get_base_context(request)
|
context = get_base_context(request)
|
||||||
|
|
||||||
|
if s.CAP_ENABLED:
|
||||||
|
context["CAP_ENABLED"] = s.CAP_ENABLED
|
||||||
|
context["CAP_API_BASE"] = s.CAP_API_BASE
|
||||||
|
context["CAP_SITE_KEY"] = s.CAP_SITE_KEY
|
||||||
|
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
context["title"] = "enviPath"
|
context["title"] = "enviPath"
|
||||||
context["next"] = request.GET.get("next", "")
|
context["next"] = request.GET.get("next", "")
|
||||||
@ -224,6 +231,11 @@ def logout(request):
|
|||||||
def register(request):
|
def register(request):
|
||||||
context = get_base_context(request)
|
context = get_base_context(request)
|
||||||
|
|
||||||
|
if s.CAP_ENABLED:
|
||||||
|
context["CAP_ENABLED"] = s.CAP_ENABLED
|
||||||
|
context["CAP_API_BASE"] = s.CAP_API_BASE
|
||||||
|
context["CAP_SITE_KEY"] = s.CAP_SITE_KEY
|
||||||
|
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
# Redirect to unified login page with signup tab
|
# Redirect to unified login page with signup tab
|
||||||
next_url = request.GET.get("next", "")
|
next_url = request.GET.get("next", "")
|
||||||
@ -238,6 +250,33 @@ def register(request):
|
|||||||
if next := request.POST.get("next"):
|
if next := request.POST.get("next"):
|
||||||
context["next"] = next
|
context["next"] = next
|
||||||
|
|
||||||
|
# Catpcha
|
||||||
|
if s.CAP_ENABLED:
|
||||||
|
cap_token = request.POST.get("cap-token")
|
||||||
|
|
||||||
|
if not cap_token:
|
||||||
|
context["message"] = "Missing CAP Token."
|
||||||
|
return render(request, "static/login.html", context)
|
||||||
|
|
||||||
|
verify_url = f"{s.CAP_API_BASE}/{s.CAP_SITE_KEY}/siteverify"
|
||||||
|
payload = {
|
||||||
|
"secret": s.CAP_SECRET_KEY,
|
||||||
|
"response": cap_token,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.post(verify_url, json=payload, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
verify_data = resp.json()
|
||||||
|
except requests.RequestException:
|
||||||
|
context["message"] = "Captcha verification failed."
|
||||||
|
return render(request, "static/login.html", context)
|
||||||
|
|
||||||
|
if not verify_data.get("success"):
|
||||||
|
context["message"] = "Captcha check failed. Please try again."
|
||||||
|
return render(request, "static/login.html", context)
|
||||||
|
# End Captcha
|
||||||
|
|
||||||
username = request.POST.get("username", "").strip()
|
username = request.POST.get("username", "").strip()
|
||||||
email = request.POST.get("email", "").strip()
|
email = request.POST.get("email", "").strip()
|
||||||
password = request.POST.get("password", "").strip()
|
password = request.POST.get("password", "").strip()
|
||||||
@ -517,7 +556,7 @@ def packages(request):
|
|||||||
|
|
||||||
# Context for paginated template
|
# Context for paginated template
|
||||||
context["entity_type"] = "package"
|
context["entity_type"] = "package"
|
||||||
context["api_endpoint"] = "/api/v1/packages/"
|
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/packages/"
|
||||||
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
||||||
context["list_title"] = "packages"
|
context["list_title"] = "packages"
|
||||||
|
|
||||||
@ -575,7 +614,7 @@ def compounds(request):
|
|||||||
|
|
||||||
# Context for paginated template
|
# Context for paginated template
|
||||||
context["entity_type"] = "compound"
|
context["entity_type"] = "compound"
|
||||||
context["api_endpoint"] = "/api/v1/compounds/"
|
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/compounds/"
|
||||||
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
||||||
context["list_mode"] = "tabbed"
|
context["list_mode"] = "tabbed"
|
||||||
context["list_title"] = "compounds"
|
context["list_title"] = "compounds"
|
||||||
@ -604,7 +643,7 @@ def rules(request):
|
|||||||
|
|
||||||
# Context for paginated template
|
# Context for paginated template
|
||||||
context["entity_type"] = "rule"
|
context["entity_type"] = "rule"
|
||||||
context["api_endpoint"] = "/api/v1/rules/"
|
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/rules/"
|
||||||
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
||||||
context["list_title"] = "rules"
|
context["list_title"] = "rules"
|
||||||
|
|
||||||
@ -632,7 +671,7 @@ def reactions(request):
|
|||||||
|
|
||||||
# Context for paginated template
|
# Context for paginated template
|
||||||
context["entity_type"] = "reaction"
|
context["entity_type"] = "reaction"
|
||||||
context["api_endpoint"] = "/api/v1/reactions/"
|
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/reactions/"
|
||||||
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
||||||
context["list_title"] = "reactions"
|
context["list_title"] = "reactions"
|
||||||
|
|
||||||
@ -660,7 +699,7 @@ def pathways(request):
|
|||||||
|
|
||||||
# Context for paginated template
|
# Context for paginated template
|
||||||
context["entity_type"] = "pathway"
|
context["entity_type"] = "pathway"
|
||||||
context["api_endpoint"] = "/api/v1/pathways/"
|
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/pathways/"
|
||||||
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
||||||
context["list_title"] = "pathways"
|
context["list_title"] = "pathways"
|
||||||
|
|
||||||
@ -690,7 +729,7 @@ def scenarios(request):
|
|||||||
|
|
||||||
# Context for paginated template
|
# Context for paginated template
|
||||||
context["entity_type"] = "scenario"
|
context["entity_type"] = "scenario"
|
||||||
context["api_endpoint"] = "/api/v1/scenarios/"
|
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/scenarios/"
|
||||||
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
||||||
context["list_title"] = "scenarios"
|
context["list_title"] = "scenarios"
|
||||||
|
|
||||||
@ -736,21 +775,21 @@ def models(request):
|
|||||||
|
|
||||||
if s.FLAGS.get("PLUGINS", False):
|
if s.FLAGS.get("PLUGINS", False):
|
||||||
for k, v in s.CLASSIFIER_PLUGINS.items():
|
for k, v in s.CLASSIFIER_PLUGINS.items():
|
||||||
context["model_types"][v().display()] = {
|
context["model_types"][v.display()] = {
|
||||||
"type": k,
|
"type": k,
|
||||||
"requires_rule_packages": True,
|
"requires_rule_packages": v.requires_rule_packages(),
|
||||||
"requires_data_packages": True,
|
"requires_data_packages": v.requires_data_packages(),
|
||||||
}
|
}
|
||||||
for k, v in s.PROPERTY_PLUGINS.items():
|
for k, v in s.PROPERTY_PLUGINS.items():
|
||||||
context["model_types"][v().display()] = {
|
context["model_types"][v.display()] = {
|
||||||
"type": k,
|
"type": k,
|
||||||
"requires_rule_packages": v().requires_rule_packages,
|
"requires_rule_packages": v.requires_rule_packages(),
|
||||||
"requires_data_packages": v().requires_data_packages,
|
"requires_data_packages": v.requires_data_packages(),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Context for paginated template
|
# Context for paginated template
|
||||||
context["entity_type"] = "model"
|
context["entity_type"] = "model"
|
||||||
context["api_endpoint"] = "/api/v1/models/"
|
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/models/"
|
||||||
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
||||||
context["list_title"] = "models"
|
context["list_title"] = "models"
|
||||||
|
|
||||||
@ -832,7 +871,7 @@ def package_models(request, package_uuid):
|
|||||||
context["object_type"] = "model"
|
context["object_type"] = "model"
|
||||||
context["breadcrumbs"] = breadcrumbs(current_package, "model")
|
context["breadcrumbs"] = breadcrumbs(current_package, "model")
|
||||||
context["entity_type"] = "model"
|
context["entity_type"] = "model"
|
||||||
context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/model/"
|
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/package/{current_package.uuid}/model/"
|
||||||
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
||||||
context["list_title"] = "models"
|
context["list_title"] = "models"
|
||||||
|
|
||||||
@ -876,16 +915,19 @@ def package_models(request, package_uuid):
|
|||||||
|
|
||||||
if s.FLAGS.get("PLUGINS", False):
|
if s.FLAGS.get("PLUGINS", False):
|
||||||
for k, v in s.CLASSIFIER_PLUGINS.items():
|
for k, v in s.CLASSIFIER_PLUGINS.items():
|
||||||
context["model_types"][v().display()] = {
|
context["model_types"][v.display()] = {
|
||||||
"type": k,
|
"type": k,
|
||||||
"requires_rule_packages": True,
|
"requires_rule_packages": v.requires_rule_packages(),
|
||||||
"requires_data_packages": True,
|
"requires_data_packages": v.requires_data_packages(),
|
||||||
|
"additional_parameters": v.Config.__name__.lower()
|
||||||
|
if v.Config.__name__ != ""
|
||||||
|
else None,
|
||||||
}
|
}
|
||||||
for k, v in s.PROPERTY_PLUGINS.items():
|
for k, v in s.PROPERTY_PLUGINS.items():
|
||||||
context["model_types"][v().display()] = {
|
context["model_types"][v.display()] = {
|
||||||
"type": k,
|
"type": k,
|
||||||
"requires_rule_packages": v().requires_rule_packages,
|
"requires_rule_packages": v.requires_rule_packages(),
|
||||||
"requires_data_packages": v().requires_data_packages,
|
"requires_data_packages": v.requires_data_packages(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, "collections/models_paginated.html", context)
|
return render(request, "collections/models_paginated.html", context)
|
||||||
@ -948,20 +990,34 @@ def package_models(request, package_uuid):
|
|||||||
|
|
||||||
mod = RuleBasedRelativeReasoning.create(**params)
|
mod = RuleBasedRelativeReasoning.create(**params)
|
||||||
elif s.FLAGS.get("PLUGINS", False) and model_type in s.CLASSIFIER_PLUGINS:
|
elif s.FLAGS.get("PLUGINS", False) and model_type in s.CLASSIFIER_PLUGINS:
|
||||||
pass
|
|
||||||
elif s.FLAGS.get("PLUGINS", False) and model_type in s.PROPERTY_PLUGINS:
|
|
||||||
params["plugin_identifier"] = model_type
|
params["plugin_identifier"] = model_type
|
||||||
impl = s.PROPERTY_PLUGINS[model_type]
|
impl = s.CLASSIFIER_PLUGINS[model_type]
|
||||||
inst = impl()
|
|
||||||
|
|
||||||
if inst.requires_rule_packages():
|
if impl.requires_rule_packages():
|
||||||
params["rule_packages"] = [
|
params["rule_packages"] = [
|
||||||
PackageManager.get_package_by_url(current_user, p) for p in rule_packages
|
PackageManager.get_package_by_url(current_user, p) for p in rule_packages
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
params["rule_packages"] = []
|
params["rule_packages"] = []
|
||||||
|
|
||||||
if not inst.requires_data_packages():
|
if not impl.requires_data_packages():
|
||||||
|
params["data_packages"] = []
|
||||||
|
|
||||||
|
params["config"] = impl.parse_config(request.POST.dict())
|
||||||
|
|
||||||
|
mod = ClassifierPluginModel.create(**params)
|
||||||
|
elif s.FLAGS.get("PLUGINS", False) and model_type in s.PROPERTY_PLUGINS:
|
||||||
|
params["plugin_identifier"] = model_type
|
||||||
|
impl = s.PROPERTY_PLUGINS[model_type]
|
||||||
|
|
||||||
|
if impl.requires_rule_packages():
|
||||||
|
params["rule_packages"] = [
|
||||||
|
PackageManager.get_package_by_url(current_user, p) for p in rule_packages
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
params["rule_packages"] = []
|
||||||
|
|
||||||
|
if not impl.requires_data_packages():
|
||||||
del params["data_packages"]
|
del params["data_packages"]
|
||||||
|
|
||||||
mod = PropertyPluginModel.create(**params)
|
mod = PropertyPluginModel.create(**params)
|
||||||
@ -1285,7 +1341,7 @@ def package_compounds(request, package_uuid):
|
|||||||
context["object_type"] = "compound"
|
context["object_type"] = "compound"
|
||||||
context["breadcrumbs"] = breadcrumbs(current_package, "compound")
|
context["breadcrumbs"] = breadcrumbs(current_package, "compound")
|
||||||
context["entity_type"] = "compound"
|
context["entity_type"] = "compound"
|
||||||
context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/compound/"
|
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/package/{current_package.uuid}/compound/"
|
||||||
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
||||||
context["list_mode"] = "tabbed"
|
context["list_mode"] = "tabbed"
|
||||||
context["list_title"] = "compounds"
|
context["list_title"] = "compounds"
|
||||||
@ -1438,7 +1494,7 @@ def package_compound_structures(request, package_uuid, compound_uuid):
|
|||||||
context["entity_type"] = "structure"
|
context["entity_type"] = "structure"
|
||||||
context["page_title"] = f"{current_compound.get_name()} - Structures"
|
context["page_title"] = f"{current_compound.get_name()} - Structures"
|
||||||
context["api_endpoint"] = (
|
context["api_endpoint"] = (
|
||||||
f"/api/v1/package/{current_package.uuid}/compound/{current_compound.uuid}/structure/"
|
f"{s.SERVER_PATH}/api/v1/package/{current_package.uuid}/compound/{current_compound.uuid}/structure/"
|
||||||
)
|
)
|
||||||
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
||||||
context["compound"] = current_compound
|
context["compound"] = current_compound
|
||||||
@ -1601,7 +1657,7 @@ def package_rules(request, package_uuid):
|
|||||||
context["object_type"] = "rule"
|
context["object_type"] = "rule"
|
||||||
context["breadcrumbs"] = breadcrumbs(current_package, "rule")
|
context["breadcrumbs"] = breadcrumbs(current_package, "rule")
|
||||||
context["entity_type"] = "rule"
|
context["entity_type"] = "rule"
|
||||||
context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/rule/"
|
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/package/{current_package.uuid}/rule/"
|
||||||
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
||||||
context["list_title"] = "rules"
|
context["list_title"] = "rules"
|
||||||
|
|
||||||
@ -1809,7 +1865,7 @@ def package_reactions(request, package_uuid):
|
|||||||
context["object_type"] = "reaction"
|
context["object_type"] = "reaction"
|
||||||
context["breadcrumbs"] = breadcrumbs(current_package, "reaction")
|
context["breadcrumbs"] = breadcrumbs(current_package, "reaction")
|
||||||
context["entity_type"] = "reaction"
|
context["entity_type"] = "reaction"
|
||||||
context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/reaction/"
|
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/package/{current_package.uuid}/reaction/"
|
||||||
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
||||||
context["list_title"] = "reactions"
|
context["list_title"] = "reactions"
|
||||||
|
|
||||||
@ -1959,7 +2015,7 @@ def package_pathways(request, package_uuid):
|
|||||||
context["object_type"] = "pathway"
|
context["object_type"] = "pathway"
|
||||||
context["breadcrumbs"] = breadcrumbs(current_package, "pathway")
|
context["breadcrumbs"] = breadcrumbs(current_package, "pathway")
|
||||||
context["entity_type"] = "pathway"
|
context["entity_type"] = "pathway"
|
||||||
context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/pathway/"
|
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/package/{current_package.uuid}/pathway/"
|
||||||
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
||||||
context["list_title"] = "pathways"
|
context["list_title"] = "pathways"
|
||||||
|
|
||||||
@ -2450,6 +2506,9 @@ def package_pathway_edges(request, package_uuid, pathway_uuid):
|
|||||||
substrate_nodes, product_nodes, name=edge_name, description=edge_description
|
substrate_nodes, product_nodes, name=edge_name, description=edge_description
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Update depths as sideeffect of above operation
|
||||||
|
current_pathway.update_depths()
|
||||||
|
|
||||||
return redirect(current_pathway.url)
|
return redirect(current_pathway.url)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@ -2535,7 +2594,7 @@ def package_scenarios(request, package_uuid):
|
|||||||
context["object_type"] = "scenario"
|
context["object_type"] = "scenario"
|
||||||
context["breadcrumbs"] = breadcrumbs(current_package, "scenario")
|
context["breadcrumbs"] = breadcrumbs(current_package, "scenario")
|
||||||
context["entity_type"] = "scenario"
|
context["entity_type"] = "scenario"
|
||||||
context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/scenario/"
|
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/package/{current_package.uuid}/scenario/"
|
||||||
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
||||||
context["list_title"] = "scenarios"
|
context["list_title"] = "scenarios"
|
||||||
|
|
||||||
@ -2790,9 +2849,15 @@ def groups(request):
|
|||||||
{"Group": s.SERVER_URL + "/group"},
|
{"Group": s.SERVER_URL + "/group"},
|
||||||
]
|
]
|
||||||
|
|
||||||
context["objects"] = Group.objects.all()
|
# Context for paginated template
|
||||||
|
context["entity_type"] = "group"
|
||||||
|
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/groups/"
|
||||||
|
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
||||||
|
context["list_title"] = "groups"
|
||||||
|
context["list_mode"] = "combined"
|
||||||
|
|
||||||
|
return render(request, "collections/groups_paginated.html", context)
|
||||||
|
|
||||||
return render(request, "collections/objects_list.html", context)
|
|
||||||
elif request.method == "POST":
|
elif request.method == "POST":
|
||||||
group_name = request.POST.get("group-name")
|
group_name = request.POST.get("group-name")
|
||||||
group_description = request.POST.get("group-description", s.DEFAULT_VALUES["description"])
|
group_description = request.POST.get("group-description", s.DEFAULT_VALUES["description"])
|
||||||
@ -2884,7 +2949,7 @@ def settings(request):
|
|||||||
|
|
||||||
# Context for paginated template
|
# Context for paginated template
|
||||||
context["entity_type"] = "setting"
|
context["entity_type"] = "setting"
|
||||||
context["api_endpoint"] = "/api/v1/settings/"
|
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/settings/"
|
||||||
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
||||||
context["list_title"] = "settings"
|
context["list_title"] = "settings"
|
||||||
context["list_mode"] = "combined"
|
context["list_mode"] = "combined"
|
||||||
|
|||||||
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
102
epiuclid/tests/factories.py
Normal file
102
epiuclid/tests/factories.py
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from epiuclid.serializers.pathway_mapper import (
|
||||||
|
HalfLifeEntry,
|
||||||
|
IUCLIDEndpointStudyRecordData,
|
||||||
|
IUCLIDReferenceSubstanceData,
|
||||||
|
IUCLIDSubstanceData,
|
||||||
|
IUCLIDTransformationProductEntry,
|
||||||
|
SoilPropertiesData,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_substance_data(**overrides) -> IUCLIDSubstanceData:
|
||||||
|
payload = {
|
||||||
|
"uuid": uuid4(),
|
||||||
|
"name": "Atrazine",
|
||||||
|
"reference_substance_uuid": uuid4(),
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
return IUCLIDSubstanceData(**payload)
|
||||||
|
|
||||||
|
|
||||||
|
def make_reference_substance_data(**overrides) -> IUCLIDReferenceSubstanceData:
|
||||||
|
payload = {
|
||||||
|
"uuid": uuid4(),
|
||||||
|
"name": "Atrazine",
|
||||||
|
"smiles": "CCNc1nc(Cl)nc(NC(C)C)n1",
|
||||||
|
"cas_number": "1912-24-9",
|
||||||
|
"ec_number": "217-617-8",
|
||||||
|
"iupac_name": "6-chloro-N2-ethyl-N4-isopropyl-1,3,5-triazine-2,4-diamine",
|
||||||
|
"molecular_formula": "C8H14ClN5",
|
||||||
|
"molecular_weight": 215.68,
|
||||||
|
"inchi": (
|
||||||
|
"InChI=1S/C8H14ClN5/c1-4-10-7-12-6(9)11-8(13-7)"
|
||||||
|
"14-5(2)3/h5H,4H2,1-3H3,(H2,10,11,12,13,14)"
|
||||||
|
),
|
||||||
|
"inchi_key": "MXWJVTOOROXGIU-UHFFFAOYSA-N",
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
return IUCLIDReferenceSubstanceData(**payload)
|
||||||
|
|
||||||
|
|
||||||
|
def make_half_life_entry(**overrides) -> HalfLifeEntry:
|
||||||
|
payload = {
|
||||||
|
"model": "SFO",
|
||||||
|
"dt50_start": 12.5,
|
||||||
|
"dt50_end": 15.0,
|
||||||
|
"unit": "d",
|
||||||
|
"source": "Model prediction",
|
||||||
|
"soil_no_code": None,
|
||||||
|
"temperature": None,
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
return HalfLifeEntry(**payload)
|
||||||
|
|
||||||
|
|
||||||
|
def make_transformation_entry(**overrides) -> IUCLIDTransformationProductEntry:
|
||||||
|
payload = {
|
||||||
|
"uuid": uuid4(),
|
||||||
|
"product_reference_uuid": uuid4(),
|
||||||
|
"parent_reference_uuids": [uuid4()],
|
||||||
|
"kinetic_formation_fraction": 0.42,
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
return IUCLIDTransformationProductEntry(**payload)
|
||||||
|
|
||||||
|
|
||||||
|
def make_soil_properties_data(**overrides) -> SoilPropertiesData:
|
||||||
|
payload = {
|
||||||
|
"soil_no_code": None,
|
||||||
|
"clay": 20.0,
|
||||||
|
"silt": 30.0,
|
||||||
|
"sand": 50.0,
|
||||||
|
"org_carbon": 1.5,
|
||||||
|
"ph_lower": 6.0,
|
||||||
|
"ph_upper": 7.0,
|
||||||
|
"ph_method": "CaCl2",
|
||||||
|
"cec": 12.0,
|
||||||
|
"moisture_content": 40.0,
|
||||||
|
"soil_type": "LOAM",
|
||||||
|
"soil_classification": "USDA",
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
return SoilPropertiesData(**payload)
|
||||||
|
|
||||||
|
|
||||||
|
def make_endpoint_study_record_data(**overrides) -> IUCLIDEndpointStudyRecordData:
|
||||||
|
payload = {
|
||||||
|
"uuid": uuid4(),
|
||||||
|
"substance_uuid": uuid4(),
|
||||||
|
"name": "Biodegradation study",
|
||||||
|
"half_lives": [],
|
||||||
|
"temperature": None,
|
||||||
|
"transformation_products": [],
|
||||||
|
"model_name_and_version": [],
|
||||||
|
"software_name_and_version": [],
|
||||||
|
"model_remarks": [],
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
return IUCLIDEndpointStudyRecordData(**payload)
|
||||||
92
epiuclid/tests/test_api.py
Normal file
92
epiuclid/tests/test_api.py
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
"""Integration tests for IUCLID export API endpoint."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from django.test import TestCase, tag
|
||||||
|
|
||||||
|
from epdb.logic import PackageManager, UserManager
|
||||||
|
from epdb.models import Node, Pathway
|
||||||
|
|
||||||
|
|
||||||
|
@tag("iuclid")
|
||||||
|
class IUCLIDExportAPITest(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.user = UserManager.create_user(
|
||||||
|
"iuclid-api-user",
|
||||||
|
"iuclid-api@test.com",
|
||||||
|
"TestPass123",
|
||||||
|
set_setting=False,
|
||||||
|
add_to_group=False,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
default_pkg = cls.user.default_package
|
||||||
|
cls.user.default_package = None
|
||||||
|
cls.user.save()
|
||||||
|
default_pkg.delete()
|
||||||
|
|
||||||
|
cls.package = PackageManager.create_package(cls.user, "IUCLID API Test Package")
|
||||||
|
|
||||||
|
cls.pathway = Pathway.create(
|
||||||
|
cls.package,
|
||||||
|
"c1ccccc1",
|
||||||
|
name="Export Test Pathway",
|
||||||
|
)
|
||||||
|
Node.create(cls.pathway, "c1ccc(O)cc1", depth=1, name="Phenol")
|
||||||
|
|
||||||
|
# Unauthorized user
|
||||||
|
cls.other_user = UserManager.create_user(
|
||||||
|
"iuclid-other-user",
|
||||||
|
"other@test.com",
|
||||||
|
"TestPass123",
|
||||||
|
set_setting=False,
|
||||||
|
add_to_group=False,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
other_pkg = cls.other_user.default_package
|
||||||
|
cls.other_user.default_package = None
|
||||||
|
cls.other_user.save()
|
||||||
|
other_pkg.delete()
|
||||||
|
|
||||||
|
def test_export_returns_zip(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
url = f"/api/v1/pathway/{self.pathway.uuid}/export/iuclid"
|
||||||
|
response = self.client.get(url)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response["Content-Type"], "application/zip")
|
||||||
|
self.assertIn(".i6z", response["Content-Disposition"])
|
||||||
|
|
||||||
|
self.assertTrue(zipfile.is_zipfile(io.BytesIO(response.content)))
|
||||||
|
|
||||||
|
def test_export_contains_expected_files(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
url = f"/api/v1/pathway/{self.pathway.uuid}/export/iuclid"
|
||||||
|
response = self.client.get(url)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(response.content)) as zf:
|
||||||
|
names = zf.namelist()
|
||||||
|
self.assertIn("manifest.xml", names)
|
||||||
|
i6d_files = [n for n in names if n.endswith(".i6d")]
|
||||||
|
# 2 substances + 2 ref substances + 1 ESR = 5 i6d files
|
||||||
|
self.assertEqual(len(i6d_files), 5)
|
||||||
|
|
||||||
|
def test_anonymous_returns_401(self):
|
||||||
|
self.client.logout()
|
||||||
|
url = f"/api/v1/pathway/{self.pathway.uuid}/export/iuclid"
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
|
||||||
|
def test_unauthorized_user_returns_403(self):
|
||||||
|
self.client.force_login(self.other_user)
|
||||||
|
url = f"/api/v1/pathway/{self.pathway.uuid}/export/iuclid"
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_nonexistent_pathway_returns_404(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
url = f"/api/v1/pathway/{uuid4()}/export/iuclid"
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
521
epiuclid/tests/test_builders.py
Normal file
521
epiuclid/tests/test_builders.py
Normal file
@ -0,0 +1,521 @@
|
|||||||
|
"""Contract tests for IUCLID XML builders - no DB required."""
|
||||||
|
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from django.test import SimpleTestCase, tag
|
||||||
|
|
||||||
|
from epiuclid.builders.base import (
|
||||||
|
NS_PLATFORM_CONTAINER,
|
||||||
|
NS_PLATFORM_FIELDS,
|
||||||
|
NS_PLATFORM_METADATA,
|
||||||
|
document_key,
|
||||||
|
)
|
||||||
|
from epiuclid.builders.endpoint_study import DOC_SUBTYPE, EndpointStudyRecordBuilder, NS_ESR_BIODEG
|
||||||
|
from epiuclid.builders.reference_substance import NS_REFERENCE_SUBSTANCE, ReferenceSubstanceBuilder
|
||||||
|
from epiuclid.builders.substance import NS_SUBSTANCE, SubstanceBuilder
|
||||||
|
|
||||||
|
from .factories import (
|
||||||
|
make_endpoint_study_record_data,
|
||||||
|
make_half_life_entry,
|
||||||
|
make_reference_substance_data,
|
||||||
|
make_soil_properties_data,
|
||||||
|
make_substance_data,
|
||||||
|
make_transformation_entry,
|
||||||
|
)
|
||||||
|
from .xml_assertions import assert_xpath_absent, assert_xpath_text
|
||||||
|
|
||||||
|
|
||||||
|
@tag("iuclid")
|
||||||
|
class SubstanceBuilderContractTest(SimpleTestCase):
|
||||||
|
def test_maps_name_and_reference_key(self):
|
||||||
|
reference_uuid = uuid4()
|
||||||
|
data = make_substance_data(name="Atrazine", reference_substance_uuid=reference_uuid)
|
||||||
|
|
||||||
|
root = ET.fromstring(SubstanceBuilder().build(data))
|
||||||
|
|
||||||
|
assert_xpath_text(self, root, f".//{{{NS_SUBSTANCE}}}ChemicalName", "Atrazine")
|
||||||
|
assert_xpath_text(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f".//{{{NS_SUBSTANCE}}}ReferenceSubstance/{{{NS_SUBSTANCE}}}ReferenceSubstance",
|
||||||
|
document_key(reference_uuid),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_omits_reference_substance_when_missing(self):
|
||||||
|
data = make_substance_data(reference_substance_uuid=None)
|
||||||
|
|
||||||
|
root = ET.fromstring(SubstanceBuilder().build(data))
|
||||||
|
|
||||||
|
assert_xpath_absent(self, root, f".//{{{NS_SUBSTANCE}}}ReferenceSubstance")
|
||||||
|
|
||||||
|
def test_sets_substance_document_type(self):
|
||||||
|
data = make_substance_data()
|
||||||
|
|
||||||
|
root = ET.fromstring(SubstanceBuilder().build(data))
|
||||||
|
|
||||||
|
assert_xpath_text(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f"{{{NS_PLATFORM_CONTAINER}}}PlatformMetadata/{{{NS_PLATFORM_METADATA}}}documentType",
|
||||||
|
"SUBSTANCE",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tag("iuclid")
|
||||||
|
class ReferenceSubstanceBuilderContractTest(SimpleTestCase):
|
||||||
|
def test_maps_structural_identifiers_and_mass_precision(self):
|
||||||
|
data = make_reference_substance_data(molecular_weight=215.6)
|
||||||
|
|
||||||
|
root = ET.fromstring(ReferenceSubstanceBuilder().build(data))
|
||||||
|
|
||||||
|
assert_xpath_text(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f".//{{{NS_REFERENCE_SUBSTANCE}}}Inventory/{{{NS_REFERENCE_SUBSTANCE}}}CASNumber",
|
||||||
|
"1912-24-9",
|
||||||
|
)
|
||||||
|
assert_xpath_text(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f".//{{{NS_REFERENCE_SUBSTANCE}}}MolecularStructuralInfo/{{{NS_REFERENCE_SUBSTANCE}}}InChl",
|
||||||
|
(
|
||||||
|
"InChI=1S/C8H14ClN5/c1-4-10-7-12-6(9)11-8(13-7)"
|
||||||
|
"14-5(2)3/h5H,4H2,1-3H3,(H2,10,11,12,13,14)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert_xpath_text(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f".//{{{NS_REFERENCE_SUBSTANCE}}}MolecularStructuralInfo/{{{NS_REFERENCE_SUBSTANCE}}}InChIKey",
|
||||||
|
"MXWJVTOOROXGIU-UHFFFAOYSA-N",
|
||||||
|
)
|
||||||
|
assert_xpath_text(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f".//{{{NS_REFERENCE_SUBSTANCE}}}MolecularStructuralInfo"
|
||||||
|
f"/{{{NS_REFERENCE_SUBSTANCE}}}MolecularWeightRange"
|
||||||
|
f"/{{{NS_REFERENCE_SUBSTANCE}}}lowerValue",
|
||||||
|
"215.60",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_omits_inventory_and_weight_for_minimal_payload(self):
|
||||||
|
data = make_reference_substance_data(
|
||||||
|
cas_number=None,
|
||||||
|
molecular_formula=None,
|
||||||
|
molecular_weight=None,
|
||||||
|
inchi=None,
|
||||||
|
inchi_key=None,
|
||||||
|
smiles="CC",
|
||||||
|
)
|
||||||
|
|
||||||
|
root = ET.fromstring(ReferenceSubstanceBuilder().build(data))
|
||||||
|
|
||||||
|
assert_xpath_absent(self, root, f".//{{{NS_REFERENCE_SUBSTANCE}}}Inventory")
|
||||||
|
assert_xpath_absent(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f".//{{{NS_REFERENCE_SUBSTANCE}}}MolecularWeightRange",
|
||||||
|
)
|
||||||
|
assert_xpath_text(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f".//{{{NS_REFERENCE_SUBSTANCE}}}MolecularStructuralInfo/{{{NS_REFERENCE_SUBSTANCE}}}SmilesNotation",
|
||||||
|
"CC",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tag("iuclid")
|
||||||
|
class EndpointStudyRecordBuilderContractTest(SimpleTestCase):
|
||||||
|
def test_sets_document_metadata_and_parent_link(self):
|
||||||
|
substance_uuid = uuid4()
|
||||||
|
data = make_endpoint_study_record_data(substance_uuid=substance_uuid)
|
||||||
|
|
||||||
|
root = ET.fromstring(EndpointStudyRecordBuilder().build(data))
|
||||||
|
|
||||||
|
metadata_root = f"{{{NS_PLATFORM_CONTAINER}}}PlatformMetadata"
|
||||||
|
assert_xpath_text(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f"{metadata_root}/{{{NS_PLATFORM_METADATA}}}documentType",
|
||||||
|
"ENDPOINT_STUDY_RECORD",
|
||||||
|
)
|
||||||
|
assert_xpath_text(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f"{metadata_root}/{{{NS_PLATFORM_METADATA}}}documentSubType",
|
||||||
|
DOC_SUBTYPE,
|
||||||
|
)
|
||||||
|
assert_xpath_text(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f"{metadata_root}/{{{NS_PLATFORM_METADATA}}}parentDocumentKey",
|
||||||
|
document_key(substance_uuid),
|
||||||
|
)
|
||||||
|
assert_xpath_text(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f"{metadata_root}/{{{NS_PLATFORM_METADATA}}}orderInSectionNo",
|
||||||
|
"1",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_esr_metadata_order_uses_stax_safe_layout(self):
|
||||||
|
data = make_endpoint_study_record_data()
|
||||||
|
|
||||||
|
root = ET.fromstring(EndpointStudyRecordBuilder().build(data))
|
||||||
|
metadata = root.find(f"{{{NS_PLATFORM_CONTAINER}}}PlatformMetadata")
|
||||||
|
|
||||||
|
self.assertIsNotNone(metadata)
|
||||||
|
assert metadata is not None
|
||||||
|
child_tags = [el.tag.split("}", 1)[1] for el in list(metadata)]
|
||||||
|
self.assertEqual(
|
||||||
|
child_tags,
|
||||||
|
[
|
||||||
|
"iuclidVersion",
|
||||||
|
"documentKey",
|
||||||
|
"documentType",
|
||||||
|
"definitionVersion",
|
||||||
|
"creationDate",
|
||||||
|
"lastModificationDate",
|
||||||
|
"name",
|
||||||
|
"documentSubType",
|
||||||
|
"parentDocumentKey",
|
||||||
|
"orderInSectionNo",
|
||||||
|
"i5Origin",
|
||||||
|
"creationTool",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_omits_results_for_skeleton_payload(self):
|
||||||
|
data = make_endpoint_study_record_data()
|
||||||
|
|
||||||
|
root = ET.fromstring(EndpointStudyRecordBuilder().build(data))
|
||||||
|
|
||||||
|
assert_xpath_absent(self, root, f".//{{{NS_ESR_BIODEG}}}ResultsAndDiscussion")
|
||||||
|
|
||||||
|
def test_maps_half_life_and_temperature_ranges(self):
|
||||||
|
data = make_endpoint_study_record_data(
|
||||||
|
half_lives=[make_half_life_entry(model="SFO", dt50_start=12.5, dt50_end=15.0)],
|
||||||
|
temperature=(20.0, 20.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
root = ET.fromstring(EndpointStudyRecordBuilder().build(data))
|
||||||
|
|
||||||
|
base = (
|
||||||
|
f".//{{{NS_ESR_BIODEG}}}ResultsAndDiscussion"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}DTParentCompound"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}entry"
|
||||||
|
)
|
||||||
|
assert_xpath_text(self, root, f"{base}/{{{NS_ESR_BIODEG}}}KineticParameters", "SFO")
|
||||||
|
assert_xpath_text(
|
||||||
|
self, root, f"{base}/{{{NS_ESR_BIODEG}}}Value/{{{NS_ESR_BIODEG}}}lowerValue", "12.5"
|
||||||
|
)
|
||||||
|
assert_xpath_text(
|
||||||
|
self, root, f"{base}/{{{NS_ESR_BIODEG}}}Value/{{{NS_ESR_BIODEG}}}upperValue", "15.0"
|
||||||
|
)
|
||||||
|
assert_xpath_text(
|
||||||
|
self, root, f"{base}/{{{NS_ESR_BIODEG}}}Temp/{{{NS_ESR_BIODEG}}}lowerValue", "20.0"
|
||||||
|
)
|
||||||
|
assert_xpath_text(
|
||||||
|
self, root, f"{base}/{{{NS_ESR_BIODEG}}}Temp/{{{NS_ESR_BIODEG}}}upperValue", "20.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_maps_soil_no_on_dt_entries(self):
|
||||||
|
data = make_endpoint_study_record_data(
|
||||||
|
half_lives=[
|
||||||
|
make_half_life_entry(
|
||||||
|
model="SFO",
|
||||||
|
dt50_start=12.5,
|
||||||
|
dt50_end=15.0,
|
||||||
|
soil_no_code="2",
|
||||||
|
temperature=(22.0, 22.0),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
temperature=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
root = ET.fromstring(EndpointStudyRecordBuilder().build(data))
|
||||||
|
base = (
|
||||||
|
f".//{{{NS_ESR_BIODEG}}}ResultsAndDiscussion"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}DTParentCompound"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}entry"
|
||||||
|
)
|
||||||
|
assert_xpath_text(
|
||||||
|
self, root, f"{base}/{{{NS_ESR_BIODEG}}}SoilNo/{{{NS_ESR_BIODEG}}}value", "2"
|
||||||
|
)
|
||||||
|
assert_xpath_text(
|
||||||
|
self, root, f"{base}/{{{NS_ESR_BIODEG}}}Temp/{{{NS_ESR_BIODEG}}}lowerValue", "22.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_maps_transformation_entries_and_model_context(self):
|
||||||
|
parent_ref_uuid = uuid4()
|
||||||
|
product_ref_uuid = uuid4()
|
||||||
|
data = make_endpoint_study_record_data(
|
||||||
|
transformation_products=[
|
||||||
|
make_transformation_entry(
|
||||||
|
parent_reference_uuids=[parent_ref_uuid],
|
||||||
|
product_reference_uuid=product_ref_uuid,
|
||||||
|
kinetic_formation_fraction=0.42,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
model_name_and_version=["Test model 1.0"],
|
||||||
|
software_name_and_version=["enviPath"],
|
||||||
|
model_remarks=["Model UUID: 00000000-0000-0000-0000-000000000000"],
|
||||||
|
)
|
||||||
|
|
||||||
|
root = ET.fromstring(EndpointStudyRecordBuilder().build(data))
|
||||||
|
|
||||||
|
assert_xpath_text(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f".//{{{NS_ESR_BIODEG}}}MaterialsAndMethods"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}ModelAndSoftware"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}ModelNameAndVersion",
|
||||||
|
"Test model 1.0",
|
||||||
|
)
|
||||||
|
entry_base = (
|
||||||
|
f".//{{{NS_ESR_BIODEG}}}ResultsAndDiscussion"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}TransformationProductsDetails"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}entry"
|
||||||
|
)
|
||||||
|
assert_xpath_text(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f"{entry_base}/{{{NS_ESR_BIODEG}}}IdentityOfCompound",
|
||||||
|
document_key(product_ref_uuid),
|
||||||
|
)
|
||||||
|
assert_xpath_text(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f"{entry_base}/{{{NS_ESR_BIODEG}}}ParentCompoundS/{{{NS_PLATFORM_FIELDS}}}key",
|
||||||
|
document_key(parent_ref_uuid),
|
||||||
|
)
|
||||||
|
assert_xpath_text(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f"{entry_base}/{{{NS_ESR_BIODEG}}}KineticFormationFraction",
|
||||||
|
"0.42",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_temperature_without_half_lives_in_xml(self):
|
||||||
|
"""Temperature with no half-lives still renders a DTParentCompound entry."""
|
||||||
|
data = make_endpoint_study_record_data(temperature=(21.0, 21.0))
|
||||||
|
root = ET.fromstring(EndpointStudyRecordBuilder().build(data))
|
||||||
|
base = (
|
||||||
|
f".//{{{NS_ESR_BIODEG}}}ResultsAndDiscussion"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}DTParentCompound"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}entry"
|
||||||
|
)
|
||||||
|
assert_xpath_text(
|
||||||
|
self, root, f"{base}/{{{NS_ESR_BIODEG}}}Temp/{{{NS_ESR_BIODEG}}}lowerValue", "21.0"
|
||||||
|
)
|
||||||
|
assert_xpath_text(
|
||||||
|
self, root, f"{base}/{{{NS_ESR_BIODEG}}}Temp/{{{NS_ESR_BIODEG}}}upperValue", "21.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_temperature_interval_in_xml(self):
|
||||||
|
"""Temperature tuple renders as lowerValue/upperValue in Temp element."""
|
||||||
|
hl = make_half_life_entry()
|
||||||
|
data = make_endpoint_study_record_data(half_lives=[hl], temperature=(20.0, 25.0))
|
||||||
|
root = ET.fromstring(EndpointStudyRecordBuilder().build(data))
|
||||||
|
base = (
|
||||||
|
f".//{{{NS_ESR_BIODEG}}}ResultsAndDiscussion"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}DTParentCompound"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}entry"
|
||||||
|
)
|
||||||
|
assert_xpath_text(
|
||||||
|
self, root, f"{base}/{{{NS_ESR_BIODEG}}}Temp/{{{NS_ESR_BIODEG}}}lowerValue", "20.0"
|
||||||
|
)
|
||||||
|
assert_xpath_text(
|
||||||
|
self, root, f"{base}/{{{NS_ESR_BIODEG}}}Temp/{{{NS_ESR_BIODEG}}}upperValue", "25.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_esr_with_soil_properties_emits_structured_soil_by_default(self):
|
||||||
|
props = make_soil_properties_data(clay=15.0, silt=35.0, sand=50.0)
|
||||||
|
data = make_endpoint_study_record_data(soil_properties=props)
|
||||||
|
root = ET.fromstring(EndpointStudyRecordBuilder().build(data))
|
||||||
|
|
||||||
|
entry_path = (
|
||||||
|
f".//{{{NS_ESR_BIODEG}}}StudyDesign"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}SoilProperties"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}entry"
|
||||||
|
)
|
||||||
|
assert_xpath_text(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f"{entry_path}/{{{NS_ESR_BIODEG}}}Clay/{{{NS_ESR_BIODEG}}}lowerValue",
|
||||||
|
"15.0",
|
||||||
|
)
|
||||||
|
assert_xpath_text(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f"{entry_path}/{{{NS_ESR_BIODEG}}}Silt/{{{NS_ESR_BIODEG}}}lowerValue",
|
||||||
|
"35.0",
|
||||||
|
)
|
||||||
|
assert_xpath_text(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f"{entry_path}/{{{NS_ESR_BIODEG}}}Sand/{{{NS_ESR_BIODEG}}}lowerValue",
|
||||||
|
"50.0",
|
||||||
|
)
|
||||||
|
assert_xpath_text(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f".//{{{NS_ESR_BIODEG}}}StudyDesign"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}SoilClassification"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}value",
|
||||||
|
"1649",
|
||||||
|
)
|
||||||
|
assert_xpath_absent(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f".//{{{NS_ESR_BIODEG}}}StudyDesign"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}SoilClassification"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}other",
|
||||||
|
)
|
||||||
|
assert_xpath_text(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f"{entry_path}/{{{NS_ESR_BIODEG}}}SoilType/{{{NS_ESR_BIODEG}}}value",
|
||||||
|
"1026",
|
||||||
|
)
|
||||||
|
assert_xpath_absent(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f"{entry_path}/{{{NS_ESR_BIODEG}}}SoilType/{{{NS_ESR_BIODEG}}}other",
|
||||||
|
)
|
||||||
|
assert_xpath_absent(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f".//{{{NS_ESR_BIODEG}}}AnyOtherInformationOnMaterialsAndMethodsInclTables",
|
||||||
|
)
|
||||||
|
assert_xpath_absent(self, root, f".//{{{NS_ESR_BIODEG}}}DetailsOnSoilCharacteristics")
|
||||||
|
|
||||||
|
def test_maps_multiple_soil_entries_with_soil_no(self):
|
||||||
|
data = make_endpoint_study_record_data(
|
||||||
|
soil_properties_entries=[
|
||||||
|
make_soil_properties_data(soil_no_code="2", soil_type="LOAMY_SAND", sand=83.1),
|
||||||
|
make_soil_properties_data(soil_no_code="4", soil_type="CLAY_LOAM", sand=23.7),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
root = ET.fromstring(EndpointStudyRecordBuilder().build(data))
|
||||||
|
|
||||||
|
entries = root.findall(
|
||||||
|
f".//{{{NS_ESR_BIODEG}}}StudyDesign"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}SoilProperties"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}entry"
|
||||||
|
)
|
||||||
|
self.assertEqual(len(entries), 2)
|
||||||
|
|
||||||
|
soil_no_values = [
|
||||||
|
entry.findtext(f"{{{NS_ESR_BIODEG}}}SoilNo/{{{NS_ESR_BIODEG}}}value")
|
||||||
|
for entry in entries
|
||||||
|
]
|
||||||
|
self.assertEqual(soil_no_values, ["2", "4"])
|
||||||
|
|
||||||
|
def test_maps_soil_type_and_soil_classification_to_structured_fields(self):
|
||||||
|
props = make_soil_properties_data(soil_type="LOAMY_SAND", soil_classification="USDA")
|
||||||
|
data = make_endpoint_study_record_data(soil_properties=props)
|
||||||
|
root = ET.fromstring(EndpointStudyRecordBuilder().build(data))
|
||||||
|
|
||||||
|
entry_path = (
|
||||||
|
f".//{{{NS_ESR_BIODEG}}}StudyDesign"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}SoilProperties"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}entry"
|
||||||
|
)
|
||||||
|
assert_xpath_text(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f"{entry_path}/{{{NS_ESR_BIODEG}}}SoilType/{{{NS_ESR_BIODEG}}}value",
|
||||||
|
"1027",
|
||||||
|
)
|
||||||
|
assert_xpath_absent(
|
||||||
|
self, root, f"{entry_path}/{{{NS_ESR_BIODEG}}}SoilType/{{{NS_ESR_BIODEG}}}other"
|
||||||
|
)
|
||||||
|
assert_xpath_text(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f".//{{{NS_ESR_BIODEG}}}StudyDesign"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}SoilClassification"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}value",
|
||||||
|
"1649",
|
||||||
|
)
|
||||||
|
assert_xpath_absent(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f".//{{{NS_ESR_BIODEG}}}StudyDesign"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}SoilClassification"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}other",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unknown_soil_type_and_classification_use_open_picklist(self):
|
||||||
|
props = make_soil_properties_data(soil_type="SILTY_SAND", soil_classification="UK_ADAS")
|
||||||
|
data = make_endpoint_study_record_data(soil_properties=props)
|
||||||
|
root = ET.fromstring(EndpointStudyRecordBuilder().build(data))
|
||||||
|
|
||||||
|
entry_path = (
|
||||||
|
f".//{{{NS_ESR_BIODEG}}}StudyDesign"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}SoilProperties"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}entry"
|
||||||
|
)
|
||||||
|
assert_xpath_text(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f"{entry_path}/{{{NS_ESR_BIODEG}}}SoilType/{{{NS_ESR_BIODEG}}}value",
|
||||||
|
"1342",
|
||||||
|
)
|
||||||
|
assert_xpath_text(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f"{entry_path}/{{{NS_ESR_BIODEG}}}SoilType/{{{NS_ESR_BIODEG}}}other",
|
||||||
|
"SILTY SAND",
|
||||||
|
)
|
||||||
|
assert_xpath_text(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f".//{{{NS_ESR_BIODEG}}}StudyDesign"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}SoilClassification"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}value",
|
||||||
|
"1342",
|
||||||
|
)
|
||||||
|
assert_xpath_text(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f".//{{{NS_ESR_BIODEG}}}StudyDesign"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}SoilClassification"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}other",
|
||||||
|
"UK ADAS",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_infers_usda_soil_classification_from_soil_type(self):
|
||||||
|
props = make_soil_properties_data(soil_type="LOAMY_SAND", soil_classification=None)
|
||||||
|
data = make_endpoint_study_record_data(soil_properties=props)
|
||||||
|
root = ET.fromstring(EndpointStudyRecordBuilder().build(data))
|
||||||
|
|
||||||
|
assert_xpath_text(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f".//{{{NS_ESR_BIODEG}}}StudyDesign"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}SoilClassification"
|
||||||
|
f"/{{{NS_ESR_BIODEG}}}value",
|
||||||
|
"1649",
|
||||||
|
)
|
||||||
|
assert_xpath_absent(
|
||||||
|
self,
|
||||||
|
root,
|
||||||
|
f".//{{{NS_ESR_BIODEG}}}StudyDesign/{{{NS_ESR_BIODEG}}}SoilClassification/{{{NS_ESR_BIODEG}}}other",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_esr_without_soil_properties_omits_study_design(self):
|
||||||
|
"""ESR with soil_properties=None → no <StudyDesign> in XML."""
|
||||||
|
data = make_endpoint_study_record_data(soil_properties=None)
|
||||||
|
root = ET.fromstring(EndpointStudyRecordBuilder().build(data))
|
||||||
|
assert_xpath_absent(self, root, f".//{{{NS_ESR_BIODEG}}}StudyDesign")
|
||||||
|
|
||||||
|
def test_omits_empty_ph_measured_in(self):
|
||||||
|
props = make_soil_properties_data(ph_method="")
|
||||||
|
data = make_endpoint_study_record_data(soil_properties=props)
|
||||||
|
|
||||||
|
root = ET.fromstring(EndpointStudyRecordBuilder().build(data))
|
||||||
|
|
||||||
|
self.assertNotIn("PHMeasuredIn", ET.tostring(root, encoding="unicode"))
|
||||||
199
epiuclid/tests/test_i6z.py
Normal file
199
epiuclid/tests/test_i6z.py
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
"""Tests for i6z archive assembly."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
import zipfile
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from django.test import SimpleTestCase, tag
|
||||||
|
|
||||||
|
from epiuclid.serializers.i6z import I6ZSerializer
|
||||||
|
from epiuclid.serializers.pathway_mapper import (
|
||||||
|
IUCLIDDocumentBundle,
|
||||||
|
IUCLIDEndpointStudyRecordData,
|
||||||
|
IUCLIDReferenceSubstanceData,
|
||||||
|
IUCLIDSubstanceData,
|
||||||
|
IUCLIDTransformationProductEntry,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_bundle() -> IUCLIDDocumentBundle:
|
||||||
|
ref_uuid = uuid4()
|
||||||
|
sub_uuid = uuid4()
|
||||||
|
return IUCLIDDocumentBundle(
|
||||||
|
substances=[
|
||||||
|
IUCLIDSubstanceData(
|
||||||
|
uuid=sub_uuid,
|
||||||
|
name="Benzene",
|
||||||
|
reference_substance_uuid=ref_uuid,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
reference_substances=[
|
||||||
|
IUCLIDReferenceSubstanceData(
|
||||||
|
uuid=ref_uuid,
|
||||||
|
name="Benzene",
|
||||||
|
smiles="c1ccccc1",
|
||||||
|
cas_number="71-43-2",
|
||||||
|
molecular_formula="C6H6",
|
||||||
|
molecular_weight=78.11,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
endpoint_study_records=[
|
||||||
|
IUCLIDEndpointStudyRecordData(
|
||||||
|
uuid=uuid4(),
|
||||||
|
substance_uuid=sub_uuid,
|
||||||
|
name="Endpoint study - Benzene",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_bundle_with_transformation_links() -> tuple[IUCLIDDocumentBundle, str, str]:
|
||||||
|
parent_ref_uuid = uuid4()
|
||||||
|
product_ref_uuid = uuid4()
|
||||||
|
sub_uuid = uuid4()
|
||||||
|
|
||||||
|
bundle = IUCLIDDocumentBundle(
|
||||||
|
substances=[
|
||||||
|
IUCLIDSubstanceData(
|
||||||
|
uuid=sub_uuid,
|
||||||
|
name="Benzene",
|
||||||
|
reference_substance_uuid=parent_ref_uuid,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
reference_substances=[
|
||||||
|
IUCLIDReferenceSubstanceData(uuid=parent_ref_uuid, name="Benzene", smiles="c1ccccc1"),
|
||||||
|
IUCLIDReferenceSubstanceData(
|
||||||
|
uuid=product_ref_uuid, name="Phenol", smiles="c1ccc(O)cc1"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
endpoint_study_records=[
|
||||||
|
IUCLIDEndpointStudyRecordData(
|
||||||
|
uuid=uuid4(),
|
||||||
|
substance_uuid=sub_uuid,
|
||||||
|
name="Endpoint study - Benzene",
|
||||||
|
transformation_products=[
|
||||||
|
IUCLIDTransformationProductEntry(
|
||||||
|
uuid=uuid4(),
|
||||||
|
product_reference_uuid=product_ref_uuid,
|
||||||
|
parent_reference_uuids=[parent_ref_uuid],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return bundle, f"{parent_ref_uuid}/0", f"{product_ref_uuid}/0"
|
||||||
|
|
||||||
|
|
||||||
|
@tag("iuclid")
|
||||||
|
class I6ZSerializerTest(SimpleTestCase):
|
||||||
|
def test_output_is_valid_zip(self):
|
||||||
|
bundle = _make_bundle()
|
||||||
|
data = I6ZSerializer().serialize(bundle)
|
||||||
|
self.assertTrue(zipfile.is_zipfile(io.BytesIO(data)))
|
||||||
|
|
||||||
|
def test_contains_manifest(self):
|
||||||
|
bundle = _make_bundle()
|
||||||
|
data = I6ZSerializer().serialize(bundle)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||||
|
self.assertIn("manifest.xml", zf.namelist())
|
||||||
|
|
||||||
|
def test_contains_i6d_files(self):
|
||||||
|
bundle = _make_bundle()
|
||||||
|
data = I6ZSerializer().serialize(bundle)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||||
|
names = zf.namelist()
|
||||||
|
# manifest + 1 substance + 1 ref substance + 1 ESR = 4 files
|
||||||
|
self.assertEqual(len(names), 4)
|
||||||
|
i6d_files = [n for n in names if n.endswith(".i6d")]
|
||||||
|
self.assertEqual(len(i6d_files), 3)
|
||||||
|
|
||||||
|
def test_manifest_references_all_documents(self):
|
||||||
|
bundle = _make_bundle()
|
||||||
|
data = I6ZSerializer().serialize(bundle)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||||
|
manifest_xml = zf.read("manifest.xml").decode("utf-8")
|
||||||
|
root = ET.fromstring(manifest_xml)
|
||||||
|
|
||||||
|
ns = "http://iuclid6.echa.europa.eu/namespaces/manifest/v1"
|
||||||
|
docs = root.findall(f".//{{{ns}}}document")
|
||||||
|
self.assertEqual(len(docs), 3)
|
||||||
|
|
||||||
|
types = set()
|
||||||
|
for doc in docs:
|
||||||
|
type_elem = doc.find(f"{{{ns}}}type")
|
||||||
|
self.assertIsNotNone(type_elem)
|
||||||
|
assert type_elem is not None
|
||||||
|
types.add(type_elem.text)
|
||||||
|
self.assertEqual(types, {"SUBSTANCE", "REFERENCE_SUBSTANCE", "ENDPOINT_STUDY_RECORD"})
|
||||||
|
|
||||||
|
def test_manifest_contains_expected_document_links(self):
|
||||||
|
bundle = _make_bundle()
|
||||||
|
data = I6ZSerializer().serialize(bundle)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||||
|
manifest_xml = zf.read("manifest.xml").decode("utf-8")
|
||||||
|
root = ET.fromstring(manifest_xml)
|
||||||
|
|
||||||
|
ns = "http://iuclid6.echa.europa.eu/namespaces/manifest/v1"
|
||||||
|
docs = root.findall(f".//{{{ns}}}document")
|
||||||
|
|
||||||
|
links_by_type: dict[str, set[tuple[str | None, str | None]]] = {}
|
||||||
|
for doc in docs:
|
||||||
|
doc_type = doc.findtext(f"{{{ns}}}type")
|
||||||
|
links = set()
|
||||||
|
for link in doc.findall(f"{{{ns}}}links/{{{ns}}}link"):
|
||||||
|
links.add(
|
||||||
|
(
|
||||||
|
link.findtext(f"{{{ns}}}ref-type"),
|
||||||
|
link.findtext(f"{{{ns}}}ref-uuid"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if doc_type:
|
||||||
|
links_by_type[doc_type] = links
|
||||||
|
|
||||||
|
self.assertIn("REFERENCE", {ref_type for ref_type, _ in links_by_type["SUBSTANCE"]})
|
||||||
|
self.assertIn("CHILD", {ref_type for ref_type, _ in links_by_type["SUBSTANCE"]})
|
||||||
|
self.assertIn(
|
||||||
|
"PARENT", {ref_type for ref_type, _ in links_by_type["ENDPOINT_STUDY_RECORD"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_i6d_files_are_valid_xml(self):
|
||||||
|
bundle = _make_bundle()
|
||||||
|
data = I6ZSerializer().serialize(bundle)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||||
|
for name in zf.namelist():
|
||||||
|
if name.endswith(".i6d"):
|
||||||
|
content = zf.read(name).decode("utf-8")
|
||||||
|
# Should not raise
|
||||||
|
ET.fromstring(content)
|
||||||
|
|
||||||
|
def test_manifest_links_esr_to_transformation_reference_substances(self):
|
||||||
|
bundle, parent_ref_key, product_ref_key = _make_bundle_with_transformation_links()
|
||||||
|
data = I6ZSerializer().serialize(bundle)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||||
|
manifest_xml = zf.read("manifest.xml").decode("utf-8")
|
||||||
|
root = ET.fromstring(manifest_xml)
|
||||||
|
|
||||||
|
ns = "http://iuclid6.echa.europa.eu/namespaces/manifest/v1"
|
||||||
|
esr_doc = None
|
||||||
|
for doc in root.findall(f".//{{{ns}}}document"):
|
||||||
|
if doc.findtext(f"{{{ns}}}type") == "ENDPOINT_STUDY_RECORD":
|
||||||
|
esr_doc = doc
|
||||||
|
break
|
||||||
|
|
||||||
|
self.assertIsNotNone(esr_doc)
|
||||||
|
assert esr_doc is not None
|
||||||
|
|
||||||
|
reference_links = {
|
||||||
|
link.findtext(f"{{{ns}}}ref-uuid")
|
||||||
|
for link in esr_doc.findall(f"{{{ns}}}links/{{{ns}}}link")
|
||||||
|
if link.findtext(f"{{{ns}}}ref-type") == "REFERENCE"
|
||||||
|
}
|
||||||
|
self.assertIn(parent_ref_key, reference_links)
|
||||||
|
self.assertIn(product_ref_key, reference_links)
|
||||||
86
epiuclid/tests/test_iuclid_export.py
Normal file
86
epiuclid/tests/test_iuclid_export.py
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
"""
|
||||||
|
Tests for the IUCLID projection layer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.test import TestCase, tag
|
||||||
|
|
||||||
|
from envipy_additional_information.information import Temperature
|
||||||
|
|
||||||
|
from epdb.logic import PackageManager, UserManager
|
||||||
|
from epdb.models import AdditionalInformation, Pathway, Scenario
|
||||||
|
from epapi.v1.interfaces.iuclid.projections import get_pathway_for_iuclid_export
|
||||||
|
|
||||||
|
|
||||||
|
@tag("api", "iuclid")
|
||||||
|
class IUCLIDExportScenarioAITests(TestCase):
|
||||||
|
"""Test that scenario additional information is correctly reflected in the IUCLID export."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.user = UserManager.create_user(
|
||||||
|
"iuclid-test-user",
|
||||||
|
"iuclid-test@envipath.com",
|
||||||
|
"SuperSafe",
|
||||||
|
set_setting=False,
|
||||||
|
add_to_group=False,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
cls.package = PackageManager.create_package(
|
||||||
|
cls.user, "IUCLID Test Package", "Package for IUCLID export tests"
|
||||||
|
)
|
||||||
|
cls.pathway = Pathway.create(cls.package, "CCO", name="Test Pathway")
|
||||||
|
cls.root_node = cls.pathway.node_set.get(depth=0)
|
||||||
|
cls.compound = cls.root_node.default_node_label.compound
|
||||||
|
cls.scenario = Scenario.objects.create(
|
||||||
|
package=cls.package,
|
||||||
|
name="Test Scenario",
|
||||||
|
scenario_type="biodegradation",
|
||||||
|
scenario_date="2024-01-01",
|
||||||
|
)
|
||||||
|
cls.root_node.scenarios.add(cls.scenario)
|
||||||
|
|
||||||
|
def test_scenario_with_no_ai_exports_without_error(self):
|
||||||
|
"""Scenario attached to a node but with no AI must export cleanly with empty lists.
|
||||||
|
|
||||||
|
Regression: previously scenario.parent was accessed here, causing AttributeError.
|
||||||
|
"""
|
||||||
|
result = get_pathway_for_iuclid_export(self.user, self.pathway.uuid)
|
||||||
|
|
||||||
|
self.assertEqual(len(result.nodes), 1)
|
||||||
|
node = result.nodes[0]
|
||||||
|
self.assertEqual(len(node.scenarios), 1)
|
||||||
|
self.assertEqual(node.scenarios[0].scenario_uuid, self.scenario.uuid)
|
||||||
|
self.assertEqual(node.scenarios[0].additional_info, [])
|
||||||
|
self.assertEqual(node.additional_info, [])
|
||||||
|
|
||||||
|
def test_direct_scenario_ai_appears_in_export(self):
|
||||||
|
"""AI added directly to a scenario (no object binding) must appear in the export DTO."""
|
||||||
|
ai = Temperature(interval={"start": 20, "end": 25})
|
||||||
|
AdditionalInformation.create(self.package, ai, scenario=self.scenario)
|
||||||
|
|
||||||
|
result = get_pathway_for_iuclid_export(self.user, self.pathway.uuid)
|
||||||
|
|
||||||
|
node = result.nodes[0]
|
||||||
|
self.assertEqual(len(node.scenarios), 1)
|
||||||
|
scenario_dto = node.scenarios[0]
|
||||||
|
self.assertEqual(len(scenario_dto.additional_info), 1)
|
||||||
|
# The same AI must roll up to the node level
|
||||||
|
self.assertEqual(len(node.additional_info), 1)
|
||||||
|
self.assertEqual(node.additional_info[0], scenario_dto.additional_info[0])
|
||||||
|
|
||||||
|
def test_object_bound_ai_excluded_from_scenario_direct_info(self):
|
||||||
|
"""AI bound to a specific object (object_id set) must not appear in direct scenario info.
|
||||||
|
|
||||||
|
get_additional_information(direct_only=True) filters object_id__isnull=False.
|
||||||
|
"""
|
||||||
|
ai = Temperature(interval={"start": 10, "end": 15})
|
||||||
|
AdditionalInformation.create(
|
||||||
|
self.package, ai, scenario=self.scenario, content_object=self.compound
|
||||||
|
)
|
||||||
|
|
||||||
|
result = get_pathway_for_iuclid_export(self.user, self.pathway.uuid)
|
||||||
|
|
||||||
|
node = result.nodes[0]
|
||||||
|
scenario_dto = node.scenarios[0]
|
||||||
|
self.assertEqual(scenario_dto.additional_info, [])
|
||||||
|
self.assertEqual(node.additional_info, [])
|
||||||
512
epiuclid/tests/test_pathway_mapper.py
Normal file
512
epiuclid/tests/test_pathway_mapper.py
Normal file
@ -0,0 +1,512 @@
|
|||||||
|
"""Tests for PathwayMapper - no DB needed, uses DTO fixtures."""
|
||||||
|
|
||||||
|
from django.test import SimpleTestCase, tag
|
||||||
|
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from epapi.v1.interfaces.iuclid.dto import (
|
||||||
|
PathwayCompoundDTO,
|
||||||
|
PathwayEdgeDTO,
|
||||||
|
PathwayExportDTO,
|
||||||
|
PathwayNodeDTO,
|
||||||
|
PathwayScenarioDTO,
|
||||||
|
)
|
||||||
|
from epiuclid.serializers.pathway_mapper import PathwayMapper
|
||||||
|
|
||||||
|
|
||||||
|
@tag("iuclid")
|
||||||
|
class PathwayMapperTest(SimpleTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.compounds = [
|
||||||
|
PathwayCompoundDTO(pk=1, name="Benzene", smiles="c1ccccc1"),
|
||||||
|
PathwayCompoundDTO(pk=2, name="Phenol", smiles="c1ccc(O)cc1"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_mapper_produces_bundle(self):
|
||||||
|
export = PathwayExportDTO(
|
||||||
|
pathway_uuid=uuid4(),
|
||||||
|
pathway_name="Test Pathway",
|
||||||
|
compounds=self.compounds,
|
||||||
|
root_compound_pks=[1],
|
||||||
|
)
|
||||||
|
bundle = PathwayMapper().map(export)
|
||||||
|
|
||||||
|
self.assertEqual(len(bundle.substances), 2)
|
||||||
|
self.assertEqual(len(bundle.reference_substances), 2)
|
||||||
|
self.assertEqual(len(bundle.endpoint_study_records), 1)
|
||||||
|
|
||||||
|
def test_mapper_deduplicates_compounds(self):
|
||||||
|
compounds_with_dup = [
|
||||||
|
PathwayCompoundDTO(pk=1, name="Benzene", smiles="c1ccccc1"),
|
||||||
|
PathwayCompoundDTO(pk=2, name="Phenol", smiles="c1ccc(O)cc1"),
|
||||||
|
PathwayCompoundDTO(pk=1, name="Benzene", smiles="c1ccccc1"),
|
||||||
|
]
|
||||||
|
export = PathwayExportDTO(
|
||||||
|
pathway_uuid=uuid4(),
|
||||||
|
pathway_name="Test Pathway",
|
||||||
|
compounds=compounds_with_dup,
|
||||||
|
root_compound_pks=[1],
|
||||||
|
)
|
||||||
|
bundle = PathwayMapper().map(export)
|
||||||
|
|
||||||
|
# 2 unique compounds -> 2 substances, 2 ref substances
|
||||||
|
self.assertEqual(len(bundle.substances), 2)
|
||||||
|
self.assertEqual(len(bundle.reference_substances), 2)
|
||||||
|
# One endpoint study record per pathway
|
||||||
|
self.assertEqual(len(bundle.endpoint_study_records), 1)
|
||||||
|
|
||||||
|
def test_mapper_extracts_smiles(self):
|
||||||
|
export = PathwayExportDTO(
|
||||||
|
pathway_uuid=uuid4(),
|
||||||
|
pathway_name="Test Pathway",
|
||||||
|
compounds=self.compounds,
|
||||||
|
root_compound_pks=[1],
|
||||||
|
)
|
||||||
|
bundle = PathwayMapper().map(export)
|
||||||
|
|
||||||
|
smiles_values = [s.smiles for s in bundle.reference_substances]
|
||||||
|
self.assertTrue(all(s is not None for s in smiles_values))
|
||||||
|
|
||||||
|
def test_mapper_extracts_cas_when_present(self):
|
||||||
|
compounds = [
|
||||||
|
PathwayCompoundDTO(pk=1, name="Benzene", smiles="c1ccccc1", cas_number="71-43-2"),
|
||||||
|
PathwayCompoundDTO(pk=2, name="Phenol", smiles="c1ccc(O)cc1"),
|
||||||
|
]
|
||||||
|
export = PathwayExportDTO(
|
||||||
|
pathway_uuid=uuid4(),
|
||||||
|
pathway_name="Test Pathway",
|
||||||
|
compounds=compounds,
|
||||||
|
root_compound_pks=[1],
|
||||||
|
)
|
||||||
|
bundle = PathwayMapper().map(export)
|
||||||
|
|
||||||
|
cas_values = [r.cas_number for r in bundle.reference_substances]
|
||||||
|
self.assertIn("71-43-2", cas_values)
|
||||||
|
|
||||||
|
def test_mapper_builds_transformation_entries(self):
|
||||||
|
export = PathwayExportDTO(
|
||||||
|
pathway_uuid=uuid4(),
|
||||||
|
pathway_name="Test Pathway",
|
||||||
|
compounds=self.compounds,
|
||||||
|
edges=[
|
||||||
|
PathwayEdgeDTO(
|
||||||
|
edge_uuid=uuid4(),
|
||||||
|
start_compound_pks=[1],
|
||||||
|
end_compound_pks=[2],
|
||||||
|
probability=0.73,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
root_compound_pks=[1],
|
||||||
|
)
|
||||||
|
bundle = PathwayMapper().map(export)
|
||||||
|
|
||||||
|
self.assertEqual(len(bundle.endpoint_study_records), 1)
|
||||||
|
esr = bundle.endpoint_study_records[0]
|
||||||
|
self.assertEqual(len(esr.transformation_products), 1)
|
||||||
|
self.assertIsNone(esr.transformation_products[0].kinetic_formation_fraction)
|
||||||
|
|
||||||
|
def test_mapper_deduplicates_transformation_entries(self):
|
||||||
|
export = PathwayExportDTO(
|
||||||
|
pathway_uuid=uuid4(),
|
||||||
|
pathway_name="Test Pathway",
|
||||||
|
compounds=self.compounds,
|
||||||
|
edges=[
|
||||||
|
PathwayEdgeDTO(edge_uuid=uuid4(), start_compound_pks=[1], end_compound_pks=[2]),
|
||||||
|
PathwayEdgeDTO(edge_uuid=uuid4(), start_compound_pks=[1], end_compound_pks=[2]),
|
||||||
|
],
|
||||||
|
root_compound_pks=[1],
|
||||||
|
)
|
||||||
|
|
||||||
|
bundle = PathwayMapper().map(export)
|
||||||
|
esr = bundle.endpoint_study_records[0]
|
||||||
|
|
||||||
|
self.assertEqual(len(esr.transformation_products), 1)
|
||||||
|
|
||||||
|
def test_mapper_creates_endpoint_record_for_each_root_compound(self):
|
||||||
|
export = PathwayExportDTO(
|
||||||
|
pathway_uuid=uuid4(),
|
||||||
|
pathway_name="Test Pathway",
|
||||||
|
compounds=self.compounds,
|
||||||
|
root_compound_pks=[1, 2],
|
||||||
|
)
|
||||||
|
|
||||||
|
bundle = PathwayMapper().map(export)
|
||||||
|
|
||||||
|
self.assertEqual(len(bundle.endpoint_study_records), 2)
|
||||||
|
esr_names = {record.name for record in bundle.endpoint_study_records}
|
||||||
|
self.assertIn("Biodegradation in soil - Test Pathway (Benzene)", esr_names)
|
||||||
|
self.assertIn("Biodegradation in soil - Test Pathway (Phenol)", esr_names)
|
||||||
|
|
||||||
|
def test_mapper_builds_root_specific_transformations_for_disjoint_subgraphs(self):
|
||||||
|
compounds = [
|
||||||
|
PathwayCompoundDTO(pk=1, name="Root A", smiles="CC"),
|
||||||
|
PathwayCompoundDTO(pk=2, name="Root B", smiles="CCC"),
|
||||||
|
PathwayCompoundDTO(pk=3, name="A Child", smiles="CCCC"),
|
||||||
|
PathwayCompoundDTO(pk=4, name="B Child", smiles="CCCCC"),
|
||||||
|
]
|
||||||
|
export = PathwayExportDTO(
|
||||||
|
pathway_uuid=uuid4(),
|
||||||
|
pathway_name="Disjoint Pathway",
|
||||||
|
compounds=compounds,
|
||||||
|
edges=[
|
||||||
|
PathwayEdgeDTO(edge_uuid=uuid4(), start_compound_pks=[1], end_compound_pks=[3]),
|
||||||
|
PathwayEdgeDTO(edge_uuid=uuid4(), start_compound_pks=[2], end_compound_pks=[4]),
|
||||||
|
],
|
||||||
|
root_compound_pks=[1, 2],
|
||||||
|
)
|
||||||
|
|
||||||
|
bundle = PathwayMapper().map(export)
|
||||||
|
|
||||||
|
substance_name_by_uuid = {sub.uuid: sub.name for sub in bundle.substances}
|
||||||
|
reference_name_by_uuid = {ref.uuid: ref.name for ref in bundle.reference_substances}
|
||||||
|
|
||||||
|
products_by_root: dict[str, set[str]] = {}
|
||||||
|
for esr in bundle.endpoint_study_records:
|
||||||
|
root_name = substance_name_by_uuid[esr.substance_uuid]
|
||||||
|
products_by_root[root_name] = {
|
||||||
|
reference_name_by_uuid[tp.product_reference_uuid]
|
||||||
|
for tp in esr.transformation_products
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertEqual(products_by_root["Root A"], {"A Child"})
|
||||||
|
self.assertEqual(products_by_root["Root B"], {"B Child"})
|
||||||
|
|
||||||
|
def test_mapper_requires_all_edge_parents_to_be_reachable(self):
|
||||||
|
compounds = [
|
||||||
|
PathwayCompoundDTO(pk=1, name="Root", smiles="CC"),
|
||||||
|
PathwayCompoundDTO(pk=2, name="Co-reactant", smiles="CCC"),
|
||||||
|
PathwayCompoundDTO(pk=3, name="Product", smiles="CCCC"),
|
||||||
|
]
|
||||||
|
export = PathwayExportDTO(
|
||||||
|
pathway_uuid=uuid4(),
|
||||||
|
pathway_name="Multi Parent Pathway",
|
||||||
|
compounds=compounds,
|
||||||
|
edges=[
|
||||||
|
PathwayEdgeDTO(edge_uuid=uuid4(), start_compound_pks=[1, 2], end_compound_pks=[3]),
|
||||||
|
],
|
||||||
|
root_compound_pks=[1],
|
||||||
|
)
|
||||||
|
|
||||||
|
bundle = PathwayMapper().map(export)
|
||||||
|
esr = bundle.endpoint_study_records[0]
|
||||||
|
|
||||||
|
self.assertEqual(len(esr.transformation_products), 0)
|
||||||
|
|
||||||
|
def test_mapper_resolves_multi_parent_transformations_after_intermediate_is_reachable(self):
|
||||||
|
compounds = [
|
||||||
|
PathwayCompoundDTO(pk=1, name="Root", smiles="CC"),
|
||||||
|
PathwayCompoundDTO(pk=2, name="Intermediate", smiles="CCC"),
|
||||||
|
PathwayCompoundDTO(pk=3, name="Product", smiles="CCCC"),
|
||||||
|
]
|
||||||
|
export = PathwayExportDTO(
|
||||||
|
pathway_uuid=uuid4(),
|
||||||
|
pathway_name="Closure Pathway",
|
||||||
|
compounds=compounds,
|
||||||
|
edges=[
|
||||||
|
PathwayEdgeDTO(edge_uuid=uuid4(), start_compound_pks=[1], end_compound_pks=[2]),
|
||||||
|
PathwayEdgeDTO(edge_uuid=uuid4(), start_compound_pks=[1, 2], end_compound_pks=[3]),
|
||||||
|
],
|
||||||
|
root_compound_pks=[1],
|
||||||
|
)
|
||||||
|
|
||||||
|
bundle = PathwayMapper().map(export)
|
||||||
|
esr = bundle.endpoint_study_records[0]
|
||||||
|
reference_name_by_uuid = {ref.uuid: ref.name for ref in bundle.reference_substances}
|
||||||
|
product_names = {
|
||||||
|
reference_name_by_uuid[tp.product_reference_uuid] for tp in esr.transformation_products
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertEqual(product_names, {"Intermediate", "Product"})
|
||||||
|
|
||||||
|
def test_mapper_populates_half_lives_from_root_node_ai(self):
|
||||||
|
"""HalfLife AI on root node → ESR.half_lives."""
|
||||||
|
from envipy_additional_information.information import HalfLife, Interval
|
||||||
|
|
||||||
|
hl = HalfLife(
|
||||||
|
model="SFO", fit="ok", comment="", dt50=Interval(start=5.0, end=10.0), source="test"
|
||||||
|
)
|
||||||
|
root_node = PathwayNodeDTO(
|
||||||
|
node_uuid=uuid4(),
|
||||||
|
compound_pk=1,
|
||||||
|
name="Root",
|
||||||
|
depth=0,
|
||||||
|
smiles="CC",
|
||||||
|
additional_info=[hl],
|
||||||
|
)
|
||||||
|
export = PathwayExportDTO(
|
||||||
|
pathway_uuid=uuid4(),
|
||||||
|
pathway_name="P",
|
||||||
|
compounds=[PathwayCompoundDTO(pk=1, name="Root", smiles="CC")],
|
||||||
|
nodes=[root_node],
|
||||||
|
root_compound_pks=[1],
|
||||||
|
)
|
||||||
|
bundle = PathwayMapper().map(export)
|
||||||
|
esr = bundle.endpoint_study_records[0]
|
||||||
|
self.assertEqual(len(esr.half_lives), 1)
|
||||||
|
self.assertEqual(esr.half_lives[0].dt50_start, 5.0)
|
||||||
|
self.assertEqual(esr.half_lives[0].dt50_end, 10.0)
|
||||||
|
|
||||||
|
def test_mapper_populates_temperature_from_root_node_ai(self):
|
||||||
|
"""Temperature AI on root node → ESR.temperature as tuple."""
|
||||||
|
from envipy_additional_information.information import Temperature, Interval
|
||||||
|
|
||||||
|
temp = Temperature(interval=Interval(start=20.0, end=25.0))
|
||||||
|
root_node = PathwayNodeDTO(
|
||||||
|
node_uuid=uuid4(),
|
||||||
|
compound_pk=1,
|
||||||
|
name="Root",
|
||||||
|
depth=0,
|
||||||
|
smiles="CC",
|
||||||
|
additional_info=[temp],
|
||||||
|
)
|
||||||
|
export = PathwayExportDTO(
|
||||||
|
pathway_uuid=uuid4(),
|
||||||
|
pathway_name="P",
|
||||||
|
compounds=[PathwayCompoundDTO(pk=1, name="Root", smiles="CC")],
|
||||||
|
nodes=[root_node],
|
||||||
|
root_compound_pks=[1],
|
||||||
|
)
|
||||||
|
bundle = PathwayMapper().map(export)
|
||||||
|
esr = bundle.endpoint_study_records[0]
|
||||||
|
self.assertEqual(esr.temperature, (20.0, 25.0))
|
||||||
|
|
||||||
|
def test_mapper_ignores_ai_on_non_root_nodes(self):
|
||||||
|
"""AI from non-root nodes (depth > 0) should not appear in ESR."""
|
||||||
|
from envipy_additional_information.information import HalfLife, Interval
|
||||||
|
|
||||||
|
hl = HalfLife(
|
||||||
|
model="SFO", fit="ok", comment="", dt50=Interval(start=5.0, end=10.0), source="test"
|
||||||
|
)
|
||||||
|
non_root_node = PathwayNodeDTO(
|
||||||
|
node_uuid=uuid4(),
|
||||||
|
compound_pk=2,
|
||||||
|
name="Product",
|
||||||
|
depth=1,
|
||||||
|
smiles="CCC",
|
||||||
|
additional_info=[hl],
|
||||||
|
)
|
||||||
|
export = PathwayExportDTO(
|
||||||
|
pathway_uuid=uuid4(),
|
||||||
|
pathway_name="P",
|
||||||
|
compounds=[
|
||||||
|
PathwayCompoundDTO(pk=1, name="Root", smiles="CC"),
|
||||||
|
PathwayCompoundDTO(pk=2, name="Product", smiles="CCC"),
|
||||||
|
],
|
||||||
|
nodes=[non_root_node],
|
||||||
|
root_compound_pks=[1],
|
||||||
|
)
|
||||||
|
bundle = PathwayMapper().map(export)
|
||||||
|
esr = bundle.endpoint_study_records[0]
|
||||||
|
self.assertEqual(len(esr.half_lives), 0)
|
||||||
|
|
||||||
|
def test_extracts_soil_texture2_from_root_node_ai(self):
|
||||||
|
"""SoilTexture2 AI on root node → ESR.soil_properties.sand/silt/clay."""
|
||||||
|
from envipy_additional_information.information import SoilTexture2
|
||||||
|
|
||||||
|
texture = SoilTexture2(sand=65.0, silt=25.0, clay=10.0)
|
||||||
|
root_node = PathwayNodeDTO(
|
||||||
|
node_uuid=uuid4(),
|
||||||
|
compound_pk=1,
|
||||||
|
name="Root",
|
||||||
|
depth=0,
|
||||||
|
smiles="CC",
|
||||||
|
additional_info=[texture],
|
||||||
|
)
|
||||||
|
export = PathwayExportDTO(
|
||||||
|
pathway_uuid=uuid4(),
|
||||||
|
pathway_name="P",
|
||||||
|
compounds=[PathwayCompoundDTO(pk=1, name="Root", smiles="CC")],
|
||||||
|
nodes=[root_node],
|
||||||
|
root_compound_pks=[1],
|
||||||
|
)
|
||||||
|
bundle = PathwayMapper().map(export)
|
||||||
|
esr = bundle.endpoint_study_records[0]
|
||||||
|
self.assertIsNotNone(esr.soil_properties)
|
||||||
|
self.assertEqual(esr.soil_properties.sand, 65.0)
|
||||||
|
self.assertEqual(esr.soil_properties.silt, 25.0)
|
||||||
|
self.assertEqual(esr.soil_properties.clay, 10.0)
|
||||||
|
|
||||||
|
def test_extracts_ph_from_root_node_ai(self):
|
||||||
|
"""Acidity AI on root node → ESR.soil_properties.ph_lower/ph_upper/ph_method."""
|
||||||
|
from envipy_additional_information.information import Acidity, Interval
|
||||||
|
|
||||||
|
acidity = Acidity(interval=Interval(start=6.5, end=7.2), method="CaCl2")
|
||||||
|
root_node = PathwayNodeDTO(
|
||||||
|
node_uuid=uuid4(),
|
||||||
|
compound_pk=1,
|
||||||
|
name="Root",
|
||||||
|
depth=0,
|
||||||
|
smiles="CC",
|
||||||
|
additional_info=[acidity],
|
||||||
|
)
|
||||||
|
export = PathwayExportDTO(
|
||||||
|
pathway_uuid=uuid4(),
|
||||||
|
pathway_name="P",
|
||||||
|
compounds=[PathwayCompoundDTO(pk=1, name="Root", smiles="CC")],
|
||||||
|
nodes=[root_node],
|
||||||
|
root_compound_pks=[1],
|
||||||
|
)
|
||||||
|
bundle = PathwayMapper().map(export)
|
||||||
|
esr = bundle.endpoint_study_records[0]
|
||||||
|
self.assertIsNotNone(esr.soil_properties)
|
||||||
|
self.assertEqual(esr.soil_properties.ph_lower, 6.5)
|
||||||
|
self.assertEqual(esr.soil_properties.ph_upper, 7.2)
|
||||||
|
self.assertEqual(esr.soil_properties.ph_method, "CaCl2")
|
||||||
|
|
||||||
|
def test_normalizes_blank_ph_method_to_none(self):
|
||||||
|
"""Blank Acidity method should not produce an empty PHMeasuredIn XML node."""
|
||||||
|
from envipy_additional_information.information import Acidity, Interval
|
||||||
|
|
||||||
|
acidity = Acidity(interval=Interval(start=6.5, end=7.2), method=" ")
|
||||||
|
root_node = PathwayNodeDTO(
|
||||||
|
node_uuid=uuid4(),
|
||||||
|
compound_pk=1,
|
||||||
|
name="Root",
|
||||||
|
depth=0,
|
||||||
|
smiles="CC",
|
||||||
|
additional_info=[acidity],
|
||||||
|
)
|
||||||
|
export = PathwayExportDTO(
|
||||||
|
pathway_uuid=uuid4(),
|
||||||
|
pathway_name="P",
|
||||||
|
compounds=[PathwayCompoundDTO(pk=1, name="Root", smiles="CC")],
|
||||||
|
nodes=[root_node],
|
||||||
|
root_compound_pks=[1],
|
||||||
|
)
|
||||||
|
bundle = PathwayMapper().map(export)
|
||||||
|
esr = bundle.endpoint_study_records[0]
|
||||||
|
|
||||||
|
self.assertIsNotNone(esr.soil_properties)
|
||||||
|
self.assertIsNone(esr.soil_properties.ph_method)
|
||||||
|
|
||||||
|
def test_extracts_cec_and_org_carbon(self):
|
||||||
|
"""CEC and OMContent AI on root node → ESR.soil_properties.cec/org_carbon."""
|
||||||
|
from envipy_additional_information.information import CEC, OMContent
|
||||||
|
|
||||||
|
cec = CEC(capacity=15.3)
|
||||||
|
om = OMContent(in_oc=2.1)
|
||||||
|
root_node = PathwayNodeDTO(
|
||||||
|
node_uuid=uuid4(),
|
||||||
|
compound_pk=1,
|
||||||
|
name="Root",
|
||||||
|
depth=0,
|
||||||
|
smiles="CC",
|
||||||
|
additional_info=[cec, om],
|
||||||
|
)
|
||||||
|
export = PathwayExportDTO(
|
||||||
|
pathway_uuid=uuid4(),
|
||||||
|
pathway_name="P",
|
||||||
|
compounds=[PathwayCompoundDTO(pk=1, name="Root", smiles="CC")],
|
||||||
|
nodes=[root_node],
|
||||||
|
root_compound_pks=[1],
|
||||||
|
)
|
||||||
|
bundle = PathwayMapper().map(export)
|
||||||
|
esr = bundle.endpoint_study_records[0]
|
||||||
|
self.assertIsNotNone(esr.soil_properties)
|
||||||
|
self.assertEqual(esr.soil_properties.cec, 15.3)
|
||||||
|
self.assertEqual(esr.soil_properties.org_carbon, 2.1)
|
||||||
|
|
||||||
|
def test_soil_properties_none_when_no_soil_ai(self):
|
||||||
|
"""No soil AI → soil_properties is None."""
|
||||||
|
export = PathwayExportDTO(
|
||||||
|
pathway_uuid=uuid4(),
|
||||||
|
pathway_name="P",
|
||||||
|
compounds=[PathwayCompoundDTO(pk=1, name="Root", smiles="CC")],
|
||||||
|
root_compound_pks=[1],
|
||||||
|
)
|
||||||
|
bundle = PathwayMapper().map(export)
|
||||||
|
esr = bundle.endpoint_study_records[0]
|
||||||
|
self.assertIsNone(esr.soil_properties)
|
||||||
|
|
||||||
|
def test_ignores_soil_ai_on_non_root_nodes(self):
|
||||||
|
"""Soil AI on non-root nodes (depth > 0) is not extracted."""
|
||||||
|
from envipy_additional_information.information import SoilTexture2
|
||||||
|
|
||||||
|
texture = SoilTexture2(sand=60.0, silt=30.0, clay=10.0)
|
||||||
|
non_root_node = PathwayNodeDTO(
|
||||||
|
node_uuid=uuid4(),
|
||||||
|
compound_pk=2,
|
||||||
|
name="Product",
|
||||||
|
depth=1,
|
||||||
|
smiles="CCC",
|
||||||
|
additional_info=[texture],
|
||||||
|
)
|
||||||
|
export = PathwayExportDTO(
|
||||||
|
pathway_uuid=uuid4(),
|
||||||
|
pathway_name="P",
|
||||||
|
compounds=[
|
||||||
|
PathwayCompoundDTO(pk=1, name="Root", smiles="CC"),
|
||||||
|
PathwayCompoundDTO(pk=2, name="Product", smiles="CCC"),
|
||||||
|
],
|
||||||
|
nodes=[non_root_node],
|
||||||
|
root_compound_pks=[1],
|
||||||
|
)
|
||||||
|
bundle = PathwayMapper().map(export)
|
||||||
|
esr = bundle.endpoint_study_records[0]
|
||||||
|
self.assertIsNone(esr.soil_properties)
|
||||||
|
|
||||||
|
def test_mapper_merges_root_scenarios_into_single_esr_with_soil_numbers(self):
|
||||||
|
"""Scenario-aware root export should merge scenarios into one ESR linked by SoilNo."""
|
||||||
|
from envipy_additional_information.information import HalfLife, Interval, SoilTexture2
|
||||||
|
|
||||||
|
scenario_a = PathwayScenarioDTO(
|
||||||
|
scenario_uuid=uuid4(),
|
||||||
|
name="Scenario A",
|
||||||
|
additional_info=[
|
||||||
|
HalfLife(
|
||||||
|
model="SFO",
|
||||||
|
fit="ok",
|
||||||
|
comment="",
|
||||||
|
dt50=Interval(start=2.0, end=2.0),
|
||||||
|
source="A",
|
||||||
|
),
|
||||||
|
SoilTexture2(sand=70.0, silt=20.0, clay=10.0),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
scenario_b = PathwayScenarioDTO(
|
||||||
|
scenario_uuid=uuid4(),
|
||||||
|
name="Scenario B",
|
||||||
|
additional_info=[
|
||||||
|
HalfLife(
|
||||||
|
model="SFO",
|
||||||
|
fit="ok",
|
||||||
|
comment="",
|
||||||
|
dt50=Interval(start=5.0, end=5.0),
|
||||||
|
source="B",
|
||||||
|
),
|
||||||
|
SoilTexture2(sand=40.0, silt=40.0, clay=20.0),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
root_node = PathwayNodeDTO(
|
||||||
|
node_uuid=uuid4(),
|
||||||
|
compound_pk=1,
|
||||||
|
name="Root",
|
||||||
|
depth=0,
|
||||||
|
smiles="CC",
|
||||||
|
scenarios=[scenario_a, scenario_b],
|
||||||
|
)
|
||||||
|
export = PathwayExportDTO(
|
||||||
|
pathway_uuid=uuid4(),
|
||||||
|
pathway_name="P",
|
||||||
|
compounds=[PathwayCompoundDTO(pk=1, name="Root", smiles="CC")],
|
||||||
|
nodes=[root_node],
|
||||||
|
root_compound_pks=[1],
|
||||||
|
)
|
||||||
|
|
||||||
|
bundle = PathwayMapper().map(export)
|
||||||
|
|
||||||
|
self.assertEqual(len(bundle.endpoint_study_records), 1)
|
||||||
|
esr = bundle.endpoint_study_records[0]
|
||||||
|
|
||||||
|
self.assertEqual(esr.name, "Biodegradation in soil - P")
|
||||||
|
self.assertEqual(len(esr.half_lives), 2)
|
||||||
|
self.assertEqual(len(esr.soil_properties_entries), 2)
|
||||||
|
|
||||||
|
by_dt50 = {hl.dt50_start: hl for hl in esr.half_lives}
|
||||||
|
self.assertEqual(by_dt50[2.0].soil_no_code, "2")
|
||||||
|
self.assertEqual(by_dt50[5.0].soil_no_code, "4")
|
||||||
|
self.assertEqual(by_dt50[2.0].temperature, None)
|
||||||
|
|
||||||
|
by_soil_no = {soil.soil_no_code: soil for soil in esr.soil_properties_entries}
|
||||||
|
self.assertEqual(by_soil_no["2"].sand, 70.0)
|
||||||
|
self.assertEqual(by_soil_no["4"].sand, 40.0)
|
||||||
148
epiuclid/tests/test_xsd_validation.py
Normal file
148
epiuclid/tests/test_xsd_validation.py
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
"""XSD validation tests for IUCLID XML builders — no DB required."""
|
||||||
|
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
from django.test import SimpleTestCase, tag
|
||||||
|
|
||||||
|
from epiuclid.builders.base import NS_PLATFORM_CONTAINER
|
||||||
|
from epiuclid.builders.endpoint_study import EndpointStudyRecordBuilder
|
||||||
|
from epiuclid.builders.reference_substance import ReferenceSubstanceBuilder
|
||||||
|
from epiuclid.builders.substance import SubstanceBuilder
|
||||||
|
from epiuclid.schemas.loader import get_content_schema, get_document_schema
|
||||||
|
|
||||||
|
from .factories import (
|
||||||
|
make_endpoint_study_record_data,
|
||||||
|
make_half_life_entry,
|
||||||
|
make_reference_substance_data,
|
||||||
|
make_soil_properties_data,
|
||||||
|
make_substance_data,
|
||||||
|
make_transformation_entry,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _content_element(xml_str: str) -> ET.Element:
|
||||||
|
"""Extract the first child of <Content> from a full i6d XML string."""
|
||||||
|
root = ET.fromstring(xml_str)
|
||||||
|
content = root.find(f"{{{NS_PLATFORM_CONTAINER}}}Content")
|
||||||
|
assert content is not None and len(content) > 0
|
||||||
|
return list(content)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_content_valid(xml_str: str, doc_type: str, subtype: str | None = None) -> None:
|
||||||
|
schema = get_content_schema(doc_type, subtype)
|
||||||
|
schema.validate(_content_element(xml_str))
|
||||||
|
|
||||||
|
|
||||||
|
@tag("iuclid")
|
||||||
|
class SubstanceXSDValidationTest(SimpleTestCase):
|
||||||
|
def test_substance_validates_against_xsd(self):
|
||||||
|
data = make_substance_data()
|
||||||
|
xml_str = SubstanceBuilder().build(data)
|
||||||
|
_assert_content_valid(xml_str, "SUBSTANCE")
|
||||||
|
|
||||||
|
def test_minimal_substance_validates_against_xsd(self):
|
||||||
|
data = make_substance_data(name="Unknown compound", reference_substance_uuid=None)
|
||||||
|
xml_str = SubstanceBuilder().build(data)
|
||||||
|
_assert_content_valid(xml_str, "SUBSTANCE")
|
||||||
|
|
||||||
|
|
||||||
|
@tag("iuclid")
|
||||||
|
class ReferenceSubstanceXSDValidationTest(SimpleTestCase):
|
||||||
|
def test_reference_substance_validates_against_xsd(self):
|
||||||
|
data = make_reference_substance_data()
|
||||||
|
xml_str = ReferenceSubstanceBuilder().build(data)
|
||||||
|
_assert_content_valid(xml_str, "REFERENCE_SUBSTANCE")
|
||||||
|
|
||||||
|
def test_reference_substance_minimal_validates_against_xsd(self):
|
||||||
|
data = make_reference_substance_data(
|
||||||
|
name="Minimal compound",
|
||||||
|
smiles="CC",
|
||||||
|
cas_number=None,
|
||||||
|
molecular_formula=None,
|
||||||
|
molecular_weight=None,
|
||||||
|
inchi=None,
|
||||||
|
inchi_key=None,
|
||||||
|
)
|
||||||
|
xml_str = ReferenceSubstanceBuilder().build(data)
|
||||||
|
_assert_content_valid(xml_str, "REFERENCE_SUBSTANCE")
|
||||||
|
|
||||||
|
|
||||||
|
@tag("iuclid")
|
||||||
|
class EndpointStudyRecordXSDValidationTest(SimpleTestCase):
|
||||||
|
def test_endpoint_study_record_validates_against_xsd(self):
|
||||||
|
data = make_endpoint_study_record_data(
|
||||||
|
name="Biodegradation study with data",
|
||||||
|
half_lives=[
|
||||||
|
make_half_life_entry(),
|
||||||
|
],
|
||||||
|
temperature=(20.0, 20.0),
|
||||||
|
transformation_products=[
|
||||||
|
make_transformation_entry(),
|
||||||
|
],
|
||||||
|
model_name_and_version=["Test model 1.0"],
|
||||||
|
software_name_and_version=["enviPath"],
|
||||||
|
model_remarks=["Model UUID: 00000000-0000-0000-0000-000000000000"],
|
||||||
|
)
|
||||||
|
xml_str = EndpointStudyRecordBuilder().build(data)
|
||||||
|
_assert_content_valid(xml_str, "ENDPOINT_STUDY_RECORD", "BiodegradationInSoil")
|
||||||
|
|
||||||
|
def test_temperature_only_esr_validates_against_xsd(self):
|
||||||
|
data = make_endpoint_study_record_data(
|
||||||
|
name="Biodegradation study with temperature only", temperature=(21.0, 21.0)
|
||||||
|
)
|
||||||
|
xml_str = EndpointStudyRecordBuilder().build(data)
|
||||||
|
_assert_content_valid(xml_str, "ENDPOINT_STUDY_RECORD", "BiodegradationInSoil")
|
||||||
|
|
||||||
|
def test_skeleton_esr_validates_against_xsd(self):
|
||||||
|
data = make_endpoint_study_record_data(name="Biodegradation study")
|
||||||
|
xml_str = EndpointStudyRecordBuilder().build(data)
|
||||||
|
_assert_content_valid(xml_str, "ENDPOINT_STUDY_RECORD", "BiodegradationInSoil")
|
||||||
|
|
||||||
|
def test_esr_with_soil_properties_validates_against_xsd(self):
|
||||||
|
"""ESR with full soil properties validates against BiodegradationInSoil XSD."""
|
||||||
|
data = make_endpoint_study_record_data(
|
||||||
|
name="Biodegradation study with soil properties",
|
||||||
|
soil_properties=make_soil_properties_data(),
|
||||||
|
)
|
||||||
|
xml_str = EndpointStudyRecordBuilder().build(data)
|
||||||
|
_assert_content_valid(xml_str, "ENDPOINT_STUDY_RECORD", "BiodegradationInSoil")
|
||||||
|
|
||||||
|
def test_esr_with_multiple_soils_and_linked_dt_validates_against_xsd(self):
|
||||||
|
data = make_endpoint_study_record_data(
|
||||||
|
name="Biodegradation study with multiple soils",
|
||||||
|
soil_properties_entries=[
|
||||||
|
make_soil_properties_data(soil_no_code="2", soil_type="LOAMY_SAND"),
|
||||||
|
make_soil_properties_data(soil_no_code="4", soil_type="CLAY_LOAM"),
|
||||||
|
],
|
||||||
|
half_lives=[
|
||||||
|
make_half_life_entry(dt50_start=1.0, dt50_end=1.0, soil_no_code="2"),
|
||||||
|
make_half_life_entry(dt50_start=2.0, dt50_end=2.0, soil_no_code="4"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
xml_str = EndpointStudyRecordBuilder().build(data)
|
||||||
|
_assert_content_valid(xml_str, "ENDPOINT_STUDY_RECORD", "BiodegradationInSoil")
|
||||||
|
|
||||||
|
|
||||||
|
@tag("iuclid")
|
||||||
|
class DocumentWrapperXSDValidationTest(SimpleTestCase):
|
||||||
|
def test_full_i6d_document_validates_against_container_xsd(self):
|
||||||
|
"""Validate the Document wrapper (PlatformMetadata + Content + Attachments + ModificationHistory).
|
||||||
|
|
||||||
|
The container schema uses processContents="strict" for xs:any in Content,
|
||||||
|
so we need the content schema loaded into the validator too.
|
||||||
|
"""
|
||||||
|
data = make_substance_data()
|
||||||
|
xml_str = SubstanceBuilder().build(data)
|
||||||
|
root = ET.fromstring(xml_str)
|
||||||
|
|
||||||
|
doc_schema = get_document_schema()
|
||||||
|
content_schema = get_content_schema("SUBSTANCE")
|
||||||
|
|
||||||
|
# This is a xmlschema quirk and happens because there are children of the Content element not defined in the Content schema.
|
||||||
|
errors = [
|
||||||
|
e for e in doc_schema.iter_errors(root) if "unavailable namespace" not in str(e.reason)
|
||||||
|
]
|
||||||
|
self.assertEqual(errors, [], msg=f"Document wrapper errors: {errors}")
|
||||||
|
|
||||||
|
content_el = _content_element(xml_str)
|
||||||
|
content_schema.validate(content_el)
|
||||||
22
epiuclid/tests/xml_assertions.py
Normal file
22
epiuclid/tests/xml_assertions.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
|
|
||||||
|
def assert_xpath_text(
|
||||||
|
case: SimpleTestCase,
|
||||||
|
root: ET.Element,
|
||||||
|
path: str,
|
||||||
|
expected: str,
|
||||||
|
) -> ET.Element:
|
||||||
|
element = root.find(path)
|
||||||
|
case.assertIsNotNone(element, msg=f"Missing element at xpath: {path}")
|
||||||
|
assert element is not None
|
||||||
|
case.assertEqual(element.text, expected)
|
||||||
|
return element
|
||||||
|
|
||||||
|
|
||||||
|
def assert_xpath_absent(case: SimpleTestCase, root: ET.Element, path: str) -> None:
|
||||||
|
case.assertIsNone(root.find(path), msg=f"Element should be absent at xpath: {path}")
|
||||||
@ -23,7 +23,7 @@ from bridge.dto import (
|
|||||||
BuildResult,
|
BuildResult,
|
||||||
EnviPyDTO,
|
EnviPyDTO,
|
||||||
EvaluationResult,
|
EvaluationResult,
|
||||||
PredictedProperty,
|
PropertyPrediction,
|
||||||
RunResult,
|
RunResult,
|
||||||
) # noqa: I001
|
) # noqa: I001
|
||||||
|
|
||||||
@ -33,7 +33,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
@register("pepperprediction")
|
@register("pepperprediction")
|
||||||
class PepperPrediction(PredictedProperty):
|
class PepperPrediction(PropertyPrediction):
|
||||||
mean: float | None
|
mean: float | None
|
||||||
std: float | None
|
std: float | None
|
||||||
log_mean: float | None
|
log_mean: float | None
|
||||||
@ -46,7 +46,7 @@ class PepperPrediction(PredictedProperty):
|
|||||||
|
|
||||||
import matplotlib.patches as mpatches
|
import matplotlib.patches as mpatches
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from matplotlib import pyplot as plt
|
from matplotlib.figure import Figure
|
||||||
from scipy import stats
|
from scipy import stats
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@ -101,7 +101,8 @@ class PepperPrediction(PredictedProperty):
|
|||||||
mask_red = x > vp
|
mask_red = x > vp
|
||||||
|
|
||||||
# Plot
|
# Plot
|
||||||
fig, ax = plt.subplots(figsize=(9, 5.5))
|
fig = Figure(figsize=(9, 5.5))
|
||||||
|
ax = fig.subplots()
|
||||||
ax.plot(x, y, color="#1f4e79", lw=2, label="Lognormal PDF")
|
ax.plot(x, y, color="#1f4e79", lw=2, label="Lognormal PDF")
|
||||||
|
|
||||||
if np.any(mask_green):
|
if np.any(mask_green):
|
||||||
@ -146,32 +147,36 @@ class PepperPrediction(PredictedProperty):
|
|||||||
]
|
]
|
||||||
ax.legend(handles=patches, frameon=True)
|
ax.legend(handles=patches, frameon=True)
|
||||||
|
|
||||||
plt.tight_layout()
|
fig.tight_layout()
|
||||||
|
|
||||||
# --- Export to SVG string ---
|
# --- Export to SVG string ---
|
||||||
buf = io.StringIO()
|
buf = io.StringIO()
|
||||||
fig.savefig(buf, format="svg", bbox_inches="tight")
|
fig.savefig(buf, format="svg", bbox_inches="tight")
|
||||||
svg = buf.getvalue()
|
svg = buf.getvalue()
|
||||||
plt.close(fig)
|
|
||||||
buf.close()
|
buf.close()
|
||||||
|
|
||||||
return svg
|
return svg
|
||||||
|
|
||||||
|
|
||||||
class PEPPER(Property):
|
class PEPPER(Property):
|
||||||
def identifier(self) -> str:
|
@classmethod
|
||||||
|
def identifier(cls) -> str:
|
||||||
return "pepper"
|
return "pepper"
|
||||||
|
|
||||||
def display(self) -> str:
|
@classmethod
|
||||||
|
def display(cls) -> str:
|
||||||
return "PEPPER"
|
return "PEPPER"
|
||||||
|
|
||||||
def name(self) -> str:
|
@classmethod
|
||||||
|
def name(cls) -> str:
|
||||||
return "Predict Environmental Pollutant PERsistence"
|
return "Predict Environmental Pollutant PERsistence"
|
||||||
|
|
||||||
def requires_rule_packages(self) -> bool:
|
@classmethod
|
||||||
|
def requires_rule_packages(cls) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def requires_data_packages(self) -> bool:
|
@classmethod
|
||||||
|
def requires_data_packages(cls) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_type(self) -> PropertyType:
|
def get_type(self) -> PropertyType:
|
||||||
|
|||||||
@ -187,8 +187,9 @@ class Pepper:
|
|||||||
groups = [group for group in dataset.group_by("structure_id")]
|
groups = [group for group in dataset.group_by("structure_id")]
|
||||||
|
|
||||||
# Unless explicitly set compute everything serial
|
# Unless explicitly set compute everything serial
|
||||||
if os.environ.get("N_PEPPER_THREADS", 1) > 1:
|
n_threads = int(os.environ.get("N_PEPPER_THREADS", 1))
|
||||||
results = Parallel(n_jobs=os.environ["N_PEPPER_THREADS"])(
|
if n_threads > 1:
|
||||||
|
results = Parallel(n_jobs=n_threads)(
|
||||||
delayed(compute_bayes_per_group)(group[1])
|
delayed(compute_bayes_per_group)(group[1])
|
||||||
for group in dataset.group_by("structure_id")
|
for group in dataset.group_by("structure_id")
|
||||||
)
|
)
|
||||||
|
|||||||
@ -31,10 +31,12 @@ dependencies = [
|
|||||||
"setuptools>=80.8.0",
|
"setuptools>=80.8.0",
|
||||||
"nh3==0.3.2",
|
"nh3==0.3.2",
|
||||||
"polars==1.35.1",
|
"polars==1.35.1",
|
||||||
|
"whitenoise>=6.12.0",
|
||||||
|
"xmlschema>=3.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
enviformer = { git = "ssh://git@git.envipath.com/enviPath/enviformer.git", rev = "v0.1.4" }
|
enviformer = { git = "ssh://git@git.envipath.com/enviPath/enviformer.git", rev = "main" }
|
||||||
envipy-plugins = { git = "ssh://git@git.envipath.com/enviPath/enviPy-plugins.git", rev = "v0.1.0" }
|
envipy-plugins = { git = "ssh://git@git.envipath.com/enviPath/enviPy-plugins.git", rev = "v0.1.0" }
|
||||||
envipy-additional-information = { git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git", branch = "develop" }
|
envipy-additional-information = { git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git", branch = "develop" }
|
||||||
envipy-ambit = { git = "ssh://git@git.envipath.com/enviPath/enviPy-ambit.git" }
|
envipy-ambit = { git = "ssh://git@git.envipath.com/enviPath/enviPy-ambit.git" }
|
||||||
@ -139,6 +141,7 @@ collectstatic = { cmd = "uv run python manage.py collectstatic --noinput", help
|
|||||||
frontend-test-setup = { cmd = "playwright install", help = "Install the browsers required for frontend testing" }
|
frontend-test-setup = { cmd = "playwright install", help = "Install the browsers required for frontend testing" }
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
|
DJANGO_SETTINGS_MODULE = "envipath.settings"
|
||||||
addopts = "--verbose --capture=no --durations=10"
|
addopts = "--verbose --capture=no --durations=10"
|
||||||
testpaths = ["tests", "*/tests"]
|
testpaths = ["tests", "*/tests"]
|
||||||
pythonpath = ["."]
|
pythonpath = ["."]
|
||||||
@ -155,4 +158,5 @@ markers = [
|
|||||||
"frontend: Frontend tests",
|
"frontend: Frontend tests",
|
||||||
"end2end: End-to-end tests",
|
"end2end: End-to-end tests",
|
||||||
"slow: Slow tests",
|
"slow: Slow tests",
|
||||||
|
"iuclid: IUCLID i6z export tests",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -88,6 +88,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
options.debugErrors ??
|
options.debugErrors ??
|
||||||
(typeof window !== "undefined" &&
|
(typeof window !== "undefined" &&
|
||||||
window.location?.search?.includes("debugErrors=1")),
|
window.location?.search?.includes("debugErrors=1")),
|
||||||
|
attach_object: options.attach_object || null,
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
if (options.schemaUrl) {
|
if (options.schemaUrl) {
|
||||||
|
|||||||
@ -637,44 +637,56 @@ function draw(pathway, elem) {
|
|||||||
node.filter(d => !d.pseudo).each(function (d, i) {
|
node.filter(d => !d.pseudo).each(function (d, i) {
|
||||||
const g = d3.select(this);
|
const g = d3.select(this);
|
||||||
|
|
||||||
// Parse the SVG string
|
if (d.image_type === "svg") {
|
||||||
const parser = new DOMParser();
|
// Parse the SVG string
|
||||||
const svgDoc = parser.parseFromString(d.image_svg, "image/svg+xml");
|
const parser = new DOMParser();
|
||||||
const svgElem = svgDoc.documentElement;
|
const svgDoc = parser.parseFromString(d.image_svg, "image/svg+xml");
|
||||||
|
const svgElem = svgDoc.documentElement;
|
||||||
|
|
||||||
// Create a unique prefix per node
|
// Create a unique prefix per node
|
||||||
const prefix = `node-${i}-`;
|
const prefix = `node-${i}-`;
|
||||||
|
|
||||||
// Rename all IDs and fix <use> references
|
// Rename all IDs and fix <use> references
|
||||||
svgElem.querySelectorAll('[id]').forEach(el => {
|
svgElem.querySelectorAll("[id]").forEach(el => {
|
||||||
const oldId = el.id;
|
const oldId = el.id;
|
||||||
const newId = prefix + oldId;
|
const newId = prefix + oldId;
|
||||||
el.id = newId;
|
el.id = newId;
|
||||||
|
|
||||||
const XLINK_NS = "http://www.w3.org/1999/xlink";
|
const XLINK_NS = "http://www.w3.org/1999/xlink";
|
||||||
// Update <use> elements that reference this old ID
|
// Update <use> elements that reference this old ID
|
||||||
const uses = Array.from(svgElem.querySelectorAll('use')).filter(
|
const uses = Array.from(svgElem.querySelectorAll("use")).filter(
|
||||||
u => u.getAttributeNS(XLINK_NS, 'href') === `#${oldId}`
|
u => u.getAttributeNS(XLINK_NS, "href") === `#${oldId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
uses.forEach(u => {
|
uses.forEach(u => {
|
||||||
u.setAttributeNS(XLINK_NS, 'href', `#${newId}`);
|
u.setAttributeNS(XLINK_NS, "href", `#${newId}`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
g.node().appendChild(svgElem);
|
g.node().appendChild(svgElem);
|
||||||
|
|
||||||
const vb = svgElem.viewBox.baseVal;
|
const vb = svgElem.viewBox.baseVal;
|
||||||
const svgWidth = vb.width || 40;
|
const svgWidth = vb.width || 40;
|
||||||
const svgHeight = vb.height || 40;
|
const svgHeight = vb.height || 40;
|
||||||
|
|
||||||
const scale = (nodeRadius * 2) / Math.max(svgWidth, svgHeight);
|
const scale = (nodeRadius * 2) / Math.max(svgWidth, svgHeight);
|
||||||
|
|
||||||
|
g.select("svg")
|
||||||
|
.attr("width", svgWidth * scale)
|
||||||
|
.attr("height", svgHeight * scale)
|
||||||
|
.attr("x", -svgWidth * scale / 2)
|
||||||
|
.attr("y", -svgHeight * scale / 2);
|
||||||
|
} else {
|
||||||
|
// We have a image type different than svg
|
||||||
|
// include it via img url
|
||||||
|
g.append("svg:image")
|
||||||
|
.attr("xlink:href", d.image)
|
||||||
|
.attr("width", 40)
|
||||||
|
.attr("height", 40)
|
||||||
|
.attr("x", -20)
|
||||||
|
.attr("y", -20);
|
||||||
|
}
|
||||||
|
|
||||||
g.select("svg")
|
|
||||||
.attr("width", svgWidth * scale)
|
|
||||||
.attr("height", svgHeight * scale)
|
|
||||||
.attr("x", -svgWidth * scale / 2)
|
|
||||||
.attr("y", -svgHeight * scale / 2);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// add element to nodes array
|
// add element to nodes array
|
||||||
|
|||||||
@ -1,8 +0,0 @@
|
|||||||
<li>
|
|
||||||
<a
|
|
||||||
role="button"
|
|
||||||
onclick="document.getElementById('new_group_modal').showModal(); return false;"
|
|
||||||
>
|
|
||||||
<span class="glyphicon glyphicon-plus"></span> New Group</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
{% load envipytags %}
|
||||||
|
|
||||||
{% if meta.can_edit %}
|
{% if meta.can_edit %}
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
@ -15,6 +17,11 @@
|
|||||||
<i class="glyphicon glyphicon-plus"></i> Add Reaction</a
|
<i class="glyphicon glyphicon-plus"></i> Add Reaction</a
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
|
{% epdb_slot_templates "epdb.actions.objects.pathway.add" as action_button_templates %}
|
||||||
|
|
||||||
|
{% for tpl in action_button_templates %}
|
||||||
|
{% include tpl %}
|
||||||
|
{% endfor %}
|
||||||
<li role="separator" class="divider"></li>
|
<li role="separator" class="divider"></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li>
|
<li>
|
||||||
@ -41,6 +48,14 @@
|
|||||||
<i class="glyphicon glyphicon-floppy-save"></i> Download Pathway as Image</a
|
<i class="glyphicon glyphicon-floppy-save"></i> Download Pathway as Image</a
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
|
{% if meta.enabled_features.IUCLID_EXPORT and meta.user.username != 'anonymous' %}
|
||||||
|
<li>
|
||||||
|
<a class="button" href="/api/v1/pathway/{{ pathway.uuid }}/export/iuclid">
|
||||||
|
<i class="glyphicon glyphicon-floppy-save"></i> Download Pathway as IUCLID
|
||||||
|
(.i6z)</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
role="button"
|
role="button"
|
||||||
|
|||||||
@ -1,21 +1,34 @@
|
|||||||
{% extends "collections/paginated_base.html" %}
|
{% extends "collections/paginated_base.html" %}
|
||||||
|
{% load envipytags %}
|
||||||
|
|
||||||
{% block page_title %}Compounds{% endblock %}
|
{% block page_title %}Compounds{% endblock %}
|
||||||
|
|
||||||
{% block action_button %}
|
{% block action_button %}
|
||||||
{% if meta.can_edit %}
|
<div class="flex items-center gap-2">
|
||||||
<button
|
{% if meta.can_edit %}
|
||||||
type="button"
|
<button
|
||||||
class="btn btn-primary btn-sm"
|
type="button"
|
||||||
onclick="document.getElementById('new_compound_modal').showModal(); return false;"
|
class="btn btn-primary btn-sm"
|
||||||
>
|
onclick="document.getElementById('new_compound_modal').showModal(); return false;"
|
||||||
New Compound
|
>
|
||||||
</button>
|
New Compound
|
||||||
{% endif %}
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% epdb_slot_templates "epdb.actions.collections.compound" as action_button_templates %}
|
||||||
|
|
||||||
|
{% for tpl in action_button_templates %}
|
||||||
|
{% include tpl %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
{% endblock action_button %}
|
{% endblock action_button %}
|
||||||
|
|
||||||
{% block action_modals %}
|
{% block action_modals %}
|
||||||
{% include "modals/collections/new_compound_modal.html" %}
|
{% include "modals/collections/new_compound_modal.html" %}
|
||||||
|
{% epdb_slot_templates "modals.collections.compound" as action_modals_templates %}
|
||||||
|
|
||||||
|
{% for tpl in action_modals_templates %}
|
||||||
|
{% include tpl %}
|
||||||
|
{% endfor %}
|
||||||
{% endblock action_modals %}
|
{% endblock action_modals %}
|
||||||
|
|
||||||
{% block description %}
|
{% block description %}
|
||||||
|
|||||||
21
templates/collections/groups_paginated.html
Normal file
21
templates/collections/groups_paginated.html
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{% extends "collections/paginated_base.html" %}
|
||||||
|
|
||||||
|
{% block page_title %}Groups{% endblock %}
|
||||||
|
|
||||||
|
{% block action_button %}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary btn-sm"
|
||||||
|
onclick="document.getElementById('new_group_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
New Group
|
||||||
|
</button>
|
||||||
|
{% endblock action_button %}
|
||||||
|
|
||||||
|
{% block action_modals %}
|
||||||
|
{% include "modals/collections/new_group_modal.html" %}
|
||||||
|
{% endblock action_modals %}
|
||||||
|
|
||||||
|
{% block description %}
|
||||||
|
<p>Users can team up in groups to share packages.</p>
|
||||||
|
{% endblock description %}
|
||||||
@ -18,8 +18,25 @@
|
|||||||
<!-- Schema form -->
|
<!-- Schema form -->
|
||||||
<template x-if="schema && !loading">
|
<template x-if="schema && !loading">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
<template x-if="attach_object">
|
||||||
|
<div>
|
||||||
|
<h4>
|
||||||
|
<span
|
||||||
|
class="text-lg font-semibold"
|
||||||
|
x-text="schema['x-title'] + ' attached to'"
|
||||||
|
></span>
|
||||||
|
<a
|
||||||
|
class="text-lg font-semibold underline text-blue-600 hover:text-blue-800"
|
||||||
|
:href="attach_object.url"
|
||||||
|
x-text="attach_object.name"
|
||||||
|
target="_blank"
|
||||||
|
></a>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Title from schema -->
|
<!-- Title from schema -->
|
||||||
<template x-if="schema['x-title'] || schema.title">
|
<template x-if="(schema['x-title'] || schema.title) && !attach_object">
|
||||||
<h4
|
<h4
|
||||||
class="text-lg font-semibold"
|
class="text-lg font-semibold"
|
||||||
x-text="data.name || schema['x-title'] || schema.title"
|
x-text="data.name || schema['x-title'] || schema.title"
|
||||||
|
|||||||
@ -1,33 +1,82 @@
|
|||||||
|
{% load static %}
|
||||||
<dialog
|
<dialog
|
||||||
id="new_model_modal"
|
id="new_model_modal"
|
||||||
class="modal"
|
class="modal"
|
||||||
x-data="{
|
x-data="{
|
||||||
isSubmitting: false,
|
isSubmitting: false,
|
||||||
modelType: '',
|
selectedType: '',
|
||||||
buildAppDomain: false,
|
buildAppDomain: false,
|
||||||
requiresRulePackages: false,
|
requiresRulePackages: false,
|
||||||
requiresDataPackages: false,
|
requiresDataPackages: false,
|
||||||
|
additional_parameters: null,
|
||||||
|
schemas: {},
|
||||||
|
formRenderKey: 0, // Counter to force form re-render
|
||||||
|
formData: null, // Store reference to form data
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
// Watch for selectedType changes
|
||||||
|
this.$watch('selectedType', (value) => {
|
||||||
|
// Reset formData when type changes and increment key to force re-render
|
||||||
|
this.formData = null;
|
||||||
|
this.formRenderKey++;
|
||||||
|
// Clear previous errors
|
||||||
|
this.error = null;
|
||||||
|
Alpine.store('validationErrors').clearErrors(); // No context - clears all
|
||||||
|
|
||||||
|
const select = this.$refs.typeSelect;
|
||||||
|
const selectedOption = select.options[select.selectedIndex];
|
||||||
|
|
||||||
|
this.requiresRulePackages = selectedOption.dataset.requires_rule_packages === 'True';
|
||||||
|
this.requiresDataPackages = selectedOption.dataset.requires_data_packages === 'True';
|
||||||
|
this.additional_parameters = selectedOption.dataset.additional_parameters;
|
||||||
|
|
||||||
|
console.log(this.selectedType);
|
||||||
|
console.log(this.schemas[this.additional_parameters]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load schemas and existing items
|
||||||
|
try {
|
||||||
|
this.loadingSchemas = true;
|
||||||
|
const [schemasRes] = await Promise.all([
|
||||||
|
fetch('/api/v1/information/schema/'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!schemasRes.ok) throw new Error('Failed to load schemas');
|
||||||
|
|
||||||
|
this.schemas = await schemasRes.json();
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err.message;
|
||||||
|
} finally {
|
||||||
|
this.loadingSchemas = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
this.isSubmitting = false;
|
this.isSubmitting = false;
|
||||||
this.modelType = '';
|
this.selectedType = '';
|
||||||
this.buildAppDomain = false;
|
this.buildAppDomain = false;
|
||||||
|
this.requiresRulePackages = false;
|
||||||
|
this.requiresDataPackages = false;
|
||||||
|
this.additional_parameters = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
setFormData(data) {
|
||||||
|
this.formData = data;
|
||||||
},
|
},
|
||||||
|
|
||||||
get showMlrr() {
|
get showMlrr() {
|
||||||
return this.modelType === 'mlrr';
|
return this.selectedType === 'mlrr';
|
||||||
},
|
},
|
||||||
|
|
||||||
get showRbrr() {
|
get showRbrr() {
|
||||||
return this.modelType === 'rbrr';
|
return this.selectedType === 'rbrr';
|
||||||
},
|
},
|
||||||
|
|
||||||
get showEnviformer() {
|
get showEnviformer() {
|
||||||
return this.modelType === 'enviformer';
|
return this.selectedType === 'enviformer';
|
||||||
},
|
},
|
||||||
|
|
||||||
get showRulePackages() {
|
get showRulePackages() {
|
||||||
console.log(this.requiresRulePackages);
|
|
||||||
return this.requiresRulePackages;
|
return this.requiresRulePackages;
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -35,14 +84,25 @@
|
|||||||
return this.requiresDataPackages;
|
return this.requiresDataPackages;
|
||||||
},
|
},
|
||||||
|
|
||||||
updateRequirements(event) {
|
|
||||||
const option = event.target.selectedOptions[0];
|
|
||||||
this.requiresRulePackages = option.dataset.requires_rule_packages === 'True';
|
|
||||||
this.requiresDataPackages = option.dataset.requires_data_packages === 'True';
|
|
||||||
},
|
|
||||||
|
|
||||||
submit(formId) {
|
submit(formId) {
|
||||||
const form = document.getElementById(formId);
|
const form = document.getElementById(formId);
|
||||||
|
|
||||||
|
// Remove previously injected inputs
|
||||||
|
form.querySelectorAll('.dynamic-param').forEach(el => el.remove());
|
||||||
|
|
||||||
|
// Add values from dynamic form into the html form
|
||||||
|
if (this.formData) {
|
||||||
|
Object.entries(this.formData).forEach(([key, value]) => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'hidden';
|
||||||
|
input.name = key;
|
||||||
|
input.value = value;
|
||||||
|
input.classList.add('dynamic-param');
|
||||||
|
|
||||||
|
form.appendChild(input);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (form && form.checkValidity()) {
|
if (form && form.checkValidity()) {
|
||||||
this.isSubmitting = true;
|
this.isSubmitting = true;
|
||||||
form.submit();
|
form.submit();
|
||||||
@ -52,6 +112,7 @@
|
|||||||
}
|
}
|
||||||
}"
|
}"
|
||||||
@close="reset()"
|
@close="reset()"
|
||||||
|
@form-data-ready="formData = $event.detail"
|
||||||
>
|
>
|
||||||
<div class="modal-box max-w-3xl">
|
<div class="modal-box max-w-3xl">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
@ -127,8 +188,8 @@
|
|||||||
id="model-type"
|
id="model-type"
|
||||||
name="model-type"
|
name="model-type"
|
||||||
class="select select-bordered w-full"
|
class="select select-bordered w-full"
|
||||||
x-model="modelType"
|
x-model="selectedType"
|
||||||
x-on:change="updateRequirements($event)"
|
x-ref="typeSelect"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="" disabled selected>Select Model Type</option>
|
<option value="" disabled selected>Select Model Type</option>
|
||||||
@ -137,6 +198,7 @@
|
|||||||
value="{{ v.type }}"
|
value="{{ v.type }}"
|
||||||
data-requires_rule_packages="{{ v.requires_rule_packages }}"
|
data-requires_rule_packages="{{ v.requires_rule_packages }}"
|
||||||
data-requires_data_packages="{{ v.requires_data_packages }}"
|
data-requires_data_packages="{{ v.requires_data_packages }}"
|
||||||
|
data-additional_parameters="{{ v.additional_parameters }}"
|
||||||
>
|
>
|
||||||
{{ k }}
|
{{ k }}
|
||||||
</option>
|
</option>
|
||||||
@ -252,6 +314,23 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<template x-if="!loadingSchemas">
|
||||||
|
<template x-for="renderKey in [formRenderKey]" :key="renderKey">
|
||||||
|
<div x-show="selectedType && schemas[additional_parameters]">
|
||||||
|
<div
|
||||||
|
x-data="schemaRenderer({
|
||||||
|
rjsf: schemas[additional_parameters],
|
||||||
|
mode: 'edit'
|
||||||
|
// No context - single form, backward compatible
|
||||||
|
})"
|
||||||
|
x-init="await init(); $dispatch('form-data-ready', data)"
|
||||||
|
>
|
||||||
|
{% include "components/schema_form.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Applicability Domain (MLRR) -->
|
<!-- Applicability Domain (MLRR) -->
|
||||||
{% if meta.enabled_features.APPLICABILITY_DOMAIN %}
|
{% if meta.enabled_features.APPLICABILITY_DOMAIN %}
|
||||||
<div x-show="showMlrr" x-cloak>
|
<div x-show="showMlrr" x-cloak>
|
||||||
@ -338,7 +417,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
@click="submit('new_model_form')"
|
@click="submit('new_model_form')"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting || !selectedType || loadingSchemas"
|
||||||
>
|
>
|
||||||
<span x-show="!isSubmitting">Submit</span>
|
<span x-show="!isSubmitting">Submit</span>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
{% extends "framework_modern.html" %}
|
{% extends "framework_modern.html" %}
|
||||||
|
{% load envipytags %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
@ -82,6 +83,12 @@
|
|||||||
<div class="collapse-content">{{ compound.description }}</div>
|
<div class="collapse-content">{{ compound.description }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Extension Slot for Viz -->
|
||||||
|
{% epdb_slot_templates "epdb.objects.compound.viz" as viz_templates %}
|
||||||
|
{% for tpl in viz_templates %}
|
||||||
|
{% include tpl %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
<!-- Image Representation -->
|
<!-- Image Representation -->
|
||||||
<div class="collapse-arrow bg-base-200 collapse">
|
<div class="collapse-arrow bg-base-200 collapse">
|
||||||
<input type="checkbox" checked />
|
<input type="checkbox" checked />
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
{% extends "framework_modern.html" %}
|
{% extends "framework_modern.html" %}
|
||||||
|
{% load envipytags %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
@ -50,6 +51,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Extension Slot for Viz -->
|
||||||
|
{% epdb_slot_templates "epdb.objects.compound_structure.viz" as viz_templates %}
|
||||||
|
{% for tpl in viz_templates %}
|
||||||
|
{% include tpl %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
<!-- Image Representation -->
|
<!-- Image Representation -->
|
||||||
<div class="collapse-arrow bg-base-200 collapse">
|
<div class="collapse-arrow bg-base-200 collapse">
|
||||||
<input type="checkbox" checked />
|
<input type="checkbox" checked />
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
{% extends "framework_modern.html" %}
|
{% extends "framework_modern.html" %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
{% load envipytags %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<script src="https://d3js.org/d3.v7.min.js"></script>
|
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
@ -76,6 +78,10 @@
|
|||||||
{% block action_modals %}
|
{% block action_modals %}
|
||||||
{% include "modals/objects/add_pathway_node_modal.html" %}
|
{% include "modals/objects/add_pathway_node_modal.html" %}
|
||||||
{% include "modals/objects/add_pathway_edge_modal.html" %}
|
{% include "modals/objects/add_pathway_edge_modal.html" %}
|
||||||
|
{% epdb_slot_templates "epdb.modals.objects.pathway.add" as add_templates %}
|
||||||
|
{% for tpl in add_templates %}
|
||||||
|
{% include tpl %}
|
||||||
|
{% endfor %}
|
||||||
{% include "modals/objects/download_pathway_csv_modal.html" %}
|
{% include "modals/objects/download_pathway_csv_modal.html" %}
|
||||||
{% include "modals/objects/download_pathway_image_modal.html" %}
|
{% include "modals/objects/download_pathway_image_modal.html" %}
|
||||||
{% include "modals/objects/identify_missing_rules_modal.html" %}
|
{% include "modals/objects/identify_missing_rules_modal.html" %}
|
||||||
|
|||||||
@ -189,7 +189,8 @@
|
|||||||
x-data="schemaRenderer({
|
x-data="schemaRenderer({
|
||||||
rjsf: schemas[item.type.toLowerCase()],
|
rjsf: schemas[item.type.toLowerCase()],
|
||||||
data: item.data,
|
data: item.data,
|
||||||
mode: 'view'
|
mode: 'view',
|
||||||
|
attach_object: item.attach_object
|
||||||
})"
|
})"
|
||||||
x-init="init()"
|
x-init="init()"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -218,6 +218,12 @@
|
|||||||
|
|
||||||
<input type="hidden" name="next" value="{{ next }}" />
|
<input type="hidden" name="next" value="{{ next }}" />
|
||||||
|
|
||||||
|
{% if CAP_ENABLED %}
|
||||||
|
<cap-widget
|
||||||
|
data-cap-api-endpoint="{{ CAP_API_BASE }}/{{ CAP_SITE_KEY }}/"
|
||||||
|
></cap-widget>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- ToS and Academic Use Notice -->
|
<!-- ToS and Academic Use Notice -->
|
||||||
<div class="text-xs text-base-content/70 mt-2">
|
<div class="text-xs text-base-content/70 mt-2">
|
||||||
<p>
|
<p>
|
||||||
@ -233,7 +239,6 @@
|
|||||||
enviPath is free for academic and non-commercial use only.
|
enviPath is free for academic and non-commercial use only.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" name="confirmsignup" class="btn btn-success w-full">
|
<button type="submit" name="confirmsignup" class="btn btn-success w-full">
|
||||||
Sign Up
|
Sign Up
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -19,7 +19,16 @@
|
|||||||
type="text/css"
|
type="text/css"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{% block extra_styles %}{% endblock %}
|
{% if CAP_ENABLED %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@cap.js/widget@0.1.41/cap.min.js"></script>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/@cap.js/widget@0.1.41/src/cap.min.css"
|
||||||
|
/>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% block extra_styles %}
|
||||||
|
{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-base-100">
|
<body class="bg-base-100">
|
||||||
<div class="flex h-screen">
|
<div class="flex h-screen">
|
||||||
|
|||||||
@ -38,7 +38,7 @@ class PathwayViewTest(TestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
pathway_url = response.url
|
pathway_url = response["Location"]
|
||||||
|
|
||||||
pw = Pathway.objects.get(url=pathway_url)
|
pw = Pathway.objects.get(url=pathway_url)
|
||||||
self.assertEqual(self.user1_default_package, pw.package)
|
self.assertEqual(self.user1_default_package, pw.package)
|
||||||
@ -81,7 +81,7 @@ class PathwayViewTest(TestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
pathway_url = response.url
|
pathway_url = response["Location"]
|
||||||
|
|
||||||
pw = Pathway.objects.get(url=pathway_url)
|
pw = Pathway.objects.get(url=pathway_url)
|
||||||
self.assertEqual(self.package, pw.package)
|
self.assertEqual(self.package, pw.package)
|
||||||
@ -128,7 +128,7 @@ class PathwayViewTest(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
pathway_url = response.url
|
pathway_url = response["Location"]
|
||||||
pw = Pathway.objects.get(url=pathway_url)
|
pw = Pathway.objects.get(url=pathway_url)
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@ -166,3 +166,37 @@ class PathwayViewTest(TestCase):
|
|||||||
|
|
||||||
pw = Pathway.objects.get(url=pathway_url)
|
pw = Pathway.objects.get(url=pathway_url)
|
||||||
self.assertEqual(len(pw.aliases), 0)
|
self.assertEqual(len(pw.aliases), 0)
|
||||||
|
|
||||||
|
@override_settings(FLAGS={**s.FLAGS, "IUCLID_EXPORT": True})
|
||||||
|
def test_pathway_detail_shows_iuclid_export_action_when_enabled(self):
|
||||||
|
pathway = Pathway.create(self.package, "CCO", name="IUCLID Export Pathway")
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
reverse(
|
||||||
|
"package pathway detail",
|
||||||
|
kwargs={
|
||||||
|
"package_uuid": str(pathway.package.uuid),
|
||||||
|
"pathway_uuid": str(pathway.uuid),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, f"/api/v1/pathway/{pathway.uuid}/export/iuclid")
|
||||||
|
|
||||||
|
@override_settings(FLAGS={**s.FLAGS, "IUCLID_EXPORT": False})
|
||||||
|
def test_pathway_detail_hides_iuclid_export_action_when_disabled(self):
|
||||||
|
pathway = Pathway.create(self.package, "CCO", name="IUCLID Export Pathway")
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
reverse(
|
||||||
|
"package pathway detail",
|
||||||
|
kwargs={
|
||||||
|
"package_uuid": str(pathway.package.uuid),
|
||||||
|
"pathway_uuid": str(pathway.uuid),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertNotContains(response, f"/api/v1/pathway/{pathway.uuid}/export/iuclid")
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from django.conf import settings as s
|
from django.conf import settings as s
|
||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@ -5,6 +7,13 @@ from django.urls import reverse
|
|||||||
from epdb.logic import PackageManager
|
from epdb.logic import PackageManager
|
||||||
from epdb.models import Package, User
|
from epdb.models import Package, User
|
||||||
|
|
||||||
|
_LOGIN_REQUIRED_MW = "epdb.middleware.login_required_middleware.LoginRequiredMiddleware"
|
||||||
|
|
||||||
|
|
||||||
|
def _middleware_with_login_required():
|
||||||
|
mw = list(s.MIDDLEWARE)
|
||||||
|
return mw if _LOGIN_REQUIRED_MW in mw else [*mw, _LOGIN_REQUIRED_MW]
|
||||||
|
|
||||||
|
|
||||||
@override_settings(MODEL_DIR=s.FIXTURE_DIRS[0] / "models", CELERY_TASK_ALWAYS_EAGER=True)
|
@override_settings(MODEL_DIR=s.FIXTURE_DIRS[0] / "models", CELERY_TASK_ALWAYS_EAGER=True)
|
||||||
class UserViewTest(TestCase):
|
class UserViewTest(TestCase):
|
||||||
@ -39,6 +48,7 @@ class UserViewTest(TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertFalse(response.wsgi_request.user.is_authenticated)
|
self.assertFalse(response.wsgi_request.user.is_authenticated)
|
||||||
|
|
||||||
|
@override_settings(ADMIN_APPROVAL_REQUIRED=True)
|
||||||
def test_register(self):
|
def test_register(self):
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("register"),
|
reverse("register"),
|
||||||
@ -51,7 +61,8 @@ class UserViewTest(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(
|
self.assertContains(
|
||||||
response, "Your account has been created! An admin will activate it soon!"
|
response,
|
||||||
|
"Your account has been created! An admin will activate it soon!",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_register_password_mismatch(self):
|
def test_register_password_mismatch(self):
|
||||||
@ -81,14 +92,19 @@ class UserViewTest(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertFalse(response.wsgi_request.user.is_authenticated)
|
self.assertFalse(response.wsgi_request.user.is_authenticated)
|
||||||
|
|
||||||
|
@override_settings(MIDDLEWARE=_middleware_with_login_required())
|
||||||
def test_next_param_properly_handled(self):
|
def test_next_param_properly_handled(self):
|
||||||
response = self.client.get(reverse("packages"))
|
packages_url = reverse("packages")
|
||||||
|
response = self.client.get(packages_url)
|
||||||
self.assertRedirects(response, f"{reverse('login')}/?next=/package")
|
self.assertRedirects(
|
||||||
|
response,
|
||||||
|
f"{s.LOGIN_URL}?next={quote(packages_url)}",
|
||||||
|
fetch_redirect_response=False,
|
||||||
|
)
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("login"),
|
reverse("login"),
|
||||||
{"username": "user0", "password": "SuperSafe", "login": "true", "next": "/package"},
|
{"username": "user0", "password": "SuperSafe", "login": "true", "next": packages_url},
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertRedirects(response, reverse("packages"))
|
self.assertRedirects(response, packages_url)
|
||||||
|
|||||||
@ -88,6 +88,10 @@ class FormatConverter(object):
|
|||||||
def from_smiles(smiles):
|
def from_smiles(smiles):
|
||||||
return Chem.MolFromSmiles(smiles)
|
return Chem.MolFromSmiles(smiles)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_molfile(molfile: str):
|
||||||
|
return Chem.MolFromMolBlock(molfile)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def to_smiles(mol, canonical=False):
|
def to_smiles(mol, canonical=False):
|
||||||
return Chem.MolToSmiles(mol, canonical=canonical)
|
return Chem.MolToSmiles(mol, canonical=canonical)
|
||||||
@ -171,12 +175,17 @@ class FormatConverter(object):
|
|||||||
try:
|
try:
|
||||||
Chem.Kekulize(mol)
|
Chem.Kekulize(mol)
|
||||||
except Exception:
|
except Exception:
|
||||||
mc = Chem.Mol(mol.ToBinary())
|
mol = Chem.Mol(mol.ToBinary())
|
||||||
|
|
||||||
if not mc.GetNumConformers():
|
if not mol.GetNumConformers():
|
||||||
Chem.rdDepictor.Compute2DCoords(mc)
|
Chem.rdDepictor.Compute2DCoords(mol)
|
||||||
|
|
||||||
pass
|
drawer = rdMolDraw2D.MolDraw2DCairo(*mol_size)
|
||||||
|
opts = drawer.drawOptions()
|
||||||
|
opts.clearBackground = False
|
||||||
|
drawer.DrawMolecule(mol)
|
||||||
|
drawer.FinishDrawing()
|
||||||
|
return drawer.GetDrawingText()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def normalize(smiles):
|
def normalize(smiles):
|
||||||
|
|||||||
@ -64,7 +64,7 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from envipy_additional_information import HalfLife, HalfLifeWS
|
from envipy_additional_information import HalfLife, HalfLifeWS, HalfLifeModel
|
||||||
from envipy_additional_information.information import Interval
|
from envipy_additional_information.information import Interval
|
||||||
from envipy_additional_information.parsers import (
|
from envipy_additional_information.parsers import (
|
||||||
AcidityParser,
|
AcidityParser,
|
||||||
@ -473,17 +473,12 @@ def build_additional_information_from_request(request, type_):
|
|||||||
|
|
||||||
comment = get_parameter_or_empty_string(request, "comment")
|
comment = get_parameter_or_empty_string(request, "comment")
|
||||||
source = get_parameter_or_empty_string(request, "source")
|
source = get_parameter_or_empty_string(request, "source")
|
||||||
first_order = get_parameter_or_empty_string(request, "firstOrder")
|
# first_order = get_parameter_or_empty_string(request, "firstOrder")
|
||||||
model = get_parameter_or_empty_string(request, "model")
|
model = get_parameter_or_empty_string(request, "model")
|
||||||
fit = get_parameter_or_empty_string(request, "fit")
|
fit = get_parameter_or_empty_string(request, "fit")
|
||||||
|
|
||||||
if first_order != "":
|
if model:
|
||||||
if model != "":
|
model = HalfLifeModel(model.upper())
|
||||||
raise ValueError("not both, model and firstOrder can be set!")
|
|
||||||
if first_order == "true":
|
|
||||||
model = "SFO"
|
|
||||||
else:
|
|
||||||
logger.info("firstOrder is set to false which is not meaningful")
|
|
||||||
|
|
||||||
return HalfLife(model=model, fit=fit, comment=comment, dt50=i, source=source)
|
return HalfLife(model=model, fit=fit, comment=comment, dt50=i, source=source)
|
||||||
|
|
||||||
@ -508,6 +503,10 @@ def build_additional_information_from_request(request, type_):
|
|||||||
comment_ws = get_parameter_or_empty_string(request, "comment_ws")
|
comment_ws = get_parameter_or_empty_string(request, "comment_ws")
|
||||||
source_ws = get_parameter_or_empty_string(request, "source_ws")
|
source_ws = get_parameter_or_empty_string(request, "source_ws")
|
||||||
model_ws = get_parameter_or_empty_string(request, "model_ws")
|
model_ws = get_parameter_or_empty_string(request, "model_ws")
|
||||||
|
|
||||||
|
if model_ws:
|
||||||
|
model_ws = HalfLifeModel(model_ws.upper())
|
||||||
|
|
||||||
fit_ws = get_parameter_or_empty_string(request, "fit_ws")
|
fit_ws = get_parameter_or_empty_string(request, "fit_ws")
|
||||||
|
|
||||||
dt50_total = IntervalParser.from_string(hl_ws_total)
|
dt50_total = IntervalParser.from_string(hl_ws_total)
|
||||||
|
|||||||
@ -51,16 +51,14 @@ def discover_plugins(_cls: Type = None) -> Dict[str, Type]:
|
|||||||
plugin_class = entry_point.load()
|
plugin_class = entry_point.load()
|
||||||
if _cls:
|
if _cls:
|
||||||
if issubclass(plugin_class, _cls):
|
if issubclass(plugin_class, _cls):
|
||||||
instance = plugin_class()
|
plugins[plugin_class.identifier()] = plugin_class
|
||||||
plugins[instance.identifier()] = instance
|
|
||||||
else:
|
else:
|
||||||
if (
|
if (
|
||||||
issubclass(plugin_class, Classifier)
|
issubclass(plugin_class, Classifier)
|
||||||
or issubclass(plugin_class, Descriptor)
|
or issubclass(plugin_class, Descriptor)
|
||||||
or issubclass(plugin_class, Property)
|
or issubclass(plugin_class, Property)
|
||||||
):
|
):
|
||||||
instance = plugin_class()
|
plugins[plugin_class.identifier()] = plugin_class
|
||||||
plugins[instance.identifier()] = plugin_class
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error loading plugin {entry_point.name}: {e}")
|
print(f"Error loading plugin {entry_point.name}: {e}")
|
||||||
@ -70,7 +68,7 @@ def discover_plugins(_cls: Type = None) -> Dict[str, Type]:
|
|||||||
module_path, class_name = plugin_module.rsplit(".", 1)
|
module_path, class_name = plugin_module.rsplit(".", 1)
|
||||||
module = importlib.import_module(module_path)
|
module = importlib.import_module(module_path)
|
||||||
plugin_class = getattr(module, class_name)
|
plugin_class = getattr(module, class_name)
|
||||||
instance = plugin_class()
|
if issubclass(plugin_class, _cls):
|
||||||
plugins[instance.identifier()] = plugin_class
|
plugins[plugin_class.identifier()] = plugin_class
|
||||||
|
|
||||||
return plugins
|
return plugins
|
||||||
|
|||||||
Reference in New Issue
Block a user