31 Commits

Author SHA1 Message Date
1e43c298d2 [Fix] Simplify Depth adjustment (#386)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#386
2026-05-12 21:04:56 +12:00
b39fc7eaf8 [Fix] Update Node depth when adding new Edges to a Pathway (#384)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#384
2026-05-12 09:40:35 +12:00
a2fc9f72cb [Feature] Make use of HalfLifeModel Enum (#383)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#383
2026-05-12 09:23:56 +12:00
734b02767e [Fix] Update plotting imports and thread handling in Pepper class (#382)
- plt.subplot does not work reliably with async/ threads.
- Bug in thread run that would fail with env set (string to number)

Reviewed-on: enviPath/enviPy#382
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2026-05-12 06:43:26 +12:00
9d70db2ca2 [Fix] Wrong indentation in welcome mail (#373)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#373
2026-04-22 08:47:05 +12:00
fec26d0233 [Feature] Admin Actions for Activation and Affiliation Request (#372)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#372
2026-04-22 08:36:31 +12:00
689f7998eb [Dep] Updated enviFormer, additional information lib, aiohttp, fsspec (#371)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#371
2026-04-22 06:38:31 +12:00
8498e59fa1 [Feature] Changes required for non public tenants (#370)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#370
2026-04-22 06:08:39 +12:00
b508511cd6 Implement basic group listing and re-enabled group creation 2026-04-14 20:58:12 +02:00
877804c0ff [Feature] Path prefixes (#369)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#369
2026-04-14 21:59:29 +12:00
964574c700 [Feature] Biotransformer in enviPath (#364)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#364
2026-04-10 00:00:13 +12:00
5029a8cda5 [Feature] Dockerized Setup (#366)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#366
2026-04-07 20:06:46 +12:00
d06bd0d4fd [Feature] Minimal IUCLID export (#338)
This is an initial implementation that creates a working minimal .i6z document.
It passes schema validation and can be imported into IUCLID.

Caveat:
IUCLID files target individual compounds.
Pathway is not actually covered by the format.

It can be added in either soil or water and soil OECD endpoints.
**I currently only implemented the soil endpoint for all data.**

This sort of works, and I can report all degradation products in a pathway (not a nice view, but we can report many transformation products and add a diagram attachment in the future).

Adding additional information is an absolute pain, as we need to explicitly map each type of information to the relevant OECD field.
I use the XSD scheme for validation, but unfortunately the IUCLID parser is not fully compliant and requires a specific order, etc.

The workflow is: finding the AI structure from the XSD scheme -> make the scheme validation pass -> upload to IUCLID to get obscure error messages -> guess what could be wrong -> repeat 💣

New specifications get released once per year, so we will have to update accordingly.
I believe that this should be a more expensive feature, as it requires significant effort to uphold.

Currently implemented for root compound only in SOIL:

- Soil Texture 2
- Soil Texture 1
- pH value
- Half-life per soil sample / scenario (mapped to disappearance; not sure about that).
- CEC
- Organic Matter (only Carbon)
- Moisture content
- Humidity

<img width="2123" alt="image.png" src="attachments/d29830e1-65ef-4136-8939-1825e0959c62">
<img width="2124" alt="image.png" src="attachments/ac9de2ac-bf68-4ba4-b40b-82f810a9de93">
<img width="2139" alt="image.png" src="attachments/5674c7e6-865e-420e-974a-6b825b331e6c">

Reviewed-on: enviPath/enviPy#338
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2026-04-07 19:46:12 +12:00
f7c45b8015 [Feature] Add legacy api endpoint to mimic ReferringScenarios (#362)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#362
2026-03-17 19:44:47 +13:00
68aea97013 [Feature] Simple template extension mechanism (#361)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#361
2026-03-16 21:06:20 +13:00
3cc7fa9e8b [Fix] Add Captcha vars to Template (#359)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#359
2026-03-13 11:46:34 +13:00
21f3390a43 [Feature] Add Captchas to avoid spam registrations (#358)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#358
2026-03-13 11:36:48 +13:00
8cdf91c8fb [Fix] Broken Model Creation (#356)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#356
2026-03-12 11:34:14 +13:00
bafbf11322 [Fix] Broken Enzyme Links (#353)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#353
2026-03-12 10:25:47 +13:00
f1a9456d1d [Fix] enviFormer prediction (#352)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#352
2026-03-12 08:49:44 +13:00
e0764126e3 [Fix] Scenario Review Status + Depth issues (#351)
https://envipath.org/api/legacy/package/32de3cf4-e3e6-4168-956e-32fa5ddb0ce1/pathway/1d537657-298c-496b-9e6f-2bec0cbe0678

-> Node.depth can be float for Dummynodes
-> Scenarios in Edge.d3_json were lacking a reviewed flag

Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#351
2026-03-12 08:28:20 +13:00
ef0c45b203 [Fix] Pepper display probability calculation (#349)
Probability of persistent is now calculated to include very persistent.

Reviewed-on: enviPath/enviPy#349
Co-authored-by: Liam Brydon <lbry121@aucklanduni.ac.nz>
Co-committed-by: Liam Brydon <lbry121@aucklanduni.ac.nz>
2026-03-11 19:12:55 +13:00
b737fc93eb [Feature] Search for Permissions, Prep Compound / Structure to be extended, Prep Template overwrites (#347)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#347
2026-03-11 11:27:15 +13:00
d4295c9349 [Fix] bootstrap command now reflects new Scenario/AdditionalInformation structure (#346)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#346
2026-03-07 03:14:28 +13:00
c6ff97694d [Feature] PEPPER in enviPath (#332)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#332
2026-03-06 22:11:22 +13:00
6e00926371 [Feature] Scenario and Additional Information creation via enviPath-python, Add Half Lifes to API Output, Fix source/target ids in legacy API (#340)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#340
2026-03-06 07:20:18 +13:00
81cc612e69 [Feature] Populate Batch Predict Table by CSV (#339)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#339
2026-03-06 03:15:44 +13:00
cc9598775c [Fix] Fix Perm for creating entities (#341)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#341
2026-02-27 03:56:33 +13:00
d2c2e643cb [Fix] Compound Grouping, Identity prediction of enviFormer, Setting params (#337)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#337
2026-02-20 10:14:28 +13:00
0ff046363c [Fix] Fixed failing frontend tests due to renaming (#335)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#335
2026-02-17 03:09:32 +13:00
5150027f0d [Fix] Login via email, prevent Usernames with certain chars 2026-02-16 13:58:06 +01:00
127 changed files with 73424 additions and 1908 deletions

93
.dockerignore Normal file
View 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/

View File

@ -3,10 +3,20 @@ EP_DATA_DIR=
ALLOWED_HOSTS=
DEBUG=
LOG_LEVEL=
MODEL_BUILDING_ENABLED=
APPLICABILITY_DOMAIN_ENABLED=
ENVIFORMER_PRESENT=
FLAG_CELERY_PRESENT=
SERVER_URL=
ENVIFORMER_DEVICE=
PLUGINS_ENABLED=
SERVER_URL=
SERVER_PATH=
ADMIN_APPROVAL_REQUIRED=
REGISTRATION_MANDATORY=
LOG_DIR=
# Celery
FLAG_CELERY_PRESENT=
CELERY_BROKER_URL=
CELERY_RESULT_BACKEND=
# DB
POSTGRES_SERVICE_NAME=
POSTGRES_DB=
@ -16,5 +26,30 @@ POSTGRES_PORT=
# MAIL
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
# MATOMO
MATOMO_SITE_ID
DEFAULT_FROM_EMAIL=
SERVER_EMAIL=
# SENTRY
SENTRY_ENABLED=
SENTRY_DSN=
SENTRY_ENVIRONMENT=
# MS ENTRA
MS_ENTRA_ENABLED=
MS_CLIENT_ID=
MS_CLIENT_SECRET=
MS_TENANT_ID=
MS_REDIRECT_URI=
MS_SCOPES=
# Tenant
TENANT=
EPDB_PACKAGE_MODEL=
# Captcha
CAP_ENABLED=
CAP_API_BASE=
CAP_SITE_KEY=
CAP_SECRET_KEY=
# QUARKUS (JAVA)
ENVIRULE_ENABLED=
FINGERPRINT_URL=
# Biotransformer
BIOTRANSFORMER_ENABLED=
BIOTRANSFORMER_URL=

View File

@ -5,10 +5,12 @@ repos:
rev: v3.2.0
hooks:
- id: trailing-whitespace
exclude: epiuclid/schemas/
- id: end-of-file-fixer
exclude: epiuclid/schemas/
- id: check-yaml
- id: check-added-large-files
exclude: ^static/images/
exclude: ^static/images/|^epiuclid/schemas/|^fixtures/
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.13.3

98
Dockerfile Normal file
View 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
View File

@ -0,0 +1,112 @@
import enum
from datetime import datetime
from typing import List
import requests
from django.conf import settings as s
# Once stable these will be exposed by enviPy-plugins lib
from envipy_additional_information import EnviPyModel, UIConfig, WidgetType # noqa: I001
from envipy_additional_information import register # noqa: I001
from bridge.contracts import Classifier # noqa: I001
from bridge.dto import (
BuildResult,
EnviPyDTO,
EvaluationResult,
RunResult,
TransformationProductPrediction,
) # noqa: I001
class BiotransformerEnvType(enum.Enum):
CYP450 = "CYP450"
ALLHUMAN = "ALLHUMAN"
ECBASED = "ECBASED"
HGUT = "HGUT"
PHASEII = "PHASEII"
ENV = "ENV"
@register("biotransformerconfig")
class BiotransformerConfig(EnviPyModel):
env_type: BiotransformerEnvType
class UI:
title = "Biotransformer Type"
env_type = UIConfig(widget=WidgetType.SELECT, label="Biotransformer Type", order=1)
class Biotransformer(Classifier):
Config = BiotransformerConfig
def __init__(self, config: BiotransformerConfig | None = None):
super().__init__(config)
self.url = f"{s.BIOTRANSFORMER_URL}/biotransformer"
@classmethod
def requires_rule_packages(cls) -> bool:
return False
@classmethod
def requires_data_packages(cls) -> bool:
return False
@classmethod
def identifier(cls) -> str:
return "biotransformer3"
@classmethod
def name(cls) -> str:
return "Biotransformer 3.0"
@classmethod
def display(cls) -> str:
return "Biotransformer 3.0"
def build(self, eP: EnviPyDTO, *args, **kwargs) -> BuildResult | None:
return
def run(self, eP: EnviPyDTO, *args, **kwargs) -> RunResult:
smiles = [c.smiles for c in eP.get_compounds()]
preds = self._post(smiles)
results = []
for substrate in preds.keys():
results.append(
TransformationProductPrediction(
substrate=substrate,
products=preds[substrate],
)
)
return RunResult(
producer=eP.get_context().url,
description=f"Generated at {datetime.now()}",
result=results,
)
def evaluate(self, eP: EnviPyDTO, *args, **kwargs) -> EvaluationResult:
pass
def build_and_evaluate(self, eP: EnviPyDTO, *args, **kwargs) -> EvaluationResult:
pass
def _post(self, smiles: List[str]) -> dict[str, dict[str, float]]:
data = {"substrates": smiles, "mode": self.config.env_type.value}
res = requests.post(self.url, json=data)
res.raise_for_status()
# Example Response JSON:
# {
# 'products': {
# 'CN1C=NC2=C1C(=O)N(C(=O)N2C)C': {
# 'CN1C2=C(C(=O)N(C)C1=O)NC=N2': 0.5,
# 'CN1C=NC2=C1C(=O)N(C)C(=O)N2.CN1C=NC2=C1C(=O)NC(=O)N2C.CO': 0.5
# }
# }
# }
return res.json()["products"]

0
bridge/__init__.py Normal file
View File

400
bridge/contracts.py Normal file
View File

@ -0,0 +1,400 @@
import enum
from abc import ABC, abstractmethod
from envipy_additional_information import EnviPyModel
from .dto import BuildResult, EnviPyDTO, EvaluationResult, RunResult
class PropertyType(enum.Enum):
"""
Enumeration representing different types of properties.
PropertyType is an Enum class that defines categories or types of properties
based on their weight or nature. It can typically be used when classifying
objects or entities by their weight classification, such as lightweight or heavy.
"""
LIGHTWEIGHT = "lightweight"
HEAVY = "heavy"
class Plugin(ABC):
"""
Defines an abstract base class Plugin to serve as a blueprint for plugins.
This class establishes the structure that all plugin implementations must
follow. It enforces the presence of required methods to ensure consistent
functionality across all derived classes.
"""
@classmethod
@abstractmethod
def identifier(cls) -> str:
pass
@classmethod
@abstractmethod
def name(cls) -> str:
"""
Represents an abstract method that provides a contract for implementing a method
to return a name as a string. Must be implemented in subclasses.
Name must be unique across all plugins.
Methods
-------
name() -> str
Abstract method to be defined in subclasses, which returns a string
representing a name.
"""
pass
@classmethod
@abstractmethod
def display(cls) -> str:
"""
An abstract method that must be implemented by subclasses to display
specific information or behavior. The method ensures that all subclasses
provide their own implementation of the display functionality.
Raises:
NotImplementedError: Raises this error when the method is not implemented
in a subclass.
Returns:
str: A string used in dropdown menus or other user interfaces to display
"""
pass
class Property(Plugin):
@classmethod
@abstractmethod
def requires_rule_packages(cls) -> bool:
"""
Defines an abstract method to determine whether rule packages are required.
This method should be implemented by subclasses to specify if they depend
on rule packages for their functioning.
Raises:
NotImplementedError: If the subclass has not implemented this method.
@return: A boolean indicating if rule packages are required.
"""
pass
@classmethod
@abstractmethod
def requires_data_packages(cls) -> bool:
"""
Defines an abstract method to determine whether data packages are required.
This method should be implemented by subclasses to specify if they depend
on data packages for their functioning.
Raises:
NotImplementedError: If the subclass has not implemented this method.
Returns:
bool: True if the service requires data packages, False otherwise.
"""
pass
@abstractmethod
def get_type(self) -> PropertyType:
"""
An abstract method that provides the type of property. This method must
be implemented by subclasses to specify the appropriate property type.
Raises:
NotImplementedError: If the method is not implemented by a subclass.
Returns:
PropertyType: The type of the property associated with the implementation.
"""
pass
def is_heavy(self):
"""
Determines if the current property type is heavy.
This method evaluates whether the property type returned from the `get_type()`
method is classified as `HEAVY`. It utilizes the `PropertyType.HEAVY` constant
for this comparison.
Raises:
AttributeError: If the `get_type()` method is not defined or does not return
a valid value.
Returns:
bool: True if the property type is `HEAVY`, otherwise False.
"""
return self.get_type() == PropertyType.HEAVY
@abstractmethod
def build(self, eP: EnviPyDTO, *args, **kwargs) -> BuildResult | None:
"""
Abstract method to prepare and construct a specific build process based on the provided
environment data transfer object (EnviPyDTO). This method should be implemented by
subclasses to handle the particular requirements of the environment.
Parameters:
eP : EnviPyDTO
The data transfer object containing environment details for the build process.
*args :
Additional positional arguments required for the build.
**kwargs :
Additional keyword arguments to offer flexibility and customization for
the build process.
Returns:
BuildResult | None
Returns a BuildResult instance if the build operation succeeds, else returns None.
Raises:
NotImplementedError
If the method is not implemented in a subclass.
"""
pass
@abstractmethod
def run(self, eP: EnviPyDTO, *args, **kwargs) -> RunResult:
"""
Represents an abstract base class for executing a generic process with
provided parameters and returning a standardized result.
Attributes:
None.
Methods:
run(eP, *args, **kwargs):
Executes a task with specified input parameters and optional
arguments, returning the outcome in the form of a RunResult object.
This is an abstract method and must be implemented in subclasses.
Raises:
NotImplementedError: If the subclass does not implement the abstract
method.
Parameters:
eP (EnviPyDTO): The primary object containing information or data required
for processing. Mandatory.
*args: Variable length argument list for additional positional arguments.
**kwargs: Arbitrary keyword arguments for additional options or settings.
Returns:
RunResult: A result object encapsulating the status, output, or details
of the process execution.
"""
pass
@abstractmethod
def evaluate(self, eP: EnviPyDTO, *args, **kwargs) -> EvaluationResult:
"""
Abstract method for evaluating data based on the given input and additional arguments.
This method is intended to be implemented by subclasses and provides
a mechanism to perform an evaluation procedure based on input encapsulated
in an EnviPyDTO object.
Parameters:
eP : EnviPyDTO
The data transfer object containing necessary input for evaluation.
*args : tuple
Additional positional arguments for the evaluation process.
**kwargs : dict
Additional keyword arguments for the evaluation process.
Returns:
EvaluationResult
The result of the evaluation performed by the method.
Raises:
NotImplementedError
If the method is not implemented in the subclass.
"""
pass
@abstractmethod
def build_and_evaluate(self, eP: EnviPyDTO, *args, **kwargs) -> EvaluationResult:
"""
An abstract method designed to build and evaluate a model or system using the provided
environmental parameters and additional optional arguments.
Args:
eP (EnviPyDTO): The environmental parameters required for building and evaluating.
*args: Additional positional arguments.
**kwargs: Additional keyword arguments.
Returns:
EvaluationResult: The result of the evaluation process.
Raises:
NotImplementedError: If the method is not implemented by a subclass.
"""
pass
class Classifier(Plugin):
Config: type[EnviPyModel] | None = None
def __init__(self, config: EnviPyModel | None = None):
self.config = config
@classmethod
def has_config(cls) -> bool:
return cls.Config is not None
@classmethod
def parse_config(cls, data: dict | None = None) -> EnviPyModel | None:
if cls.Config is None:
return None
return cls.Config(**(data or {}))
@classmethod
def create(cls, data: dict | None = None):
return cls(cls.parse_config(data))
@classmethod
@abstractmethod
def requires_rule_packages(cls) -> bool:
"""
Defines an abstract method to determine whether rule packages are required.
This method should be implemented by subclasses to specify if they depend
on rule packages for their functioning.
Raises:
NotImplementedError: If the subclass has not implemented this method.
@return: A boolean indicating if rule packages are required.
"""
pass
@classmethod
@abstractmethod
def requires_data_packages(cls) -> bool:
"""
Defines an abstract method to determine whether data packages are required.
This method should be implemented by subclasses to specify if they depend
on data packages for their functioning.
Raises:
NotImplementedError: If the subclass has not implemented this method.
Returns:
bool: True if the service requires data packages, False otherwise.
"""
pass
@abstractmethod
def build(self, eP: EnviPyDTO, *args, **kwargs) -> BuildResult | None:
"""
Abstract method to prepare and construct a specific build process based on the provided
environment data transfer object (EnviPyDTO). This method should be implemented by
subclasses to handle the particular requirements of the environment.
Parameters:
eP : EnviPyDTO
The data transfer object containing environment details for the build process.
*args :
Additional positional arguments required for the build.
**kwargs :
Additional keyword arguments to offer flexibility and customization for
the build process.
Returns:
BuildResult | None
Returns a BuildResult instance if the build operation succeeds, else returns None.
Raises:
NotImplementedError
If the method is not implemented in a subclass.
"""
pass
@abstractmethod
def run(self, eP: EnviPyDTO, *args, **kwargs) -> RunResult:
"""
Represents an abstract base class for executing a generic process with
provided parameters and returning a standardized result.
Attributes:
None.
Methods:
run(eP, *args, **kwargs):
Executes a task with specified input parameters and optional
arguments, returning the outcome in the form of a RunResult object.
This is an abstract method and must be implemented in subclasses.
Raises:
NotImplementedError: If the subclass does not implement the abstract
method.
Parameters:
eP (EnviPyDTO): The primary object containing information or data required
for processing. Mandatory.
*args: Variable length argument list for additional positional arguments.
**kwargs: Arbitrary keyword arguments for additional options or settings.
Returns:
RunResult: A result object encapsulating the status, output, or details
of the process execution.
"""
pass
@abstractmethod
def evaluate(self, eP: EnviPyDTO, *args, **kwargs) -> EvaluationResult | None:
"""
Abstract method for evaluating data based on the given input and additional arguments.
This method is intended to be implemented by subclasses and provides
a mechanism to perform an evaluation procedure based on input encapsulated
in an EnviPyDTO object.
Parameters:
eP : EnviPyDTO
The data transfer object containing necessary input for evaluation.
*args : tuple
Additional positional arguments for the evaluation process.
**kwargs : dict
Additional keyword arguments for the evaluation process.
Returns:
EvaluationResult
The result of the evaluation performed by the method.
Raises:
NotImplementedError
If the method is not implemented in the subclass.
"""
pass
@abstractmethod
def build_and_evaluate(self, eP: EnviPyDTO, *args, **kwargs) -> EvaluationResult | None:
"""
An abstract method designed to build and evaluate a model or system using the provided
environmental parameters and additional optional arguments.
Args:
eP (EnviPyDTO): The environmental parameters required for building and evaluating.
*args: Additional positional arguments.
**kwargs: Additional keyword arguments.
Returns:
EvaluationResult: The result of the evaluation process.
Raises:
NotImplementedError: If the method is not implemented by a subclass.
"""
pass

149
bridge/dto.py Normal file
View File

@ -0,0 +1,149 @@
from dataclasses import dataclass
from typing import Any, List, Optional, Protocol
from envipy_additional_information import EnviPyModel, register
from pydantic import HttpUrl
from utilities.chem import FormatConverter, ProductSet
@dataclass(frozen=True, slots=True)
class Context:
uuid: str
url: str
work_dir: str
class CompoundProto(Protocol):
url: str | None
name: str | None
smiles: str
class RuleProto(Protocol):
url: str
name: str
def apply(self, smiles, *args, **kwargs): ...
class ReactionProto(Protocol):
url: str
name: str
rules: List[RuleProto]
class EnviPyDTO(Protocol):
def get_context(self) -> Context: ...
def get_compounds(self) -> List[CompoundProto]: ...
def get_reactions(self) -> List[ReactionProto]: ...
def get_rules(self) -> List[RuleProto]: ...
@staticmethod
def standardize(smiles, remove_stereo=False, canonicalize_tautomers=False): ...
@staticmethod
def apply(
smiles: str,
smirks: str,
preprocess_smiles: bool = True,
bracketize: bool = True,
standardize: bool = True,
kekulize: bool = True,
remove_stereo: bool = True,
reactant_filter_smarts: str | None = None,
product_filter_smarts: str | None = None,
) -> List["ProductSet"]: ...
class EnviPyPrediction(EnviPyModel):
pass
class PropertyPrediction(EnviPyPrediction):
pass
class TransformationProductPrediction(EnviPyPrediction):
substrate: str
products: dict[str, float]
@register("buildresult")
class BuildResult(EnviPyModel):
data: dict[str, Any] | List[dict[str, Any]] | None
@register("runresult")
class RunResult(EnviPyModel):
producer: HttpUrl
description: Optional[str] = None
result: EnviPyPrediction | List[EnviPyPrediction]
@register("evaluationresult")
class EvaluationResult(EnviPyModel):
data: dict[str, Any] | List[dict[str, Any]] | None
class BaseDTO(EnviPyDTO):
def __init__(
self,
uuid: str,
url: str,
work_dir: str,
compounds: List[CompoundProto],
reactions: List[ReactionProto],
rules: List[RuleProto],
):
self.uuid = uuid
self.url = url
self.work_dir = work_dir
self.compounds = compounds
self.reactions = reactions
self.rules = rules
def get_context(self) -> Context:
return Context(uuid=self.uuid, url=self.url, work_dir=self.work_dir)
def get_compounds(self) -> List[CompoundProto]:
return self.compounds
def get_reactions(self) -> List[ReactionProto]:
return self.reactions
def get_rules(self) -> List[RuleProto]:
return self.rules
@staticmethod
def standardize(smiles, remove_stereo=False, canonicalize_tautomers=False):
return FormatConverter.standardize(
smiles, remove_stereo=remove_stereo, canonicalize_tautomers=canonicalize_tautomers
)
@staticmethod
def apply(
smiles: str,
smirks: str,
preprocess_smiles: bool = True,
bracketize: bool = True,
standardize: bool = True,
kekulize: bool = True,
remove_stereo: bool = True,
reactant_filter_smarts: str | None = None,
product_filter_smarts: str | None = None,
) -> List["ProductSet"]:
return FormatConverter.apply(
smiles,
smirks,
preprocess_smiles,
bracketize,
standardize,
kekulize,
remove_stereo,
reactant_filter_smarts,
product_filter_smarts,
)

50
docker-compose.yml Normal file
View 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:

View File

@ -14,7 +14,6 @@ import os
from pathlib import Path
from dotenv import load_dotenv
from envipy_plugins import Classifier, Property, Descriptor
from sklearn.ensemble import RandomForestClassifier
from sklearn.tree import DecisionTreeClassifier
@ -38,7 +37,6 @@ ALLOWED_HOSTS = os.environ["ALLOWED_HOSTS"].split(",")
# Application definition
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
@ -46,6 +44,7 @@ INSTALLED_APPS = [
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.postgres",
# 3rd party
"django_extensions",
"oauth2_provider",
@ -75,6 +74,7 @@ AUTHENTICATION_BACKENDS = [
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
@ -93,10 +93,19 @@ if os.environ.get("REGISTRATION_MANDATORY", False) == "True":
ROOT_URLCONF = "envipath.urls"
TEMPLATE_DIRS = [
os.path.join(BASE_DIR, "templates"),
]
# If we have a non-public tenant, we might need to overwrite some templates
# search TENANT folder first...
if TENANT != "public":
TEMPLATE_DIRS.insert(0, os.path.join(BASE_DIR, TENANT, "templates"))
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": (os.path.join(BASE_DIR, "templates"),),
"DIRS": TEMPLATE_DIRS,
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
@ -128,6 +137,13 @@ DATABASES = {
}
}
if os.environ.get("USE_TEMPLATE_DB", False) == "True":
DATABASES["default"]["TEST"] = {
"NAME": f"test_{os.environ['TEMPLATE_DB']}",
"TEMPLATE": os.environ["TEMPLATE_DB"],
}
# Password validation
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
@ -175,11 +191,21 @@ ADMIN_APPROVAL_REQUIRED = os.environ.get("ADMIN_APPROVAL_REQUIRED", "False") ==
# SESAME_MAX_AGE = 300
# # TODO set to "home"
# LOGIN_REDIRECT_URL = "/"
SERVER_HOST = os.environ.get("SERVER_URL", "http://localhost:8000")
SERVER_PATH = os.environ.get("SERVER_PATH", "")
SERVER_URL = SERVER_HOST
if SERVER_PATH:
SERVER_URL = os.path.join(SERVER_HOST, SERVER_PATH)
LOGIN_URL = "/login/"
if SERVER_PATH:
LOGIN_URL = f"/{SERVER_PATH}/login/"
SERVER_URL = os.environ.get("SERVER_URL", "http://localhost:8000")
CSRF_TRUSTED_ORIGINS = [SERVER_URL]
CSRF_TRUSTED_ORIGINS = [SERVER_HOST]
AMBIT_URL = "http://localhost:9001"
DEFAULT_VALUES = {"description": "no description"}
@ -201,19 +227,20 @@ if not os.path.exists(LOG_DIR):
os.mkdir(LOG_DIR)
PLUGIN_DIR = os.path.join(EP_DATA_DIR, "plugins")
if not os.path.exists(PLUGIN_DIR):
os.mkdir(PLUGIN_DIR)
API_PAGINATION_DEFAULT_PAGE_SIZE = int(os.environ.get("API_PAGINATION_DEFAULT_PAGE_SIZE", 50))
PAGINATION_MAX_PER_PAGE_SIZE = int(
os.environ.get("API_PAGINATION_MAX_PAGE_SIZE", 100)
) # Ninja override
if not os.path.exists(PLUGIN_DIR):
os.mkdir(PLUGIN_DIR)
# Set this as our static root dir
STATIC_ROOT = STATIC_DIR
STATIC_URL = "/static/"
if SERVER_PATH:
STATIC_URL = f"/{SERVER_PATH}/static/"
# Where the sources are stored...
STATICFILES_DIRS = (BASE_DIR / "static",)
@ -277,9 +304,8 @@ if not FLAG_CELERY_PRESENT:
# Celery Configuration Options
CELERY_TIMEZONE = "Europe/Berlin"
# Celery Configuration
CELERY_BROKER_URL = "redis://localhost:6379/0" # Use Redis as message broker
CELERY_RESULT_BACKEND = "redis://localhost:6379/1"
CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "redis://localhost:6379/0")
CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", "redis://localhost:6379/1")
CELERY_ACCEPT_CONTENT = ["json"]
CELERY_TASK_SERIALIZER = "json"
@ -311,22 +337,21 @@ DEFAULT_MODEL_PARAMS = {
"num_chains": 10,
}
DEFAULT_MAX_NUMBER_OF_NODES = 30
DEFAULT_MAX_DEPTH = 5
DEFAULT_MAX_NUMBER_OF_NODES = 50
DEFAULT_MAX_DEPTH = 8
DEFAULT_MODEL_THRESHOLD = 0.25
# Loading Plugins
PLUGINS_ENABLED = os.environ.get("PLUGINS_ENABLED", "False") == "True"
if PLUGINS_ENABLED:
from utilities.plugin import discover_plugins
CLASSIFIER_PLUGINS = discover_plugins(_cls=Classifier)
PROPERTY_PLUGINS = discover_plugins(_cls=Property)
DESCRIPTOR_PLUGINS = discover_plugins(_cls=Descriptor)
BASE_PLUGINS = os.environ.get("BASE_PLUGINS", None)
if BASE_PLUGINS:
BASE_PLUGINS = BASE_PLUGINS.split(",")
else:
CLASSIFIER_PLUGINS = {}
PROPERTY_PLUGINS = {}
DESCRIPTOR_PLUGINS = {}
BASE_PLUGINS = []
CLASSIFIER_PLUGINS = {}
PROPERTY_PLUGINS = {}
DESCRIPTOR_PLUGINS = {}
SENTRY_ENABLED = os.environ.get("SENTRY_ENABLED", "False") == "True"
if SENTRY_ENABLED:
@ -350,6 +375,10 @@ if SENTRY_ENABLED:
before_send=before_send,
)
IUCLID_EXPORT_ENABLED = os.environ.get("IUCLID_EXPORT_ENABLED", "False") == "True"
if IUCLID_EXPORT_ENABLED:
INSTALLED_APPS.append("epiuclid")
# compile into digestible flags
FLAGS = {
"MODEL_BUILDING": MODEL_BUILDING_ENABLED,
@ -358,6 +387,7 @@ FLAGS = {
"SENTRY": SENTRY_ENABLED,
"ENVIFORMER": ENVIFORMER_PRESENT,
"APPLICABILITY_DOMAIN": APPLICABILITY_DOMAIN_ENABLED,
"IUCLID_EXPORT": IUCLID_EXPORT_ENABLED,
}
# path of the URL are checked via "startswith"
@ -370,7 +400,6 @@ LOGIN_EXEMPT_URLS = [
"/o/userinfo/",
"/password_reset/",
"/reset/",
"/microsoft/",
"/terms",
"/privacy",
"/cookie-policy",
@ -379,8 +408,13 @@ LOGIN_EXEMPT_URLS = [
"/careers",
"/cite",
"/legal",
"/entra/",
"/auth/",
]
if SERVER_PATH:
LOGIN_EXEMPT_URLS = [f"/{SERVER_PATH}{x}" for x in LOGIN_EXEMPT_URLS]
# MS AD/Entra
MS_ENTRA_ENABLED = os.environ.get("MS_ENTRA_ENABLED", "False") == "True"
if MS_ENTRA_ENABLED:
@ -396,3 +430,15 @@ if MS_ENTRA_ENABLED:
# Site ID 10 -> beta.envipath.org
MATOMO_SITE_ID = os.environ.get("MATOMO_SITE_ID", "10")
# CAP
CAP_ENABLED = os.environ.get("CAP_ENABLED", "False") == "True"
CAP_API_BASE = os.environ.get("CAP_API_BASE", None)
CAP_SITE_KEY = os.environ.get("CAP_SITE_KEY", None)
CAP_SECRET_KEY = os.environ.get("CAP_SECRET_KEY", None)
# Biotransformer
BIOTRANSFORMER_ENABLED = os.environ.get("BIOTRANSFORMER_ENABLED", "False") == "True"
FLAGS["BIOTRANSFORMER"] = BIOTRANSFORMER_ENABLED
if BIOTRANSFORMER_ENABLED:
BIOTRANSFORMER_URL = os.environ.get("BIOTRANSFORMER_URL", None)

View File

@ -21,19 +21,27 @@ from django.urls import include, path
from .api import api_v1, api_legacy
PATH_PREFIX = s.SERVER_PATH
if PATH_PREFIX and not PATH_PREFIX.endswith("/"):
PATH_PREFIX += "/"
urlpatterns = [
path("", include("epdb.urls")),
path("admin/", admin.site.urls),
path("api/v1/", api_v1.urls),
path("api/legacy/", api_legacy.urls),
path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")),
path(f"{PATH_PREFIX}", include("epdb.urls")),
path(f"{PATH_PREFIX}admin/", admin.site.urls),
path(f"{PATH_PREFIX}api/v1/", api_v1.urls),
path(f"{PATH_PREFIX}api/legacy/", api_legacy.urls),
path(f"{PATH_PREFIX}o/", include("oauth2_provider.urls", namespace="oauth2_provider")),
]
if "migration" in s.INSTALLED_APPS:
urlpatterns.append(path("", include("migration.urls")))
urlpatterns.append(path(f"{PATH_PREFIX}", include("migration.urls")))
if s.MS_ENTRA_ENABLED:
urlpatterns.append(path("", include("epauth.urls")))
urlpatterns.append(path(f"{PATH_PREFIX}", include("epauth.urls")))
if s.TENANT != "public":
urlpatterns.append(path(f"{PATH_PREFIX}", include(f"{s.TENANT}.urls")))
# Custom error handlers
handler400 = "epdb.views.handler400"

View File

@ -49,7 +49,6 @@ class AdditionalInformationAPITests(TestCase):
description="Test scenario for additional information tests",
scenario_type="biodegradation",
scenario_date="2024-01-01",
additional_information={}, # Initialize with empty dict
)
cls.other_scenario = Scenario.objects.create(
package=cls.other_package,
@ -57,7 +56,6 @@ class AdditionalInformationAPITests(TestCase):
description="Scenario in package without access",
scenario_type="biodegradation",
scenario_date="2024-01-01",
additional_information={},
)
def test_list_all_schemas(self):

View File

@ -60,7 +60,7 @@ class ScenarioCreationAPITests(TestCase):
)
self.assertEqual(response.status_code, 404)
self.assertIn("Package not found", response.json()["detail"])
self.assertIn(f"Package with UUID {fake_uuid} not found", response.json()["detail"])
def test_create_scenario_insufficient_permissions(self):
"""Test that unauthorized access returns 403."""

View File

@ -74,7 +74,6 @@ class TestSchemaGeneration:
assert all(isinstance(g, str) for g in 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()))
def test_form_data_matches_schema(self, model_name: str, model_cls: Type[BaseModel]):

View File

@ -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 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
Package = s.GET_PACKAGE_MODEL()
def get_compound_for_read(user, compound_uuid: UUID):
"""
@ -41,6 +45,24 @@ def get_package_for_read(user, package_uuid: UUID):
return package
def get_package_for_write(user, package_uuid: UUID):
"""
Get package by UUID with permission check.
"""
# FIXME: update package manager with custom exceptions to avoid manual checks here
try:
package = Package.objects.get(uuid=package_uuid)
except Package.DoesNotExist:
raise EPAPINotFoundError(f"Package with UUID {package_uuid} not found")
# FIXME: optimize package manager to exclusively work with UUIDs
if not user or user.is_anonymous or not PackageManager.writable(user, package):
raise EPAPIPermissionDeniedError("Insufficient permissions to access this package.")
return package
def get_scenario_for_read(user, scenario_uuid: UUID):
"""Get scenario by UUID with read permission check."""
try:

View File

@ -9,6 +9,7 @@ from envipy_additional_information import registry
from envipy_additional_information.groups import GroupEnum
from epapi.utils.schema_transformers import build_rjsf_output
from epapi.utils.validation_errors import handle_validation_error
from epdb.models import AdditionalInformation
from ..dal import get_scenario_for_read, get_scenario_for_write
logger = logging.getLogger(__name__)
@ -44,12 +45,14 @@ def list_scenario_info(request, scenario_uuid: UUID):
scenario = get_scenario_for_read(request.user, scenario_uuid)
result = []
for ai in scenario.get_additional_information():
for ai in AdditionalInformation.objects.filter(scenario=scenario):
result.append(
{
"type": ai.__class__.__name__,
"type": ai.get().__class__.__name__,
"uuid": getattr(ai, "uuid", None),
"data": ai.model_dump(mode="json"),
"data": ai.data,
"attach_object": ai.content_object.simple_json() if ai.content_object else None,
}
)
return result
@ -85,20 +88,17 @@ def update_scenario_info(
scenario = get_scenario_for_write(request.user, scenario_uuid)
ai_uuid_str = str(ai_uuid)
# Find item to determine type for validation
found_type = None
for type_name, items in scenario.additional_information.items():
if any(item.get("uuid") == ai_uuid_str for item in items):
found_type = type_name
break
ai = AdditionalInformation.objects.filter(uuid=ai_uuid_str, scenario=scenario)
if found_type is None:
raise HttpError(404, f"Additional information not found: {ai_uuid}")
if not ai.exists():
raise HttpError(404, f"Additional information with UUID {ai_uuid} not found")
ai = ai.first()
# Get the model class for validation
cls = registry.get_model(found_type.lower())
cls = registry.get_model(ai.type.lower())
if not cls:
raise HttpError(500, f"Unknown model type in data: {found_type}")
raise HttpError(500, f"Unknown model type in data: {ai.type}")
# Validate the payload against the model
try:

View 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)

View File

@ -9,15 +9,14 @@ import logging
import json
from epdb.models import Scenario
from epdb.logic import PackageManager
from epdb.views import _anonymous_or_real
from ..pagination import EnhancedPageNumberPagination
from ..schemas import (
ReviewStatusFilter,
ScenarioOutSchema,
ScenarioCreateSchema,
ScenarioReviewStatusAndRelatedFilter,
)
from ..dal import get_user_entities_for_read, get_package_entities_for_read
from ..dal import get_user_entities_for_read, get_package_entities_for_read, get_package_for_write
from envipy_additional_information import registry
logger = logging.getLogger(__name__)
@ -29,7 +28,7 @@ router = Router()
@paginate(
EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
filter_schema=ScenarioReviewStatusAndRelatedFilter,
filter_schema=ReviewStatusFilter,
)
def list_all_scenarios(request):
user = request.user
@ -44,7 +43,7 @@ def list_all_scenarios(request):
@paginate(
EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
filter_schema=ScenarioReviewStatusAndRelatedFilter,
filter_schema=ReviewStatusFilter,
)
def list_package_scenarios(request, package_uuid: UUID):
user = request.user
@ -58,7 +57,7 @@ def create_scenario(request, package_uuid: UUID, payload: ScenarioCreateSchema =
user = _anonymous_or_real(request)
try:
current_package = PackageManager.get_package_by_id(user, package_uuid)
current_package = get_package_for_write(user, package_uuid)
except ValueError as e:
error_msg = str(e)
if "does not exist" in error_msg:

View File

@ -15,9 +15,9 @@ router = Router()
EnhancedPageNumberPagination,
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
return SettingManager.get_all_settings(user)

View 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.
"""

View File

View 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

View 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,
)

View File

@ -1,6 +1,7 @@
from ninja import Router
from ninja.security import SessionAuth
from envipath import settings as s
from .auth import BearerTokenAuth
from .endpoints import (
packages,
@ -13,6 +14,7 @@ from .endpoints import (
structure,
additional_information,
settings,
groups,
)
# Main router with authentication
@ -34,3 +36,9 @@ router.add_router("", models.router)
router.add_router("", structure.router)
router.add_router("", additional_information.router)
router.add_router("", settings.router)
router.add_router("", groups.router)
if s.IUCLID_EXPORT_ENABLED:
from epiuclid.api import router as iuclid_router
router.add_router("", iuclid_router)

View File

@ -22,12 +22,6 @@ class StructureReviewStatusFilter(FilterSchema):
review_status: Annotated[Optional[bool], FilterLookup("compound__package__reviewed")] = None
class ScenarioReviewStatusAndRelatedFilter(ReviewStatusFilter):
"""Filter schema for review_status and parent query parameter."""
exclude_related: Annotated[Optional[bool], FilterLookup("parent__isnull")] = None
# Base schema for all package-scoped entities
class PackageEntityOutSchema(Schema):
"""Base schema for entities belonging to a package."""
@ -132,3 +126,10 @@ class SettingOutSchema(Schema):
url: str = ""
name: str
description: str
class GroupOutSchema(Schema):
uuid: UUID
url: str = ""
name: str
description: str

View File

@ -3,6 +3,6 @@ from django.urls import path
from . import views
urlpatterns = [
path("microsoft/login/", views.microsoft_login, name="microsoft_login"),
path("microsoft/callback/", views.microsoft_callback, name="microsoft_callback"),
path("entra/login/", views.entra_login, name="entra_login"),
path("auth/redirect/", views.entra_callback, name="entra_callback"),
]

View File

@ -1,34 +1,49 @@
import msal
from django.conf import settings as s
from django.contrib.auth import get_user_model
from django.contrib.auth import login
from django.shortcuts import redirect
from django.contrib.auth import get_user_model
from epdb.logic import UserManager
def microsoft_login(request):
def get_msal_app_with_cache(request):
"""
Create MSAL app with session-based token cache.
"""
cache = msal.SerializableTokenCache()
# Load cache from session if it exists
if request.session.get("msal_token_cache"):
cache.deserialize(request.session["msal_token_cache"])
msal_app = msal.ConfidentialClientApplication(
client_id=s.MS_ENTRA_CLIENT_ID,
client_credential=s.MS_ENTRA_CLIENT_SECRET,
authority=s.MS_ENTRA_AUTHORITY
authority=s.MS_ENTRA_AUTHORITY,
token_cache=cache,
)
return msal_app, cache
def entra_login(request):
msal_app = msal.ConfidentialClientApplication(
client_id=s.MS_ENTRA_CLIENT_ID,
client_credential=s.MS_ENTRA_CLIENT_SECRET,
authority=s.MS_ENTRA_AUTHORITY,
)
flow = msal_app.initiate_auth_code_flow(
scopes=s.MS_ENTRA_SCOPES,
redirect_uri=s.MS_ENTRA_REDIRECT_URI
scopes=s.MS_ENTRA_SCOPES, redirect_uri=s.MS_ENTRA_REDIRECT_URI
)
request.session["msal_auth_flow"] = flow
return redirect(flow["auth_uri"])
def microsoft_callback(request):
msal_app = msal.ConfidentialClientApplication(
client_id=s.MS_ENTRA_CLIENT_ID,
client_credential=s.MS_ENTRA_CLIENT_SECRET,
authority=s.MS_ENTRA_AUTHORITY
)
def entra_callback(request):
msal_app, cache = get_msal_app_with_cache(request)
flow = request.session.pop("msal_auth_flow", None)
if not flow:
@ -37,30 +52,79 @@ def microsoft_callback(request):
# Acquire token using the flow and callback request
result = msal_app.acquire_token_by_auth_code_flow(flow, request.GET)
if "access_token" in result:
# Optional: Fetch user info from Microsoft Graph
import requests
resp = requests.get(
"https://graph.microsoft.com/v1.0/me",
headers={"Authorization": f"Bearer {result['access_token']}"}
)
user_info = resp.json()
# Save the token cache to session
if cache.has_state_changed:
request.session["msal_token_cache"] = cache.serialize()
user_name = user_info["displayName"]
user_email = user_info["mail"]
user_oid = user_info["id"]
claims = result["id_token_claims"]
# Get implementing class
User = get_user_model()
user_name = claims.get("name")
user_email = claims.get("emailaddress", claims.get("email"))
user_oid = claims.get("oid")
if User.objects.filter(uuid=user_oid).exists():
login(request, User.objects.get(uuid=user_oid))
else:
u = UserManager.create_user(user_name, user_email, None, uuid=user_oid, is_active=True)
login(request, u)
if not all([user_name, user_email, user_oid]):
raise ValueError("Missing required claims in ID token")
# TODO Group Sync
# Get implementing class
User = get_user_model()
return redirect("/")
if User.objects.filter(uuid=user_oid).exists():
u = User.objects.get(uuid=user_oid)
return redirect("/") # Handle errors
if u.username != user_name:
u.username = user_name
u.save()
else:
u = UserManager.create_user(user_name, user_email, None, uuid=user_oid, is_active=True)
login(request, u)
return redirect(s.SERVER_URL) # Handle errors
def get_access_token_from_request(request, scopes=None):
"""
Get an access token from the request using MSAL token cache.
"""
if scopes is None:
scopes = s.MS_ENTRA_SCOPES
# Get user from request (must be authenticated)
if not request.user.is_authenticated:
return None
# Create MSAL app with persistent cache
msal_app, cache = get_msal_app_with_cache(request)
# Try to get accounts from cache
accounts = msal_app.get_accounts()
if not accounts:
return None
# Find the account that matches the current user
user_account = None
for account in accounts:
if account.get("local_account_id") == str(request.user.uuid):
user_account = account
break
# If no matching account found, use the first available account
if not user_account and accounts:
user_account = accounts[0]
if not user_account:
return None
# Try to acquire token silently from cache
result = msal_app.acquire_token_silent(scopes=scopes, account=user_account)
# Save cache changes back to session
if cache.has_state_changed:
request.session["msal_token_cache"] = cache.serialize()
if result and "access_token" in result:
return result
return None

View File

@ -1,7 +1,12 @@
import logging
from django.conf import settings as s
from django.contrib import admin
from django.contrib import messages
from .models import (
AdditionalInformation,
ClassifierPluginModel,
Compound,
CompoundStructure,
Edge,
@ -16,6 +21,7 @@ from .models import (
Node,
ParallelRule,
Pathway,
PropertyPluginModel,
Reaction,
Scenario,
Setting,
@ -26,9 +32,130 @@ from .models import (
Package = s.GET_PACKAGE_MODEL()
logger = logging.getLogger(__name__)
class AdditionalInformationAdmin(admin.ModelAdmin):
pass
class UserAdmin(admin.ModelAdmin):
list_display = ["username", "email", "is_active", "is_staff", "is_superuser"]
list_display = [
"username",
"email",
"is_active",
"is_staff",
"is_superuser",
"last_login",
"date_joined",
]
actions = ["send_welcome_mail", "send_affiliation_mail"]
@admin.action(description="Send welcome mail")
def send_welcome_mail(self, request, queryset):
from django.core.mail import EmailMultiAlternatives
tpl = """Hello {username},
Your account has been successfully activated.
To log in, please visit
https://envipath.org/password_reset/
and request a new password.
If you have any questions or feedback, feel free to visit our community forum at
https://community.envipath.org/.
You do not need to register again for the forum - you can log in using your enviPath account by clicking "Log In" and then "Log in with enviPath."
Best regards,
The enviPath Team"""
users = []
for user in queryset:
if user.is_active:
logger.info(f"{user.username} already active - not sending mail again")
continue
try:
msg = EmailMultiAlternatives(
"Your enviPath Account Is Now Active",
tpl.format(username=user.username),
"admin@envipath.org",
[user.email],
bcc=["admin@envipath.org"],
)
msg.send(fail_silently=False)
user.is_active = True
user.password = "ASDF"
user.save()
users.append(user)
logger.info(f"{user.username} -> {user.email} mail sent")
except Exception as e:
logger.info(f"Error sending mail to {user.username}: {e}")
self.message_user(
request, f"Sent welcome mail to {[u.email for u in users]}", messages.SUCCESS
)
@admin.action(description="Send affiliation mail")
def send_affiliation_mail(self, request, queryset):
from django.core.mail import EmailMultiAlternatives
tpl = """Dear {username},
Thank you for your interest in enviPath!
Please note that the public enviPath system is intended for non-commercial use only.
We see that you registered using the email address {email}.
If possible, we kindly ask you to register using an official email address that reflects your affiliation (e.g., a university, NGO, or research organization).
If you would like us to update your account, simply reply to this email and let us know which address we should use.
We will then change it in our system, and you will receive a password reset email at the new address.
If you are registering with a company email address and are interested in commercial use, you are very welcome to book a meeting with us so we can discuss how we can best support you.
To book a meeting, please visit https://envipath.com/book
If changing to an affiliation email address is not possible, please contact us at registration@envipath.org
Best regards,
enviPath team"""
users = []
for user in queryset:
if user.is_active or user.contacted:
logger.info(
f"{user.username} already active or already contacted - not sending mail again"
)
continue
try:
msg = EmailMultiAlternatives(
"Regarding your enviPath registration",
tpl.format(username=user.username, email=user.email),
"admin@envipath.org",
[user.email],
bcc=["admin@envipath.org"],
)
msg.send(fail_silently=False)
user.contacted = True
user.save()
users.append(user)
logger.info(f"{user.username} -> {user.email} affiliation mail sent")
except Exception as e:
logger.info(f"Error sending mail to {user.username}: {e}")
self.message_user(
request, f"Sent affiliation mail to {[u.email for u in users]}", messages.SUCCESS
)
class UserPackagePermissionAdmin(admin.ModelAdmin):
@ -65,6 +192,14 @@ class EnviFormerAdmin(EPAdmin):
pass
class PropertyPluginModelAdmin(admin.ModelAdmin):
pass
class ClassifierPluginModelAdmin(admin.ModelAdmin):
pass
class LicenseAdmin(admin.ModelAdmin):
list_display = ["cc_string", "link", "image_link"]
@ -117,6 +252,7 @@ class ExternalIdentifierAdmin(admin.ModelAdmin):
pass
admin.site.register(AdditionalInformation, AdditionalInformationAdmin)
admin.site.register(User, UserAdmin)
admin.site.register(UserPackagePermission, UserPackagePermissionAdmin)
admin.site.register(Group, GroupAdmin)
@ -125,7 +261,9 @@ admin.site.register(JobLog, JobLogAdmin)
admin.site.register(Package, PackageAdmin)
admin.site.register(MLRelativeReasoning, MLRelativeReasoningAdmin)
admin.site.register(EnviFormer, EnviFormerAdmin)
admin.site.register(PropertyPluginModel, PropertyPluginModelAdmin)
admin.site.register(License, LicenseAdmin)
admin.site.register(ClassifierPluginModel, ClassifierPluginModelAdmin)
admin.site.register(Compound, CompoundAdmin)
admin.site.register(CompoundStructure, CompoundStructureAdmin)
admin.site.register(SimpleAmbitRule, SimpleAmbitRuleAdmin)

View File

@ -15,3 +15,14 @@ class EPDBConfig(AppConfig):
model_name = getattr(settings, "EPDB_PACKAGE_MODEL", "epdb.Package")
logger.info(f"Using Package model: {model_name}")
from .autodiscovery import autodiscover
autodiscover()
if settings.PLUGINS_ENABLED:
from bridge.contracts import Property, Classifier
from utilities.plugin import discover_plugins
settings.PROPERTY_PLUGINS.update(**discover_plugins(_cls=Property))
settings.CLASSIFIER_PLUGINS.update(**discover_plugins(_cls=Classifier))

5
epdb/autodiscovery.py Normal file
View File

@ -0,0 +1,5 @@
from django.utils.module_loading import autodiscover_modules
def autodiscover():
autodiscover_modules("epdb_hooks")

View File

@ -1,9 +1,10 @@
from collections import defaultdict
from typing import Any, Dict, List, Optional
import nh3
from django.conf import settings as s
from django.contrib.auth import get_user_model
from django.http import HttpResponse
from django.http import HttpResponse, JsonResponse
from django.shortcuts import redirect
from ninja import Field, Form, Query, Router, Schema
from ninja.security import SessionAuth
@ -11,8 +12,16 @@ from ninja.security import SessionAuth
from utilities.chem import FormatConverter
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 (
AdditionalInformation,
Compound,
CompoundStructure,
Edge,
@ -37,6 +46,13 @@ from .models import (
Package = s.GET_PACKAGE_MODEL()
def get_package_for_write(user, package_uuid):
p = PackageManager.get_package_by_id(user, package_uuid)
if not PackageManager.writable(user, p):
raise ValueError("You do not have the rights to write to this Package!")
return p
def _anonymous_or_real(request):
if request.user.is_authenticated and not request.user.is_anonymous:
return request.user
@ -87,6 +103,8 @@ class SimpleObject(Schema):
return "reviewed" if obj.compound.package.reviewed else "unreviewed"
elif isinstance(obj, Node) or isinstance(obj, Edge):
return "reviewed" if obj.pathway.package.reviewed else "unreviewed"
elif isinstance(obj, dict) and "review_status" in obj:
return "reviewed" if obj.get("review_status") else "unreviewed"
else:
raise ValueError("Object has no package")
@ -455,7 +473,7 @@ class UpdatePackage(Schema):
@router.post("/package/{uuid:package_uuid}", response={200: PackageSchema | Any, 400: Error})
def update_package(request, package_uuid, pack: Form[UpdatePackage]):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
p = get_package_for_write(request.user, package_uuid)
if pack.hiddenMethod:
if pack.hiddenMethod == "DELETE":
@ -551,21 +569,42 @@ class CompoundSchema(Schema):
@staticmethod
def resolve_halflifes(obj: Compound):
return []
res = []
for scen, hls in obj.half_lifes().items():
for hl in hls:
res.append(
{
"hl": str(hl.dt50),
"hlComment": hl.comment,
"hlFit": hl.fit,
"hlModel": hl.model,
"scenarioId": scen.url,
"scenarioName": scen.name,
"scenarioType": scen.scenario_type,
"source": hl.source,
}
)
return res
@staticmethod
def resolve_pubchem_compound_references(obj: Compound):
# TODO
return []
@staticmethod
def resolve_pathway_scenarios(obj: Compound):
return [
{
"scenarioId": "https://envipath.org/package/5882df9c-dae1-4d80-a40e-db4724271456/scenario/cd8350cd-4249-4111-ba9f-4e2209338501",
"scenarioName": "Fritz, R. & Brauner, A. (1989) - (00004)",
"scenarioType": "Soil",
}
]
res = []
for pw in obj.related_pathways:
for scen in pw.scenarios.all():
res.append(
{
"scenarioId": scen.url,
"scenarioName": scen.name,
"scenarioType": scen.scenario_type,
}
)
return res
class CompoundStructureSchema(Schema):
@ -618,7 +657,22 @@ class CompoundStructureSchema(Schema):
@staticmethod
def resolve_halflifes(obj: CompoundStructure):
return []
res = []
for scen, hls in obj.half_lifes().items():
for hl in hls:
res.append(
{
"hl": str(hl.dt50),
"hlComment": hl.comment,
"hlFit": hl.fit,
"hlModel": hl.model,
"scenarioId": scen.url,
"scenarioName": scen.name,
"scenarioType": scen.scenario_type,
"source": hl.source,
}
)
return res
@staticmethod
def resolve_pubchem_compound_references(obj: CompoundStructure):
@ -626,13 +680,18 @@ class CompoundStructureSchema(Schema):
@staticmethod
def resolve_pathway_scenarios(obj: CompoundStructure):
return [
{
"scenarioId": "https://envipath.org/package/5882df9c-dae1-4d80-a40e-db4724271456/scenario/cd8350cd-4249-4111-ba9f-4e2209338501",
"scenarioName": "Fritz, R. & Brauner, A. (1989) - (00004)",
"scenarioType": "Soil",
}
]
res = []
for pw in obj.related_pathways:
for scen in pw.scenarios.all():
res.append(
{
"scenarioId": scen.url,
"scenarioName": scen.name,
"scenarioType": scen.scenario_type,
}
)
return res
class CompoundStructureWrapper(Schema):
@ -717,7 +776,7 @@ def create_package_compound(
c: Form[CreateCompound],
):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
p = get_package_for_write(request.user, package_uuid)
# inchi is not used atm
c = Compound.create(
p, c.compoundSmiles, c.compoundName, c.compoundDescription, inchi=c.inchi
@ -730,14 +789,10 @@ def create_package_compound(
@router.delete("/package/{uuid:package_uuid}/compound/{uuid:compound_uuid}")
def delete_compound(request, package_uuid, compound_uuid):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
if PackageManager.writable(request.user, p):
c = Compound.objects.get(package=p, uuid=compound_uuid)
c.delete()
return redirect(f"{p.url}/compound")
else:
raise ValueError("You do not have the rights to delete this Compound!")
p = get_package_for_write(request.user, package_uuid)
c = Compound.objects.get(package=p, uuid=compound_uuid)
c.delete()
return redirect(f"{p.url}/compound")
except ValueError:
return 403, {
"message": f"Deleting Compound with id {compound_uuid} failed due to insufficient rights!"
@ -749,31 +804,29 @@ def delete_compound(request, package_uuid, compound_uuid):
)
def delete_compound_structure(request, package_uuid, compound_uuid, structure_uuid):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
p = get_package_for_write(request.user, package_uuid)
if PackageManager.writable(request.user, p):
c = Compound.objects.get(package=p, uuid=compound_uuid)
cs = CompoundStructure.objects.get(compound=c, uuid=structure_uuid)
c = Compound.objects.get(package=p, uuid=compound_uuid)
cs = CompoundStructure.objects.get(compound=c, uuid=structure_uuid)
# Check if we have to delete the compound as no structure is left
if len(cs.compound.structures.all()) == 1:
# This will delete the structure as well
# Check if we have to delete the compound as no structure is left
if len(cs.compound.structures.all()) == 1:
# This will delete the structure as well
c.delete()
return redirect(p.url + "/compound")
else:
if cs.normalized_structure:
c.delete()
return redirect(p.url + "/compound")
else:
if cs.normalized_structure:
c.delete()
return redirect(p.url + "/compound")
if c.default_structure == cs:
cs.delete()
c.default_structure = c.structures.all().first()
return redirect(c.url + "/structure")
else:
if c.default_structure == cs:
cs.delete()
c.default_structure = c.structures.all().first()
return redirect(c.url + "/structure")
else:
cs.delete()
return redirect(c.url + "/structure")
else:
raise ValueError("You do not have the rights to delete this CompoundStructure!")
cs.delete()
return redirect(c.url + "/structure")
except ValueError:
return 403, {
"message": f"Deleting CompoundStructure with id {compound_uuid} failed due to insufficient rights!"
@ -960,7 +1013,7 @@ def create_package_simple_rule(
r: Form[CreateSimpleRule],
):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
p = get_package_for_write(request.user, package_uuid)
if r.rdkitrule and r.rdkitrule.strip() == "true":
raise ValueError("Not yet implemented!")
@ -996,7 +1049,7 @@ def create_package_parallel_rule(
r: Form[CreateParallelRule],
):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
p = get_package_for_write(request.user, package_uuid)
srs = SimpleRule.objects.filter(package=p, url__in=r.simpleRules)
@ -1040,7 +1093,7 @@ def post_package_parallel_rule(request, package_uuid, rule_uuid, compound: Form[
def _post_package_rule(request, package_uuid, rule_uuid, compound: Form[str]):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
p = get_package_for_write(request.user, package_uuid)
r = Rule.objects.get(package=p, uuid=rule_uuid)
if compound is not None:
@ -1085,14 +1138,11 @@ def delete_parallel_rule(request, package_uuid, rule_uuid):
def _delete_rule(request, package_uuid, rule_uuid):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
p = get_package_for_write(request.user, package_uuid)
r = Rule.objects.get(package=p, uuid=rule_uuid)
r.delete()
return redirect(f"{p.url}/rule")
if PackageManager.writable(request.user, p):
r = Rule.objects.get(package=p, uuid=rule_uuid)
r.delete()
return redirect(f"{p.url}/rule")
else:
raise ValueError("You do not have the rights to delete this Rule!")
except ValueError:
return 403, {
"message": f"Deleting Rule with id {rule_uuid} failed due to insufficient rights!"
@ -1207,7 +1257,7 @@ def create_package_reaction(
r: Form[CreateReaction],
):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
p = get_package_for_write(request.user, package_uuid)
if r.smirks is None and (r.educt is None or r.product is None):
raise ValueError("Either SMIRKS or educt/product must be provided")
@ -1253,14 +1303,11 @@ def create_package_reaction(
@router.delete("/package/{uuid:package_uuid}/reaction/{uuid:reaction_uuid}")
def delete_reaction(request, package_uuid, reaction_uuid):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
p = get_package_for_write(request.user, package_uuid)
if PackageManager.writable(request.user, p):
r = Reaction.objects.get(package=p, uuid=reaction_uuid)
r.delete()
return redirect(f"{p.url}/reaction")
else:
raise ValueError("You do not have the rights to delete this Reaction!")
r = Reaction.objects.get(package=p, uuid=reaction_uuid)
r.delete()
return redirect(f"{p.url}/reaction")
except ValueError:
return 403, {
"message": f"Deleting Reaction with id {reaction_uuid} failed due to insufficient rights!"
@ -1291,7 +1338,14 @@ class ScenarioSchema(Schema):
@staticmethod
def resolve_collection(obj: Scenario):
return obj.additional_information
res = defaultdict(list)
for ai in obj.get_additional_information(direct_only=False):
data = ai.data
data["related"] = ai.content_object.simple_json() if ai.content_object else None
res[ai.type].append(data)
return res
@staticmethod
def resolve_review_status(obj: Rule):
@ -1332,17 +1386,57 @@ def get_package_scenario(request, package_uuid, scenario_uuid):
}
@router.delete("/package/{uuid:package_uuid}/scenario")
def delete_scenarios(request, package_uuid, scenario_uuid):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
@router.post("/package/{uuid:package_uuid}/scenario", response={200: str | Any, 403: Error})
def create_package_scenario(request, package_uuid):
from utilities.legacy import build_additional_information_from_request
try:
p = get_package_for_write(request.user, package_uuid)
scen_date = None
date_year = request.POST.get("dateYear")
date_month = request.POST.get("dateMonth")
date_day = request.POST.get("dateDay")
if date_year:
scen_date = date_year
if date_month:
scen_date += f"-{date_month}"
if date_day:
scen_date += f"-{date_day}"
name = request.POST.get("studyname")
description = request.POST.get("studydescription")
study_type = request.POST.get("type")
ais = []
types = request.POST.get("adInfoTypes[]", [])
if types:
types = types.split(",")
for t in types:
ais.append(build_additional_information_from_request(request, t))
new_s = Scenario.create(p, name, description, scen_date, study_type, ais)
return JsonResponse({"scenarioLocation": new_s.url})
except ValueError:
return 403, {
"message": f"Getting Package with id {package_uuid} failed due to insufficient rights!"
}
@router.delete("/package/{uuid:package_uuid}/scenario")
def delete_scenarios(request, package_uuid):
try:
p = get_package_for_write(request.user, package_uuid)
scens = Scenario.objects.filter(package=p)
scens.delete()
return redirect(f"{p.url}/scenario")
if PackageManager.writable(request.user, p):
scens = Scenario.objects.filter(package=p)
scens.delete()
return redirect(f"{p.url}/scenario")
else:
raise ValueError("You do not have the rights to delete Scenarios!")
except ValueError:
return 403, {"message": "Deleting Scenarios failed due to insufficient rights!"}
@ -1350,20 +1444,61 @@ def delete_scenarios(request, package_uuid, scenario_uuid):
@router.delete("/package/{uuid:package_uuid}/scenario/{uuid:scenario_uuid}")
def delete_scenario(request, package_uuid, scenario_uuid):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
p = get_package_for_write(request.user, package_uuid)
scen = Scenario.objects.get(package=p, uuid=scenario_uuid)
scen.delete()
return redirect(f"{p.url}/scenario")
if PackageManager.writable(request.user, p):
scen = Scenario.objects.get(package=p, uuid=scenario_uuid)
scen.delete()
return redirect(f"{p.url}/scenario")
else:
raise ValueError("You do not have the rights to delete this Scenario!")
except ValueError:
return 403, {
"message": f"Deleting Scenario with id {scenario_uuid} failed due to insufficient rights!"
}
@router.post(
"/package/{uuid:package_uuid}/additional-information", response={200: str | Any, 403: Error}
)
def create_package_additional_information(request, package_uuid):
from utilities.legacy import build_additional_information_from_request
try:
p = get_package_for_write(request.user, package_uuid)
scen = request.POST.get("scenario")
scenario = Scenario.objects.get(package=p, url=scen)
url_parser = EPDBURLParser(request.POST.get("attach_obj"))
attach_obj = url_parser.get_object()
if not hasattr(attach_obj, "additional_information"):
raise ValueError("Can't attach additional information to this object!")
if not attach_obj.url.startswith(p.url):
raise ValueError(
"Additional Information can only be set to objects stored in the same package!"
)
types = request.POST.get("adInfoTypes[]", "").split(",")
for t in types:
ai = build_additional_information_from_request(request, t)
AdditionalInformation.create(
p,
ai,
scenario=scenario,
content_object=attach_obj,
)
# TODO implement additional information endpoint ?
return redirect(f"{scenario.url}")
except ValueError:
return 403, {
"message": f"Getting Package with id {package_uuid} failed due to insufficient rights!"
}
###########
# Pathway #
###########
@ -1380,8 +1515,8 @@ class PathwayEdge(Schema):
pseudo: bool = False
rule: Optional[str] = Field(None, alias="rule")
scenarios: List[SimpleScenario] = Field([], alias="scenarios")
source: int = -1
target: int = -1
source: int = Field(-1)
target: int = Field(-1)
@staticmethod
def resolve_rule(obj: Edge):
@ -1394,7 +1529,7 @@ class PathwayEdge(Schema):
class PathwayNode(Schema):
atomCount: int = Field(None, alias="atom_count")
depth: int = Field(None, alias="depth")
depth: float = Field(None, alias="depth")
dt50s: List[Dict[str, str]] = Field([], alias="dt50s")
engineeredIntermediate: bool = Field(None, alias="engineered_intermediate")
id: str = Field(None, alias="url")
@ -1444,9 +1579,9 @@ class PathwaySchema(Schema):
isIncremental: bool = Field(None, alias="is_incremental")
isPredicted: bool = Field(None, alias="is_predicted")
lastModified: int = Field(None, alias="last_modified")
links: List[PathwayEdge] = Field([], alias="edges")
links: List[PathwayEdge] = Field([])
name: str = Field(None, alias="name")
nodes: List[PathwayNode] = Field([], alias="nodes")
nodes: List[PathwayNode] = Field([])
pathwayName: str = Field(None, alias="name")
reviewStatus: str = Field(None, alias="review_status")
scenarios: List["SimpleScenario"] = Field([], alias="scenarios")
@ -1468,6 +1603,14 @@ class PathwaySchema(Schema):
def resolve_last_modified(obj: Pathway):
return int(obj.modified.timestamp())
@staticmethod
def resolve_links(obj: Pathway):
return obj.d3_json().get("links", [])
@staticmethod
def resolve_nodes(obj: Pathway):
return obj.d3_json().get("nodes", [])
@router.get("/pathway", response={200: PathwayWrapper, 403: Error})
def get_pathways(request):
@ -1511,16 +1654,16 @@ class CreatePathway(Schema):
selectedSetting: str | None = None
@router.post("/package/{uuid:package_uuid}/pathway")
def create_pathway(
@router.post("/package/{uuid:package_uuid}/pathway", response={200: Any, 403: Error})
def create_package_pathway(
request,
package_uuid,
pw: Form[CreatePathway],
):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
p = get_package_for_write(request.user, package_uuid)
stand_smiles = FormatConverter.standardize(pw.smilesinput.strip())
stand_smiles = FormatConverter.standardize(pw.smilesinput.strip(), remove_stereo=True)
new_pw = Pathway.create(p, stand_smiles, name=pw.name, description=pw.description)
@ -1547,20 +1690,18 @@ def create_pathway(
return redirect(new_pw.url)
except ValueError as e:
return 400, {"message": str(e)}
return 403, {"message": str(e)}
@router.delete("/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}")
def delete_pathway(request, package_uuid, pathway_uuid):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
p = get_package_for_write(request.user, package_uuid)
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
pw.delete()
return redirect(f"{p.url}/pathway")
if PackageManager.writable(request.user, p):
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
pw.delete()
return redirect(f"{p.url}/pathway")
else:
raise ValueError("You do not have the rights to delete this pathway!")
except ValueError:
return 403, {
"message": f"Deleting Pathway with id {pathway_uuid} failed due to insufficient rights!"
@ -1668,7 +1809,7 @@ class CreateNode(Schema):
)
def add_pathway_node(request, package_uuid, pathway_uuid, n: Form[CreateNode]):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
p = get_package_for_write(request.user, package_uuid)
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
if n.nodeDepth is not None and n.nodeDepth.strip() != "":
@ -1686,15 +1827,13 @@ def add_pathway_node(request, package_uuid, pathway_uuid, n: Form[CreateNode]):
@router.delete("/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}/node/{uuid:node_uuid}")
def delete_node(request, package_uuid, pathway_uuid, node_uuid):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
p = get_package_for_write(request.user, package_uuid)
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
n = Node.objects.get(pathway=pw, uuid=node_uuid)
n.delete()
return redirect(f"{pw.url}/node")
if PackageManager.writable(request.user, p):
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
n = Node.objects.get(pathway=pw, uuid=node_uuid)
n.delete()
return redirect(f"{pw.url}/node")
else:
raise ValueError("You do not have the rights to delete this Node!")
except ValueError:
return 403, {
"message": f"Deleting Node with id {node_uuid} failed due to insufficient rights!"
@ -1731,7 +1870,7 @@ class EdgeSchema(Schema):
startNodes: List["EdgeNode"] = Field([], alias="start_nodes")
@staticmethod
def resolve_review_status(obj: Node):
def resolve_review_status(obj: Edge):
return "reviewed" if obj.pathway.package.reviewed else "unreviewed"
@ -1778,7 +1917,7 @@ class CreateEdge(Schema):
)
def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
p = get_package_for_write(request.user, package_uuid)
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
if e.edgeAsSmirks is None and (e.educts is None or e.products is None):
@ -1828,23 +1967,24 @@ def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
description=e.edgeReason,
)
# Update depths as sideeffect of above operation
pw.update_depths()
return redirect(new_e.url)
except ValueError:
return 403, {"message": "Adding node failed!"}
return 403, {"message": "Adding Edge failed!"}
@router.delete("/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}/edge/{uuid:edge_uuid}")
def delete_edge(request, package_uuid, pathway_uuid, edge_uuid):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
p = get_package_for_write(request.user, package_uuid)
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
e = Edge.objects.get(pathway=pw, uuid=edge_uuid)
e.delete()
return redirect(f"{pw.url}/edge")
if PackageManager.writable(request.user, p):
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
e = Edge.objects.get(pathway=pw, uuid=edge_uuid)
e.delete()
return redirect(f"{pw.url}/edge")
else:
raise ValueError("You do not have the rights to delete this Edge!")
except ValueError:
return 403, {
"message": f"Deleting Edge with id {edge_uuid} failed due to insufficient rights!"
@ -1937,7 +2077,7 @@ def get_model(request, package_uuid, model_uuid, c: Query[Classify]):
return 400, {"message": "Received empty SMILES"}
try:
stand_smiles = FormatConverter.standardize(c.smiles)
stand_smiles = FormatConverter.standardize(c.smiles, remove_stereo=True)
except ValueError:
return 400, {"message": f'"{c.smiles}" is not a valid SMILES'}
@ -1980,14 +2120,11 @@ def get_model(request, package_uuid, model_uuid, c: Query[Classify]):
@router.delete("/package/{uuid:package_uuid}/model/{uuid:model_uuid}")
def delete_model(request, package_uuid, model_uuid):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
p = get_package_for_write(request.user, package_uuid)
if PackageManager.writable(request.user, p):
m = EPModel.objects.get(package=p, uuid=model_uuid)
m.delete()
return redirect(f"{p.url}/model")
else:
raise ValueError("You do not have the rights to delete this Model!")
m = EPModel.objects.get(package=p, uuid=model_uuid)
m.delete()
return redirect(f"{p.url}/model")
except ValueError:
return 403, {
"message": f"Deleting Model with id {model_uuid} failed due to insufficient rights!"

View File

@ -1,4 +1,3 @@
import json
import logging
import re
from typing import Any, Dict, List, Optional, Set, Union, Tuple
@ -11,6 +10,7 @@ from django.db import transaction
from pydantic import ValidationError
from epdb.models import (
AdditionalInformation,
Compound,
CompoundStructure,
Edge,
@ -22,6 +22,7 @@ from epdb.models import (
Node,
Pathway,
Permission,
PropertyPluginModel,
Reaction,
Rule,
Setting,
@ -263,8 +264,12 @@ class GroupManager(object):
return bool(re.findall(GroupManager.group_pattern, url))
@staticmethod
def create_group(current_user, name, description):
def create_group(current_user, name, description, *args, **kwargs):
g = Group()
if "uuid" in kwargs:
g.uuid = kwargs["uuid"]
# Clean for potential XSS
g.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
g.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
@ -340,52 +345,17 @@ class PackageManager(object):
@staticmethod
def readable(user, package):
if (
UserPackagePermission.objects.filter(package=package, user=user).exists()
or GroupPackagePermission.objects.filter(
package=package, group__in=GroupManager.get_groups(user)
)
or package.reviewed is True
or user.is_superuser
):
return True
return False
return (
PackageManager.has_package_permission(user, package, "read") | package.reviewed is True
)
@staticmethod
def writable(user, package):
if (
UserPackagePermission.objects.filter(
package=package, user=user, permission=Permission.WRITE[0]
).exists()
or GroupPackagePermission.objects.filter(
package=package,
group__in=GroupManager.get_groups(user),
permission=Permission.WRITE[0],
).exists()
or UserPackagePermission.objects.filter(
package=package, user=user, permission=Permission.ALL[0]
).exists()
or user.is_superuser
):
return True
return False
return PackageManager.has_package_permission(user, package, "write")
@staticmethod
def administrable(user, package):
if (
UserPackagePermission.objects.filter(
package=package, user=user, permission=Permission.ALL[0]
).exists()
or GroupPackagePermission.objects.filter(
package=package,
group__in=GroupManager.get_groups(user),
permission=Permission.ALL[0],
).exists()
or user.is_superuser
):
return True
return False
return PackageManager.has_package_permission(user, package, "all")
@staticmethod
def has_package_permission(user: "User", package: Union[str, UUID, "Package"], permission: str):
@ -469,7 +439,9 @@ class PackageManager(object):
# remove package if user is owner and package is reviewed e.g. admin
qs = qs.filter(reviewed=False)
return qs.distinct()
qs = qs.distinct()
return qs
@staticmethod
def get_all_writeable_packages(user):
@ -513,7 +485,9 @@ class PackageManager(object):
qs = qs.filter(reviewed=False)
return qs.distinct()
qs = qs.distinct()
return qs
@staticmethod
def get_packages():
@ -633,15 +607,30 @@ class PackageManager(object):
# Stores old_id to new_id
mapping = {}
# Stores new_scen_id to old_parent_scen_id
parent_mapping = {}
# Mapping old scen_id to old_obj_id
scen_mapping = defaultdict(list)
# Enzymelink Mapping rule_id to enzymelink objects
enzyme_mapping = defaultdict(list)
# old_parent_id to child
postponed_scens = defaultdict(list)
# Store Scenarios
for scenario in data["scenarios"]:
skip_scen = False
# Check if parent exists and park this Scenario to convert it later into an
# AdditionalInformation object
for ex in scenario.get("additionalInformationCollection", {}).get(
"additionalInformation", []
):
if ex["name"] == "referringscenario":
postponed_scens[ex["data"]].append(scenario)
skip_scen = True
break
if skip_scen:
continue
scen = Scenario()
scen.package = pack
scen.uuid = UUID(scenario["id"].split("/")[-1]) if keep_ids else uuid4()
@ -654,19 +643,12 @@ class PackageManager(object):
mapping[scenario["id"]] = scen.uuid
new_add_inf = defaultdict(list)
# TODO Store AI...
for ex in scenario.get("additionalInformationCollection", {}).get(
"additionalInformation", []
):
name = ex["name"]
addinf_data = ex["data"]
# park the parent scen id for now and link it later
if name == "referringscenario":
parent_mapping[scen.uuid] = addinf_data
continue
# Broken eP Data
if name == "initialmasssediment" and addinf_data == "missing data":
continue
@ -674,17 +656,11 @@ class PackageManager(object):
continue
try:
res = AdditionalInformationConverter.convert(name, addinf_data)
res_cls_name = res.__class__.__name__
ai_data = json.loads(res.model_dump_json())
ai_data["uuid"] = f"{uuid4()}"
new_add_inf[res_cls_name].append(ai_data)
ai = AdditionalInformationConverter.convert(name, addinf_data)
AdditionalInformation.create(pack, ai, scenario=scen)
except (ValidationError, ValueError):
logger.error(f"Failed to convert {name} with {addinf_data}")
scen.additional_information = new_add_inf
scen.save()
print("Scenarios imported...")
# Store compounds and its structures
@ -713,6 +689,10 @@ class PackageManager(object):
struc.description = structure["description"]
struc.aliases = structure.get("aliases", [])
struc.smiles = structure["smiles"]
if structure.get("molfile"):
struc.molfile = structure["molfile"]
struc.save()
for scen in structure["scenarios"]:
@ -924,14 +904,46 @@ class PackageManager(object):
print("Pathways imported...")
# Linking Phase
for child, parent in parent_mapping.items():
child_obj = Scenario.objects.get(uuid=child)
parent_obj = Scenario.objects.get(uuid=mapping[parent])
child_obj.parent = parent_obj
child_obj.save()
for parent, children in postponed_scens.items():
for child in children:
for ex in child.get("additionalInformationCollection", {}).get(
"additionalInformation", []
):
child_id = child["id"]
name = ex["name"]
addinf_data = ex["data"]
if name == "referringscenario":
continue
# Broken eP Data
if name == "initialmasssediment" and addinf_data == "missing data":
continue
if name == "columnheight" and addinf_data == "(2)-(2.5);(6)-(8)":
continue
ai = AdditionalInformationConverter.convert(name, addinf_data)
if child_id not in scen_mapping:
logger.info(
f"{child_id} not found in scen_mapping. Seems like its not attached to any object"
)
print(
f"{child_id} not found in scen_mapping. Seems like its not attached to any object"
)
scen = Scenario.objects.get(uuid=mapping[parent])
mapping[child_id] = scen.uuid
for obj in scen_mapping[child_id]:
_ = AdditionalInformation.create(pack, ai, scen, content_object=obj)
for scen_id, objects in scen_mapping.items():
new_id = mapping.get(scen_id)
if new_id is None:
logger.warning(f"Could not find mapping for {scen_id}")
print(f"Could not find mapping for {scen_id}")
continue
scen = Scenario.objects.get(uuid=mapping[scen_id])
for o in objects:
o.scenarios.add(scen)
@ -964,6 +976,7 @@ class PackageManager(object):
matches = re.findall(r">(R[0-9]+)<", evidence["evidence"])
if not matches or len(matches) != 1:
logger.warning(f"Could not find reaction id in {evidence['evidence']}")
print(f"Could not find reaction id in {evidence['evidence']}")
continue
e.add_kegg_reaction_id(matches[0])
@ -982,55 +995,10 @@ class PackageManager(object):
print("Fixing Node depths...")
total_pws = Pathway.objects.filter(package=pack).count()
for p, pw in enumerate(Pathway.objects.filter(package=pack)):
print(pw.url)
in_count = defaultdict(lambda: 0)
out_count = defaultdict(lambda: 0)
for e in pw.edges:
# TODO check if this will remain
for react in e.start_nodes.all():
out_count[str(react.uuid)] += 1
for prod in e.end_nodes.all():
in_count[str(prod.uuid)] += 1
root_nodes = []
for n in pw.nodes:
num_parents = in_count[str(n.uuid)]
if num_parents == 0:
# must be a root node or unconnected node
if n.depth != 0:
n.depth = 0
n.save()
# Only root node may have children
if out_count[str(n.uuid)] > 0:
root_nodes.append(n)
levels = [root_nodes]
seen = set()
# Do a bfs to determine depths starting with level 0 a.k.a. root nodes
for i, level_nodes in enumerate(levels):
new_level = []
for n in level_nodes:
for e in n.out_edges.all():
for prod in e.end_nodes.all():
if str(prod.uuid) not in seen:
old_depth = prod.depth
if old_depth != i + 1:
print(f"updating depth from {old_depth} to {i + 1}")
prod.depth = i + 1
prod.save()
new_level.append(prod)
seen.add(str(n.uuid))
if new_level:
levels.append(new_level)
print(f"{p + 1}/{total_pws} fixed.")
pw.update_depths()
print(f"{p + 1}/{total_pws} fixed.", end="\r")
return pack
@ -1109,19 +1077,23 @@ class SettingManager(object):
description: str = None,
max_nodes: int = None,
max_depth: int = None,
rule_packages: List[Package] = None,
rule_packages: List[Package] | None = None,
model: EPModel = None,
model_threshold: float = None,
expansion_scheme: ExpansionSchemeChoice = ExpansionSchemeChoice.BFS,
property_models: List["PropertyPluginModel"] | None = None,
):
new_s = Setting()
# Clean for potential XSS
new_s.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
new_s.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
new_s.max_nodes = max_nodes
new_s.max_depth = max_depth
new_s.model = model
new_s.model_threshold = model_threshold
new_s.expansion_scheme = expansion_scheme
new_s.save()
@ -1130,6 +1102,11 @@ class SettingManager(object):
new_s.rule_packages.add(r)
new_s.save()
if property_models is not None:
for pm in property_models:
new_s.property_models.add(pm)
new_s.save()
usp = UserSettingPermission()
usp.user = user
usp.setting = new_s

View File

@ -41,9 +41,7 @@ class Command(BaseCommand):
"SequentialRule",
"Scenario",
"Setting",
"MLRelativeReasoning",
"RuleBasedRelativeReasoning",
"EnviFormer",
"EPModel",
"ApplicabilityDomain",
"EnzymeLink",
]

View File

@ -0,0 +1,83 @@
import os
import subprocess
from django.conf import settings
from django.core.management import call_command
from django.core.management.base import BaseCommand
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument(
"-n",
"--name",
type=str,
help="Name of the database to recreate. Default is 'appdb'",
default="appdb",
)
parser.add_argument(
"-d",
"--dump",
type=str,
help="Path to the dump file",
default="./fixtures/db.dump",
)
parser.add_argument(
"-ou",
"--oldurl",
type=str,
help="Old URL, e.g. https://envipath.org/",
default="https://envipath.org/",
)
parser.add_argument(
"-nu",
"--newurl",
type=str,
help="New URL, e.g. http://localhost:8000/",
default="http://localhost:8000/",
)
def handle(self, *args, **options):
dump_file = options["dump"]
if not os.path.exists(dump_file):
raise ValueError(f"Dump file {dump_file} does not exist")
db_name = options["name"]
print(f"Dropping database {db_name} y/n: ", end="")
if input() in "yY":
result = subprocess.run(
["dropdb", db_name],
capture_output=True,
text=True,
)
print(result.stdout)
else:
raise ValueError("Aborted")
print(f"Creating database {db_name}")
result = subprocess.run(
["createdb", db_name],
capture_output=True,
text=True,
)
print(result.stdout)
print(f"Restoring database {db_name} from {dump_file}")
result = subprocess.run(
["pg_restore", "-d", db_name, dump_file, "--no-owner"],
capture_output=True,
text=True,
)
print(result.stdout)
if db_name == settings.DATABASES["default"]["NAME"]:
call_command("localize_urls", "--old", options["oldurl"], "--new", options["newurl"])
else:
print("Skipping localize_urls as database is not the default one.")

View File

@ -0,0 +1,179 @@
# Generated by Django 5.2.7 on 2026-02-12 09:38
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("epdb", "0015_user_is_reviewer"),
]
operations = [
migrations.RemoveField(
model_name="enviformer",
name="model_status",
),
migrations.RemoveField(
model_name="mlrelativereasoning",
name="model_status",
),
migrations.RemoveField(
model_name="rulebasedrelativereasoning",
name="model_status",
),
migrations.AddField(
model_name="epmodel",
name="model_status",
field=models.CharField(
choices=[
("INITIAL", "Initial"),
("INITIALIZING", "Model is initializing."),
("BUILDING", "Model is building."),
(
"BUILT_NOT_EVALUATED",
"Model is built and can be used for predictions, Model is not evaluated yet.",
),
("EVALUATING", "Model is evaluating"),
("FINISHED", "Model has finished building and evaluation."),
("ERROR", "Model has failed."),
],
default="INITIAL",
),
),
migrations.AlterField(
model_name="enviformer",
name="eval_packages",
field=models.ManyToManyField(
blank=True,
related_name="%(app_label)s_%(class)s_eval_packages",
to=settings.EPDB_PACKAGE_MODEL,
verbose_name="Evaluation Packages",
),
),
migrations.AlterField(
model_name="enviformer",
name="rule_packages",
field=models.ManyToManyField(
blank=True,
related_name="%(app_label)s_%(class)s_rule_packages",
to=settings.EPDB_PACKAGE_MODEL,
verbose_name="Rule Packages",
),
),
migrations.AlterField(
model_name="mlrelativereasoning",
name="eval_packages",
field=models.ManyToManyField(
blank=True,
related_name="%(app_label)s_%(class)s_eval_packages",
to=settings.EPDB_PACKAGE_MODEL,
verbose_name="Evaluation Packages",
),
),
migrations.AlterField(
model_name="mlrelativereasoning",
name="rule_packages",
field=models.ManyToManyField(
blank=True,
related_name="%(app_label)s_%(class)s_rule_packages",
to=settings.EPDB_PACKAGE_MODEL,
verbose_name="Rule Packages",
),
),
migrations.AlterField(
model_name="rulebasedrelativereasoning",
name="eval_packages",
field=models.ManyToManyField(
blank=True,
related_name="%(app_label)s_%(class)s_eval_packages",
to=settings.EPDB_PACKAGE_MODEL,
verbose_name="Evaluation Packages",
),
),
migrations.AlterField(
model_name="rulebasedrelativereasoning",
name="rule_packages",
field=models.ManyToManyField(
blank=True,
related_name="%(app_label)s_%(class)s_rule_packages",
to=settings.EPDB_PACKAGE_MODEL,
verbose_name="Rule Packages",
),
),
migrations.CreateModel(
name="PropertyPluginModel",
fields=[
(
"epmodel_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="epdb.epmodel",
),
),
("threshold", models.FloatField(default=0.5)),
("eval_results", models.JSONField(blank=True, default=dict, null=True)),
("multigen_eval", models.BooleanField(default=False)),
("plugin_identifier", models.CharField(max_length=255)),
(
"app_domain",
models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="epdb.applicabilitydomain",
),
),
(
"data_packages",
models.ManyToManyField(
blank=True,
related_name="%(app_label)s_%(class)s_data_packages",
to=settings.EPDB_PACKAGE_MODEL,
verbose_name="Data Packages",
),
),
(
"eval_packages",
models.ManyToManyField(
blank=True,
related_name="%(app_label)s_%(class)s_eval_packages",
to=settings.EPDB_PACKAGE_MODEL,
verbose_name="Evaluation Packages",
),
),
(
"rule_packages",
models.ManyToManyField(
blank=True,
related_name="%(app_label)s_%(class)s_rule_packages",
to=settings.EPDB_PACKAGE_MODEL,
verbose_name="Rule Packages",
),
),
],
options={
"abstract": False,
},
bases=("epdb.epmodel",),
),
migrations.AddField(
model_name="setting",
name="property_models",
field=models.ManyToManyField(
blank=True,
related_name="settings",
to="epdb.propertypluginmodel",
verbose_name="Setting Property Models",
),
),
migrations.DeleteModel(
name="PluginModel",
),
]

View File

@ -0,0 +1,93 @@
# Generated by Django 5.2.7 on 2026-02-20 12:02
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("epdb", "0016_remove_enviformer_model_status_and_more"),
]
operations = [
migrations.CreateModel(
name="AdditionalInformation",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("uuid", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
("url", models.TextField(null=True, unique=True, verbose_name="URL")),
("kv", models.JSONField(blank=True, default=dict, null=True)),
("type", models.TextField(verbose_name="Additional Information Type")),
("data", models.JSONField(blank=True, default=dict, null=True)),
("object_id", models.PositiveBigIntegerField(blank=True, null=True)),
(
"content_type",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
(
"package",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.EPDB_PACKAGE_MODEL,
verbose_name="Package",
),
),
(
"scenario",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="scenario_additional_information",
to="epdb.scenario",
),
),
],
options={
"indexes": [
models.Index(fields=["type"], name="epdb_additi_type_394349_idx"),
models.Index(
fields=["scenario", "type"], name="epdb_additi_scenari_a59edf_idx"
),
models.Index(
fields=["content_type", "object_id"], name="epdb_additi_content_44d4b4_idx"
),
models.Index(
fields=["scenario", "content_type", "object_id"],
name="epdb_additi_scenari_ef2bf5_idx",
),
],
"constraints": [
models.CheckConstraint(
condition=models.Q(
models.Q(("content_type__isnull", True), ("object_id__isnull", True)),
models.Q(("content_type__isnull", False), ("object_id__isnull", False)),
_connector="OR",
),
name="ck_addinfo_gfk_pair",
),
models.CheckConstraint(
condition=models.Q(
("scenario__isnull", False),
("content_type__isnull", False),
_connector="OR",
),
name="ck_addinfo_not_both_null",
),
],
},
),
]

View File

@ -0,0 +1,132 @@
# Generated by Django 5.2.7 on 2026-02-20 12:03
from django.db import migrations
def get_additional_information(scenario):
from envipy_additional_information import registry
from envipy_additional_information.parsers import TypeOfAerationParser
for k, vals in scenario.additional_information.items():
if k == "enzyme":
continue
if k == "SpikeConentration":
k = "SpikeConcentration"
if k == "AerationType":
k = "TypeOfAeration"
for v in vals:
# Per default additional fields are ignored
MAPPING = {c.__name__: c for c in registry.list_models().values()}
try:
inst = MAPPING[k](**v)
except Exception:
if k == "TypeOfAeration":
toa = TypeOfAerationParser()
inst = toa.from_string(v["type"])
# Add uuid to uniquely identify objects for manipulation
if "uuid" in v:
inst.__dict__["uuid"] = v["uuid"]
yield inst
def forward_func(apps, schema_editor):
Scenario = apps.get_model("epdb", "Scenario")
ContentType = apps.get_model("contenttypes", "ContentType")
AdditionalInformation = apps.get_model("epdb", "AdditionalInformation")
bulk = []
related = []
ctype = {o.model: o for o in ContentType.objects.all()}
parents = Scenario.objects.prefetch_related(
"compound_set",
"compoundstructure_set",
"reaction_set",
"rule_set",
"pathway_set",
"node_set",
"edge_set",
).filter(parent__isnull=True)
for i, scenario in enumerate(parents):
print(f"{i + 1}/{len(parents)}", end="\r")
if scenario.parent is not None:
related.append(scenario.parent)
continue
for ai in get_additional_information(scenario):
bulk.append(
AdditionalInformation(
package=scenario.package,
scenario=scenario,
type=ai.__class__.__name__,
data=ai.model_dump(mode="json"),
)
)
print("\n", len(bulk))
related = Scenario.objects.prefetch_related(
"compound_set",
"compoundstructure_set",
"reaction_set",
"rule_set",
"pathway_set",
"node_set",
"edge_set",
).filter(parent__isnull=False)
for i, scenario in enumerate(related):
print(f"{i + 1}/{len(related)}", end="\r")
parent = scenario.parent
# Check to which objects this scenario is attached to
for ai in get_additional_information(scenario):
rel_objs = [
"compound",
"compoundstructure",
"reaction",
"rule",
"pathway",
"node",
"edge",
]
for rel_obj in rel_objs:
for o in getattr(scenario, f"{rel_obj}_set").all():
bulk.append(
AdditionalInformation(
package=scenario.package,
scenario=parent,
type=ai.__class__.__name__,
data=ai.model_dump(mode="json"),
content_type=ctype[rel_obj],
object_id=o.pk,
)
)
print("Start creating additional information objects...")
AdditionalInformation.objects.bulk_create(bulk)
print("Done!")
print(len(bulk))
Scenario.objects.filter(parent__isnull=False).delete()
# Call ai save to fix urls
ais = AdditionalInformation.objects.all()
total = ais.count()
for i, ai in enumerate(ais):
print(f"{i + 1}/{total}", end="\r")
ai.save()
class Migration(migrations.Migration):
dependencies = [
("epdb", "0017_additionalinformation"),
]
operations = [
migrations.RunPython(forward_func, reverse_code=migrations.RunPython.noop),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 5.2.7 on 2026-02-23 08:45
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("epdb", "0018_auto_20260220_1203"),
]
operations = [
migrations.RemoveField(
model_name="scenario",
name="additional_information",
),
migrations.RemoveField(
model_name="scenario",
name="parent",
),
]

View File

@ -0,0 +1,65 @@
# Generated by Django 5.2.7 on 2026-03-09 10:41
import django.db.models.deletion
from django.db import migrations, models
def populate_polymorphic_ctype(apps, schema_editor):
ContentType = apps.get_model("contenttypes", "ContentType")
Compound = apps.get_model("epdb", "Compound")
CompoundStructure = apps.get_model("epdb", "CompoundStructure")
# Update Compound records
compound_ct = ContentType.objects.get_for_model(Compound)
Compound.objects.filter(polymorphic_ctype__isnull=True).update(polymorphic_ctype=compound_ct)
# Update CompoundStructure records
compound_structure_ct = ContentType.objects.get_for_model(CompoundStructure)
CompoundStructure.objects.filter(polymorphic_ctype__isnull=True).update(
polymorphic_ctype=compound_structure_ct
)
def reverse_populate_polymorphic_ctype(apps, schema_editor):
Compound = apps.get_model("epdb", "Compound")
CompoundStructure = apps.get_model("epdb", "CompoundStructure")
Compound.objects.all().update(polymorphic_ctype=None)
CompoundStructure.objects.all().update(polymorphic_ctype=None)
class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("epdb", "0019_remove_scenario_additional_information_and_more"),
]
operations = [
migrations.AlterModelOptions(
name="compoundstructure",
options={"base_manager_name": "objects"},
),
migrations.AddField(
model_name="compound",
name="polymorphic_ctype",
field=models.ForeignKey(
editable=False,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="polymorphic_%(app_label)s.%(class)s_set+",
to="contenttypes.contenttype",
),
),
migrations.AddField(
model_name="compoundstructure",
name="polymorphic_ctype",
field=models.ForeignKey(
editable=False,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="polymorphic_%(app_label)s.%(class)s_set+",
to="contenttypes.contenttype",
),
),
migrations.RunPython(populate_polymorphic_ctype, reverse_populate_polymorphic_ctype),
]

View 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",),
),
]

View File

@ -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",
),
),
]

View File

@ -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"),
),
]

View 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),
),
]

View 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),
]

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,17 @@ from django.core.mail import EmailMultiAlternatives
from django.utils import timezone
from epdb.logic import SPathway
from epdb.models import Edge, EPModel, JobLog, Node, Pathway, Rule, Setting, User
from epdb.models import (
AdditionalInformation,
Edge,
EPModel,
JobLog,
Node,
Pathway,
Rule,
Setting,
User,
)
from utilities.chem import FormatConverter
logger = logging.getLogger(__name__)
@ -66,9 +76,9 @@ def mul(a, b):
@shared_task(queue="predict")
def predict_simple(model_pk: int, smiles: str):
def predict_simple(model_pk: int, smiles: str, *args, **kwargs):
mod = get_ml_model(model_pk)
res = mod.predict(smiles)
res = mod.predict(smiles, *args, **kwargs)
return res
@ -229,9 +239,28 @@ def predict(
if JobLog.objects.filter(task_id=self.request.id).exists():
JobLog.objects.filter(task_id=self.request.id).update(status="SUCCESS", task_result=pw.url)
# dispatch property job
compute_properties.delay(pw_pk, pred_setting_pk)
return pw.url
@shared_task(bind=True, queue="background")
def compute_properties(self, pathway_pk: int, setting_pk: int):
pw = Pathway.objects.get(id=pathway_pk)
setting = Setting.objects.get(id=setting_pk)
nodes = [n for n in pw.nodes]
smiles = [n.default_node_label.smiles for n in nodes]
for prop_mod in setting.property_models.all():
if prop_mod.instance().is_heavy():
rr = prop_mod.predict_batch(smiles)
for idx, pred in enumerate(rr.result):
n = nodes[idx]
_ = AdditionalInformation.create(pw.package, ai=pred, content_object=n)
@shared_task(bind=True, queue="background")
def identify_missing_rules(
self,
@ -420,7 +449,7 @@ def batch_predict(
standardized_substrates_and_smiles = []
for substrate in substrate_and_names:
try:
stand_smiles = FormatConverter.standardize(substrate[0])
stand_smiles = FormatConverter.standardize(substrate[0], remove_stereo=True)
standardized_substrates_and_smiles.append([stand_smiles, substrate[1]])
except ValueError:
raise ValueError(

17
epdb/template_registry.py Normal file
View 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, [])]

View File

@ -2,6 +2,8 @@ from django import template
from pydantic import AnyHttpUrl, ValidationError
from pydantic.type_adapter import TypeAdapter
from epdb.template_registry import get_templates
register = template.Library()
url_adapter = TypeAdapter(AnyHttpUrl)
@ -19,3 +21,8 @@ def is_url(value):
return True
except ValidationError:
return False
@register.simple_tag
def epdb_slot_templates(slot):
return get_templates(slot)

View File

@ -1,12 +1,15 @@
import json
import logging
from datetime import datetime
from typing import Any, Dict, List
from typing import Any, Dict, List, Iterable
import requests
import nh3
from django.conf import settings as s
from django.contrib.auth import get_user_model
from django.core.exceptions import BadRequest, PermissionDenied
from django.contrib.auth.validators import UnicodeUsernameValidator
from django.core.exceptions import BadRequest, PermissionDenied, ValidationError
from django.core.validators import validate_email
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
@ -26,9 +29,11 @@ from .logic import (
UserManager,
)
from .models import (
AdditionalInformation,
APIToken,
Compound,
CompoundStructure,
ClassifierPluginModel,
Edge,
EnviFormer,
EnzymeLink,
@ -44,6 +49,7 @@ from .models import (
Node,
Pathway,
Permission,
PropertyPluginModel,
Reaction,
Rule,
RuleBasedRelativeReasoning,
@ -143,6 +149,11 @@ def handler500(request):
def login(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":
context["title"] = "enviPath"
context["next"] = request.GET.get("next", "")
@ -160,14 +171,27 @@ def login(request):
# Get email for username and check if the account is active
try:
temp_user = get_user_model().objects.get(username=username)
# Try username and if it fails check if username is a valid email adress and we'll find a user
try:
temp_user = get_user_model().objects.get(username=username)
except get_user_model().DoesNotExist as e:
# validate_email returns None if input is valid -> check for None
# Otherwise a ValidationError is raised
if validate_email(username) is None:
temp_user = get_user_model().objects.get(email=username)
else:
raise e
if not temp_user.is_active:
context["message"] = "User account is not activated yet!"
return render(request, "static/login.html", context)
email = temp_user.email
except get_user_model().DoesNotExist:
except (get_user_model().DoesNotExist, ValidationError):
context["message"] = "Login failed!"
return render(request, "static/login.html", context)
except Exception as e:
logger.info(f"Uncaught exception while trying to login: {e}")
context["message"] = "Login failed!"
return render(request, "static/login.html", context)
@ -207,6 +231,11 @@ def logout(request):
def register(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":
# Redirect to unified login page with signup tab
next_url = request.GET.get("next", "")
@ -221,6 +250,33 @@ def register(request):
if next := request.POST.get("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()
email = request.POST.get("email", "").strip()
password = request.POST.get("password", "").strip()
@ -230,6 +286,15 @@ def register(request):
context["message"] = "Invalid username/email/password"
return render(request, "static/login.html", context)
try:
UnicodeUsernameValidator()(username)
except ValidationError:
context["message"] = (
"Enter a valid username. This value may contain only letters, "
"numbers, and @/./+/-/_ characters."
)
return render(request, "static/login.html", context)
if password != rpassword or password == "":
context["message"] = "Registration failed, provided passwords differ!"
return render(request, "static/login.html", context)
@ -377,7 +442,7 @@ def breadcrumbs(
def set_scenarios(current_user, attach_object, scenario_urls: List[str]):
scens = []
for scenario_url in scenario_urls:
# As empty lists will be removed in POST request well send ['']
# As empty lists will be removed in POST request we'll send ['']
if scenario_url == "":
continue
@ -389,6 +454,7 @@ def set_scenarios(current_user, attach_object, scenario_urls: List[str]):
def set_aliases(current_user, attach_object, aliases: List[str]):
# As empty lists will be removed in POST request we'll send ['']
if aliases == [""]:
aliases = []
@ -397,7 +463,7 @@ def set_aliases(current_user, attach_object, aliases: List[str]):
def copy_object(current_user, target_package: "Package", source_object_url: str):
# Ensures that source is readable
# Ensures that source object is readable
source_package = PackageManager.get_package_by_url(current_user, source_object_url)
if source_package == target_package:
@ -405,7 +471,7 @@ def copy_object(current_user, target_package: "Package", source_object_url: str)
parser = EPDBURLParser(source_object_url)
# if the url won't contain a package or is a plain package
# if the url don't contain a package or is a plain package
if not parser.contains_package_url():
raise ValueError(f"Object {source_object_url} can't be copied!")
@ -490,7 +556,7 @@ def packages(request):
# Context for paginated template
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["list_title"] = "packages"
@ -548,7 +614,7 @@ def compounds(request):
# Context for paginated template
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["list_mode"] = "tabbed"
context["list_title"] = "compounds"
@ -577,7 +643,7 @@ def rules(request):
# Context for paginated template
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["list_title"] = "rules"
@ -605,7 +671,7 @@ def reactions(request):
# Context for paginated template
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["list_title"] = "reactions"
@ -633,7 +699,7 @@ def pathways(request):
# Context for paginated template
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["list_title"] = "pathways"
@ -663,7 +729,7 @@ def scenarios(request):
# Context for paginated template
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["list_title"] = "scenarios"
@ -690,16 +756,40 @@ def models(request):
# Keep model_types for potential modal/action use
context["model_types"] = {
"ML Relative Reasoning": "ml-relative-reasoning",
"Rule Based Relative Reasoning": "rule-based-relative-reasoning",
"EnviFormer": "enviformer",
"ML Relative Reasoning": {
"type": "ml-relative-reasoning",
"requires_rule_packages": True,
"requires_data_packages": True,
},
"Rule Based Relative Reasoning": {
"type": "rule-based-relative-reasoning",
"requires_rule_packages": True,
"requires_data_packages": True,
},
"EnviFormer": {
"type": "enviformer",
"requires_rule_packages": False,
"requires_data_packages": True,
},
}
for k, v in s.CLASSIFIER_PLUGINS.items():
context["model_types"][v.display()] = k
if s.FLAGS.get("PLUGINS", False):
for k, v in s.CLASSIFIER_PLUGINS.items():
context["model_types"][v.display()] = {
"type": k,
"requires_rule_packages": v.requires_rule_packages(),
"requires_data_packages": v.requires_data_packages(),
}
for k, v in s.PROPERTY_PLUGINS.items():
context["model_types"][v.display()] = {
"type": k,
"requires_rule_packages": v.requires_rule_packages(),
"requires_data_packages": v.requires_data_packages(),
}
# Context for paginated template
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["list_title"] = "models"
@ -781,7 +871,7 @@ def package_models(request, package_uuid):
context["object_type"] = "model"
context["breadcrumbs"] = breadcrumbs(current_package, "model")
context["entity_type"] = "model"
context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/model/"
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/package/{current_package.uuid}/model/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "models"
@ -806,16 +896,39 @@ def package_models(request, package_uuid):
)
context["model_types"] = {
"ML Relative Reasoning": "mlrr",
"Rule Based Relative Reasoning": "rbrr",
"ML Relative Reasoning": {
"type": "ml-relative-reasoning",
"requires_rule_packages": True,
"requires_data_packages": True,
},
"Rule Based Relative Reasoning": {
"type": "rule-based-relative-reasoning",
"requires_rule_packages": True,
"requires_data_packages": True,
},
"EnviFormer": {
"type": "enviformer",
"requires_rule_packages": False,
"requires_data_packages": True,
},
}
if s.FLAGS.get("ENVIFORMER", False):
context["model_types"]["EnviFormer"] = "enviformer"
if s.FLAGS.get("PLUGINS", False):
for k, v in s.CLASSIFIER_PLUGINS.items():
context["model_types"][v.display()] = k
context["model_types"][v.display()] = {
"type": k,
"requires_rule_packages": v.requires_rule_packages(),
"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():
context["model_types"][v.display()] = {
"type": k,
"requires_rule_packages": v.requires_rule_packages(),
"requires_data_packages": v.requires_data_packages(),
}
return render(request, "collections/models_paginated.html", context)
@ -846,7 +959,7 @@ def package_models(request, package_uuid):
params["threshold"] = threshold
mod = EnviFormer.create(**params)
elif model_type == "mlrr":
elif model_type == "ml-relative-reasoning":
# ML Specific
threshold = float(request.POST.get("model-threshold", 0.5))
# TODO handle additional fingerprinter
@ -870,14 +983,44 @@ def package_models(request, package_uuid):
params["app_domain_local_compatibility_threshold"] = local_compatibility_threshold
mod = MLRelativeReasoning.create(**params)
elif model_type == "rbrr":
elif model_type == "rule-based-relative-reasoning":
params["rule_packages"] = [
PackageManager.get_package_by_url(current_user, p) for p in rule_packages
]
mod = RuleBasedRelativeReasoning.create(**params)
elif s.FLAGS.get("PLUGINS", False) and model_type in s.CLASSIFIER_PLUGINS.values():
pass
elif s.FLAGS.get("PLUGINS", False) and model_type in s.CLASSIFIER_PLUGINS:
params["plugin_identifier"] = model_type
impl = s.CLASSIFIER_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():
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"]
mod = PropertyPluginModel.create(**params)
else:
return error(
request, "Invalid model type.", f'Model type "{model_type}" is not supported."'
@ -901,14 +1044,18 @@ def package_model(request, package_uuid, model_uuid):
if request.method == "GET":
classify = request.GET.get("classify", False)
ad_assessment = request.GET.get("app-domain-assessment", False)
# TODO this needs to be generic
half_life = request.GET.get("half_life", False)
if classify or ad_assessment:
if any([classify, ad_assessment, half_life]):
smiles = request.GET.get("smiles", "").strip()
# Check if smiles is non empty and valid
if smiles == "":
return JsonResponse({"error": "Received empty SMILES"}, status=400)
stereo = FormatConverter.has_stereo(smiles)
try:
stand_smiles = FormatConverter.standardize(smiles, remove_stereo=True)
except ValueError:
@ -942,6 +1089,19 @@ def package_model(request, package_uuid, model_uuid):
return JsonResponse(res, safe=False)
elif half_life:
from epdb.tasks import dispatch_eager, predict_simple
_, run_res = dispatch_eager(
current_user, predict_simple, current_model.pk, stand_smiles, include_svg=True
)
# Here we expect a single result
if isinstance(run_res.result, Iterable):
return JsonResponse(run_res.result[0].model_dump(mode="json"), safe=False)
return JsonResponse(run_res.result.model_dump(mode="json"), safe=False)
else:
app_domain_assessment = current_model.app_domain.assess(stand_smiles)
return JsonResponse(app_domain_assessment, safe=False)
@ -956,7 +1116,11 @@ def package_model(request, package_uuid, model_uuid):
context["model"] = current_model
context["current_object"] = current_model
return render(request, "objects/model.html", context)
if isinstance(current_model, PropertyPluginModel):
context["plugin_identifier"] = current_model.plugin_identifier
return render(request, "objects/model/property_model.html", context)
else:
return render(request, "objects/model/classification_model.html", context)
elif request.method == "POST":
if hidden := request.POST.get("hidden", None):
@ -1177,7 +1341,7 @@ def package_compounds(request, package_uuid):
context["object_type"] = "compound"
context["breadcrumbs"] = breadcrumbs(current_package, "compound")
context["entity_type"] = "compound"
context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/compound/"
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/package/{current_package.uuid}/compound/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_mode"] = "tabbed"
context["list_title"] = "compounds"
@ -1330,7 +1494,7 @@ def package_compound_structures(request, package_uuid, compound_uuid):
context["entity_type"] = "structure"
context["page_title"] = f"{current_compound.get_name()} - Structures"
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["compound"] = current_compound
@ -1493,7 +1657,7 @@ def package_rules(request, package_uuid):
context["object_type"] = "rule"
context["breadcrumbs"] = breadcrumbs(current_package, "rule")
context["entity_type"] = "rule"
context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/rule/"
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/package/{current_package.uuid}/rule/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "rules"
@ -1567,7 +1731,7 @@ def package_rule(request, package_uuid, rule_uuid):
context = get_base_context(request)
if smiles := request.GET.get("smiles", False):
stand_smiles = FormatConverter.standardize(smiles)
stand_smiles = FormatConverter.standardize(smiles, remove_stereo=True)
res = current_rule.apply(stand_smiles)
if len(res) > 1:
logger.info(
@ -1701,7 +1865,7 @@ def package_reactions(request, package_uuid):
context["object_type"] = "reaction"
context["breadcrumbs"] = breadcrumbs(current_package, "reaction")
context["entity_type"] = "reaction"
context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/reaction/"
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/package/{current_package.uuid}/reaction/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "reactions"
@ -1851,7 +2015,7 @@ def package_pathways(request, package_uuid):
context["object_type"] = "pathway"
context["breadcrumbs"] = breadcrumbs(current_package, "pathway")
context["entity_type"] = "pathway"
context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/pathway/"
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/package/{current_package.uuid}/pathway/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "pathways"
@ -1895,7 +2059,7 @@ def package_pathways(request, package_uuid):
"Pathway prediction failed due to missing or empty SMILES",
)
try:
stand_smiles = FormatConverter.standardize(smiles)
stand_smiles = FormatConverter.standardize(smiles, remove_stereo=True)
except ValueError:
return error(
request,
@ -1916,6 +2080,7 @@ def package_pathways(request, package_uuid):
prediction_setting = SettingManager.get_setting_by_url(current_user, prediction_setting)
else:
prediction_setting = current_user.prediction_settings()
pw = Pathway.create(
current_package,
stand_smiles,
@ -2341,6 +2506,9 @@ def package_pathway_edges(request, package_uuid, pathway_uuid):
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)
else:
@ -2426,7 +2594,7 @@ def package_scenarios(request, package_uuid):
context["object_type"] = "scenario"
context["breadcrumbs"] = breadcrumbs(current_package, "scenario")
context["entity_type"] = "scenario"
context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/scenario/"
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/package/{current_package.uuid}/scenario/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "scenarios"
@ -2480,8 +2648,10 @@ def package_scenario(request, package_uuid, scenario_uuid):
context["breadcrumbs"] = breadcrumbs(current_package, "scenario", current_scenario)
context["scenario"] = current_scenario
# Get scenarios that have current_scenario as a parent
context["children"] = current_scenario.scenario_set.order_by("name")
context["associated_additional_information"] = AdditionalInformation.objects.filter(
scenario=current_scenario
)
# Note: Modals now fetch schemas and data from API endpoints
# Keeping these for backwards compatibility if needed elsewhere
@ -2588,11 +2758,22 @@ def user(request, user_uuid):
context["user"] = requested_user
model_qs = EPModel.objects.none()
for p in PackageManager.get_all_readable_packages(requested_user, include_reviewed=True):
model_qs |= p.models
accessible_packages = PackageManager.get_all_readable_packages(
requested_user, include_reviewed=True
)
context["models"] = model_qs
property_models = PropertyPluginModel.objects.filter(
package__in=accessible_packages
).order_by("name")
tp_prediction_models = (
EPModel.objects.filter(package__in=accessible_packages)
.exclude(id__in=[pm.id for pm in property_models])
.order_by("name")
)
context["models"] = tp_prediction_models
context["property_models"] = property_models
context["tokens"] = APIToken.objects.filter(user=requested_user)
@ -2668,9 +2849,15 @@ def groups(request):
{"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":
group_name = request.POST.get("group-name")
group_description = request.POST.get("group-description", s.DEFAULT_VALUES["description"])
@ -2762,14 +2949,13 @@ def settings(request):
# Context for paginated template
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["list_title"] = "settings"
context["list_mode"] = "combined"
return render(request, "collections/settings_paginated.html", context)
return render(request, "collections/objects_list.html", context)
elif request.method == "POST":
if s.DEBUG:
for k, v in request.POST.items():
@ -2781,15 +2967,18 @@ def settings(request):
new_default = request.POST.get("prediction-setting-new-default", "off") == "on"
# min 2, max s.DEFAULT_MAX_NUMBER_OF_NODES
max_nodes = min(
max(
int(request.POST.get("prediction-setting-max-nodes", 1)),
s.DEFAULT_MAX_NUMBER_OF_NODES,
2,
),
s.DEFAULT_MAX_NUMBER_OF_NODES,
)
# min 1, max s.DEFAULT_MAX_DEPTH
max_depth = min(
max(int(request.POST.get("prediction-setting-max-depth", 1)), s.DEFAULT_MAX_DEPTH),
max(int(request.POST.get("prediction-setting-max-depth", 1)), 1),
s.DEFAULT_MAX_DEPTH,
)
@ -2827,6 +3016,18 @@ def settings(request):
else:
raise BadRequest("Neither Model-Based nor Rule-Based as Method selected!")
property_model_urls = request.POST.getlist("prediction-setting-property-models")
if property_model_urls:
mods = []
for pm_url in property_model_urls:
model = PropertyPluginModel.objects.get(url=pm_url)
if PackageManager.readable(current_user, model.package):
mods.append(model)
params["property_models"] = mods
created_setting = SettingManager.create_setting(
current_user,
name=name,
@ -2936,12 +3137,12 @@ def jobs(request):
parts = pair.split(",")
try:
smiles = FormatConverter.standardize(parts[0])
smiles = FormatConverter.standardize(parts[0], remove_stereo=True)
except ValueError:
raise BadRequest(f"Couldn't standardize SMILES {parts[0]}!")
# name is optional
name = parts[1] if len(parts) > 1 else None
name = ",".join(parts[1:]) if len(parts) > 1 else None
pred_data.append([smiles, name])
max_tps = 50

0
epiuclid/__init__.py Normal file
View File

22
epiuclid/api.py Normal file
View 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
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class EpiuclidConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "epiuclid"

View File

105
epiuclid/builders/base.py Normal file
View 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"

View 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("_", " ")

View 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,
)

View 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,
)

View 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())

View 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>

View 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>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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="&gt;"/>
<xs:enumeration value="&gt;="/>
<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="&lt;"/>
<xs:enumeration value="&lt;="/>
<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="&gt;"/>
<xs:enumeration value="&gt;="/>
<xs:enumeration value="&lt;"/>
<xs:enumeration value="&lt;="/>
<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>

View 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>

View 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>

View 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>

View 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:
&lt;schema . . .>
. . .
&lt;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.
&lt;type . . .>
. . .
&lt;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>

View File

118
epiuclid/serializers/i6z.py Normal file
View 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

View 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)

View 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,
}

View File

102
epiuclid/tests/factories.py Normal file
View 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)

View 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)

View 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
View 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)

View 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, [])

View 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)

View 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)

View 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}")

BIN
fixtures/db.dump Normal file

Binary file not shown.

Binary file not shown.

366
pepper/__init__.py Normal file
View File

@ -0,0 +1,366 @@
import logging
import math
import os
import pickle
from datetime import datetime
from typing import Any, List, Optional
import polars as pl
from pydantic import computed_field
from sklearn.metrics import (
mean_absolute_error,
mean_squared_error,
r2_score,
root_mean_squared_error,
)
from sklearn.model_selection import ShuffleSplit
# Once stable these will be exposed by enviPy-plugins lib
from envipy_additional_information import register # noqa: I001
from bridge.contracts import Property, PropertyType # noqa: I001
from bridge.dto import (
BuildResult,
EnviPyDTO,
EvaluationResult,
PropertyPrediction,
RunResult,
) # noqa: I001
from .impl.pepper import Pepper # noqa: I001
logger = logging.getLogger(__name__)
@register("pepperprediction")
class PepperPrediction(PropertyPrediction):
mean: float | None
std: float | None
log_mean: float | None
log_std: float | None
@computed_field
@property
def svg(self, xscale="linear", quantiles=(0.01, 0.99), n_points=2000) -> Optional[str]:
import io
import matplotlib.patches as mpatches
import numpy as np
from matplotlib.figure import Figure
from scipy import stats
"""
Plot the lognormal distribution of chemical half-lives where parameters are
given on a base-10 log scale: log10(half-life) ~ Normal(mu_log10, sigma_log10^2).
Shades:
- x < a in green (Non-persistent)
- a <= x <= b in yellow (Persistent)
- x > b in red (Very persistent)
Legend shows the shaded color and the probability mass in each region.
"""
sigma_log10 = self.log_std
mu_log10 = self.log_mean
if sigma_log10 <= 0:
raise ValueError("sigma_log10 must be > 0")
# Persistent and Very Persistent thresholds in days from REACH (https://doi.org/10.26434/chemrxiv-2025-xmslf)
p = 120
vp = 180
# Convert base-10 log parameters to natural-log parameters for SciPy's lognorm
ln10 = np.log(10.0)
mu_ln = mu_log10 * ln10
sigma_ln = sigma_log10 * ln10
# SciPy parameterization: lognorm(s=sigma_ln, scale=exp(mu_ln))
dist = stats.lognorm(s=sigma_ln, scale=np.exp(mu_ln))
# Exact probabilities
p_green = dist.cdf(p) # P(X < p) prob not persistent
p_yellow = 1.0 - dist.cdf(p) # P (X > p) prob persistent
p_red = 1.0 - dist.cdf(vp) # P(X > vp) prob very persistent
# Plotting range
q_low, q_high = dist.ppf(quantiles)
x_min = max(1e-12, min(q_low, p) * 0.9)
x_max = max(q_high, vp) * 1.1
# Build x-grid (linear days axis)
if xscale == "log":
x = np.logspace(np.log10(x_min), np.log10(x_max), n_points)
else:
x = np.linspace(x_min, x_max, n_points)
y = dist.pdf(x)
# Masks for shading
mask_green = x < p
mask_yellow = (x >= p) & (x <= vp)
mask_red = x > vp
# Plot
fig = Figure(figsize=(9, 5.5))
ax = fig.subplots()
ax.plot(x, y, color="#1f4e79", lw=2, label="Lognormal PDF")
if np.any(mask_green):
ax.fill_between(x[mask_green], y[mask_green], 0, color="tab:green", alpha=0.3)
if np.any(mask_yellow):
ax.fill_between(x[mask_yellow], y[mask_yellow], 0, color="gold", alpha=0.35)
if np.any(mask_red):
ax.fill_between(x[mask_red], y[mask_red], 0, color="tab:red", alpha=0.3)
# Threshold lines
ax.axvline(p, color="gray", ls="--", lw=1)
ax.axvline(vp, color="gray", ls="--", lw=1)
# Labels & title
ax.set_title(
f"Half-life Distribution (Lognormal)\nlog10 parameters: μ={mu_log10:g}, σ={sigma_log10:g}"
)
ax.set_xlabel("Half-life (days)")
ax.set_ylabel("Probability density")
ax.grid(True, alpha=0.25)
if xscale == "log":
ax.set_xscale("log") # not used in this example, but supported
# Legend with probabilities
patches = [
mpatches.Patch(
color="tab:green",
alpha=0.3,
label=f"Non-persistent (<{p:g} d): {p_green:.2%}",
),
mpatches.Patch(
color="gold",
alpha=0.35,
label=f"Persistent ({p:g}{vp:g} d): {p_yellow:.2%}",
),
mpatches.Patch(
color="tab:red",
alpha=0.3,
label=f"Very persistent (>{vp:g} d): {p_red:.2%}",
),
]
ax.legend(handles=patches, frameon=True)
fig.tight_layout()
# --- Export to SVG string ---
buf = io.StringIO()
fig.savefig(buf, format="svg", bbox_inches="tight")
svg = buf.getvalue()
buf.close()
return svg
class PEPPER(Property):
@classmethod
def identifier(cls) -> str:
return "pepper"
@classmethod
def display(cls) -> str:
return "PEPPER"
@classmethod
def name(cls) -> str:
return "Predict Environmental Pollutant PERsistence"
@classmethod
def requires_rule_packages(cls) -> bool:
return False
@classmethod
def requires_data_packages(cls) -> bool:
return True
def get_type(self) -> PropertyType:
return PropertyType.HEAVY
def generate_dataset(self, eP: EnviPyDTO) -> pl.DataFrame:
"""
Generates a dataset in the form of a Polars DataFrame containing compound information, including
SMILES strings and logarithmic values of degradation half-lives (dt50).
The dataset is built by iterating over a list of compounds, standardizing SMILES strings, and
calculating the logarithmic mean of the half-life intervals for different environmental scenarios
associated with each compound.
The resulting DataFrame will only include unique rows based on SMILES and logarithmic half-life
values.
Parameters:
eP (EnviPyDTO): An object that provides access to compound data and utility functions for
standardization and retrieval of half-life information.
Returns:
pl.DataFrame: The resulting dataset with unique rows containing compound structure identifiers,
standardized SMILES strings, and logarithmic half-life values.
Raises:
Exception: Exceptions are caught and logged during data processing, specifically when retrieving
half-life information.
Note:
- The logarithmic mean is calculated from the start and end intervals of the dt50 (half-life).
- Compounds not associated with any half-life data are skipped, and errors encountered during processing
are logged without halting the execution.
"""
columns = ["structure_id", "smiles", "dt50_log"]
rows = []
for c in eP.get_compounds():
hls = c.half_lifes()
if len(hls):
stand_smiles = eP.standardize(c.smiles, remove_stereo=True)
for scenario, half_lives in hls.items():
for h in half_lives:
# In the original Pepper code they take the mean of the start and end interval.
half_mean = (h.dt50.start + h.dt50.end) / 2
rows.append([str(c.url), stand_smiles, math.log10(half_mean)])
df = pl.DataFrame(data=rows, schema=columns, orient="row", infer_schema_length=None)
df = df.unique(subset=["smiles", "dt50_log"], keep="any", maintain_order=False)
return df
def save_dataset(self, df: pl.DataFrame, path: str):
with open(path, "wb") as fh:
pickle.dump(df, fh)
def load_dataset(self, path: str) -> pl.DataFrame:
with open(path, "rb") as fh:
return pickle.load(fh)
def build(self, eP: EnviPyDTO, *args, **kwargs) -> BuildResult | None:
logger.info(f"Start building PEPPER {eP.get_context().uuid}")
df = self.generate_dataset(eP)
if df.shape[0] == 0:
raise ValueError("No data found for building model")
p = Pepper()
p, train_ds = p.train_model(df)
ds_store_path = os.path.join(
eP.get_context().work_dir, f"pepper_ds_{eP.get_context().uuid}.pkl"
)
self.save_dataset(train_ds, ds_store_path)
model_store_path = os.path.join(
eP.get_context().work_dir, f"pepper_{eP.get_context().uuid}.pkl"
)
p.save_model(model_store_path)
logger.info(f"Finished building PEPPER {eP.get_context().uuid}")
def run(self, eP: EnviPyDTO, *args, **kwargs) -> RunResult:
load_path = os.path.join(eP.get_context().work_dir, f"pepper_{eP.get_context().uuid}.pkl")
p = Pepper.load_model(load_path)
X_new = [c.smiles for c in eP.get_compounds()]
predictions = p.predict_batch(X_new)
results = []
for p in zip(*predictions):
if p[0] is None or p[1] is None:
result = {"log_mean": None, "mean": None, "log_std": None, "std": None, "svg": None}
else:
result = {
"log_mean": p[0],
"mean": 10 ** p[0],
"log_std": p[1],
"std": 10 ** p[1],
}
results.append(PepperPrediction(**result))
rr = RunResult(
producer=eP.get_context().url,
description=f"Generated at {datetime.now()}",
result=results,
)
return rr
def evaluate(self, eP: EnviPyDTO, *args, **kwargs) -> EvaluationResult | None:
logger.info(f"Start evaluating PEPPER {eP.get_context().uuid}")
load_path = os.path.join(eP.get_context().work_dir, f"pepper_{eP.get_context().uuid}.pkl")
p = Pepper.load_model(load_path)
df = self.generate_dataset(eP)
ds = p.preprocess_data(df)
y_pred = p.predict_batch(ds["smiles"])
# We only need the mean
if isinstance(y_pred, tuple):
y_pred = y_pred[0]
res = self.eval_stats(ds["dt50_bayesian_mean"], y_pred)
logger.info(f"Finished evaluating PEPPER {eP.get_context().uuid}")
return EvaluationResult(data=res)
def build_and_evaluate(self, eP: EnviPyDTO, *args, **kwargs) -> EvaluationResult | None:
logger.info(f"Start evaluating PEPPER {eP.get_context().uuid}")
ds_load_path = os.path.join(
eP.get_context().work_dir, f"pepper_ds_{eP.get_context().uuid}.pkl"
)
ds = self.load_dataset(ds_load_path)
n_splits = kwargs.get("n_splits", 20)
shuff = ShuffleSplit(n_splits=n_splits, test_size=0.1, random_state=42)
fold_metrics: List[dict[str, Any]] = []
for split_id, (train_index, test_index) in enumerate(shuff.split(ds)):
logger.info(f"Evaluation fold {split_id}/{n_splits} PEPPER {eP.get_context().uuid}")
train = ds[train_index]
test = ds[test_index]
model = Pepper()
model.train_model(train, preprocess=False)
features = test[model.descriptors.get_descriptor_names()].rows()
y_pred = model.predict_batch(features, is_smiles=False)
# We only need the mean for eval statistics but mean, std can be returned
if isinstance(y_pred, tuple) or isinstance(y_pred, list):
y_pred = y_pred[0]
# Remove None if they occur
y_true_filtered, y_pred_filtered = [], []
for t, p in zip(test["dt50_bayesian_mean"], y_pred):
if p is None:
continue
y_true_filtered.append(t)
y_pred_filtered.append(p)
if len(y_true_filtered) == 0:
print("Skipping empty fold")
continue
fold_metrics.append(self.eval_stats(y_true_filtered, y_pred_filtered))
logger.info(f"Finished evaluating PEPPER {eP.get_context().uuid}")
return EvaluationResult(data=fold_metrics)
@staticmethod
def eval_stats(y_true, y_pred) -> dict[str, float]:
scores_dic = {
"r2": r2_score(y_true, y_pred),
"mse": mean_squared_error(y_true, y_pred),
"rmse": root_mean_squared_error(y_true, y_pred),
"mae": mean_absolute_error(y_true, y_pred),
}
return scores_dic

0
pepper/impl/__init__.py Normal file
View File

196
pepper/impl/bayesian.py Normal file
View File

@ -0,0 +1,196 @@
import emcee
import numpy as np
from scipy.stats import lognorm, norm
class Bayesian:
def __init__(self, y, comment_list=None):
if comment_list is None:
comment_list = []
self.y = y
self.comment_list = comment_list
# LOQ default settings
self.LOQ_lower = -1 # (2.4 hours)
self.LOQ_upper = 3 # 1000 days
# prior default settings
self.prior_mu_mean = 1.5
self.prior_mu_std = 2
self.prior_sigma_mean = 0.4
self.prior_sigma_std = 0.4
self.lower_limit_sigma = 0.2
# EMCEE defaults
self.nwalkers = 10
self.iterations = 2000
self.burn_in = 100
ndim = 2 # number of dimensions (mean, std)
# backend = emcee.backends.HDFBackend("backend.h5")
# backend.reset(self.nwalkers, ndim)
self.sampler = emcee.EnsembleSampler(self.nwalkers, ndim, self.logPosterior)
self.posterior_mu = None
self.posterior_sigma = None
def get_censored_values_only(self):
censored_values = []
for i, comment in enumerate(self.comment_list):
if comment in ["<", ">"]:
censored_values.append(self.y[i])
elif self.y[i] > self.LOQ_upper or self.y[i] < self.LOQ_lower:
censored_values.append(self.y[i])
return censored_values
# Class functions
def determine_LOQ(self):
"""
Determines if the LOQ is upper or lower, and the value (if not default)
:return: upper_LOQ , lower_LOQ
"""
censored_values = self.get_censored_values_only()
# Find upper LOQ
upper_LOQ = np.nan
# bigger than global LOQ
if max(self.y) >= self.LOQ_upper:
upper_LOQ = self.LOQ_upper
# case if exactly 365 days
elif max(self.y) == 2.562: # 365 days
upper_LOQ = 2.562
self.LOQ_upper = upper_LOQ
# case if "bigger than" indication in comments
elif ">" in self.comment_list:
i = 0
while i < len(self.y):
if self.y[i] == min(censored_values) and self.comment_list[i] == ">":
self.LOQ_upper = self.y[i]
break
i += 1
# Find lower LOQ
lower_LOQ = np.nan
# smaller than global LOQ
if min(self.y) <= self.LOQ_lower:
lower_LOQ = self.LOQ_lower
# case if exactly 1 day
elif min(self.y) == 0: # 1 day
lower_LOQ = 0
self.LOQ_lower = 0
# case if "smaller than" indication in comments
elif "<" in self.comment_list:
i = 0
while i < len(self.y):
if self.y[i] == max(censored_values) and self.comment_list[i] == "<":
self.LOQ_lower = self.y[i]
break
i += 1
return upper_LOQ, lower_LOQ
def logLikelihood(self, theta, sigma):
"""
Likelihood function (the probability of a dataset (mean, std) given the model parameters)
Convert not censored observations into type numeric
:param theta: mean half-life value to be evaluated
:param sigma: std half-life value to be evaluated
:return: log_likelihood
"""
upper_LOQ, lower_LOQ = self.determine_LOQ()
n_censored_upper = 0
n_censored_lower = 0
y_not_cen = []
if np.isnan(upper_LOQ) and np.isnan(lower_LOQ):
y_not_cen = self.y
else:
for i in self.y:
if np.isnan(upper_LOQ) and i >= upper_LOQ: # censor above threshold
n_censored_upper += 1
if np.isnan(lower_LOQ) and i <= lower_LOQ: # censor below threshold
n_censored_lower += 1
else: # do not censor
y_not_cen.append(i)
LL_left_cen = 0
LL_right_cen = 0
LL_not_cen = 0
# likelihood for not censored observations
if n_censored_lower > 0: # loglikelihood for left censored observations
LL_left_cen = n_censored_lower * norm.logcdf(
lower_LOQ, loc=theta, scale=sigma
) # cumulative distribution function CDF
if n_censored_upper > 0: # loglikelihood for right censored observations
LL_right_cen = n_censored_upper * norm.logsf(
upper_LOQ, loc=theta, scale=sigma
) # survival function (1-CDF)
if len(y_not_cen) > 0: # loglikelihood for uncensored values
LL_not_cen = sum(
norm.logpdf(y_not_cen, loc=theta, scale=sigma)
) # probability density function PDF
return LL_left_cen + LL_not_cen + LL_right_cen
def get_prior_probability_sigma(self, sigma):
# convert mean and sd to logspace parameters, to see this formula check
# https://en.wikipedia.org/wiki/Log-normal_distribution under Method of moments section
temp = 1 + (self.prior_sigma_std / self.prior_sigma_mean) ** 2
meanlog = self.prior_sigma_mean / np.sqrt(temp)
sdlog = np.sqrt(np.log(temp))
# calculate of logpdf of sigma
norm_pdf_sigma = lognorm.logpdf(sigma, s=sdlog, loc=self.lower_limit_sigma, scale=meanlog)
return norm_pdf_sigma
def get_prior_probability_theta(self, theta):
norm_pdf_theta = norm.logpdf(theta, loc=self.prior_mu_mean, scale=self.prior_mu_std)
return norm_pdf_theta
def logPrior(self, par):
"""
Obtain prior loglikelihood of [theta, sigma]
:param par: par = [theta,sigma]
:return: loglikelihood
"""
# calculate the mean and standard deviation in the log-space
norm_pdf_mean = self.get_prior_probability_theta(par[0])
norm_pdf_std = self.get_prior_probability_sigma(par[1])
log_norm_pdf = [norm_pdf_mean, norm_pdf_std]
return sum(log_norm_pdf)
def logPosterior(self, par):
"""
Obtain posterior loglikelihood
:param par: [theta, sigma]
:return: posterior loglikelihood
"""
logpri = self.logPrior(par)
if not np.isfinite(logpri):
return -np.inf
loglikelihood = self.logLikelihood(par[0], par[1])
return logpri + loglikelihood
def get_posterior_distribution(self):
"""
Sample posterior distribution and get median of mean and std samples
:return: posterior half-life mean and std
"""
if self.posterior_mu:
return self.posterior_mu, self.posterior_sigma
# Sampler parameters
ndim = 2 # number of dimensions (mean,std)
p0 = abs(np.random.randn(self.nwalkers, ndim)) # only positive starting numbers (for std)
# Sample distribution
self.sampler.run_mcmc(p0, self.iterations)
# get chain and log_prob in one-dimensional array (merged chains with burn-in)
samples = self.sampler.get_chain(flat=True, discard=100)
# get median mean and std
self.posterior_mu = np.median(samples[:, 0])
self.posterior_sigma = np.median(samples[:, 1])
return self.posterior_mu, self.posterior_sigma
# Utility functions
def get_normal_distribution(x, mu, sig):
return np.exp(-np.power(x - mu, 2.0) / (2 * np.power(sig, 2.0)))

View File

@ -0,0 +1,11 @@
GPR:
name: Gaussian Process Regressor
regressor: GaussianProcessRegressor
regressor_params:
normalize_y: True
n_restarts_optimizer: 0
kernel: "ConstantKernel(1.0, (1e-3, 1e3)) * Matern(length_scale=2.5, length_scale_bounds=(1e-3, 1e3), nu=0.5)"
feature_reduction_method: None
feature_reduction_parameters:
pca:
n_components: 34

View File

@ -0,0 +1,60 @@
from abc import ABC, abstractmethod
from typing import List
from mordred import Calculator, descriptors
from padelpy import from_smiles
from rdkit import Chem
class Descriptor(ABC):
@abstractmethod
def get_molecule_descriptors(self, molecule: str) -> List[float | int] | None:
pass
@abstractmethod
def get_descriptor_names(self) -> List[str]:
pass
class Mordred(Descriptor):
calc = Calculator(descriptors, ignore_3D=True)
def get_molecule_descriptors(self, molecule: str) -> List[float | int] | None:
mol = Chem.MolFromSmiles(molecule)
res = list(self.calc(mol))
return res
def get_descriptor_names(self) -> List[str]:
return [f"Mordred_{i}" for i in range(len(self.calc.descriptors))]
class PaDEL(Descriptor):
calc = Calculator(descriptors)
def get_molecule_descriptors(self, molecule: str) -> List[float | int] | None:
try:
padel_descriptors = from_smiles(molecule, threads=1)
except RuntimeError:
return []
formatted = []
for k, v in padel_descriptors.items():
try:
formatted.append(float(v))
except ValueError:
formatted.append(0.0)
return formatted
def get_descriptor_names(self) -> List[str]:
return [f"PaDEL_{i}" for i in range(1875)]
if __name__ == "__main__":
mol = "CC1=CC(O)=CC=C1[N+](=O)[O-]"
m = Mordred()
print(list(m.get_molecule_descriptors(mol)))
p = PaDEL()
print(list(p.get_molecule_descriptors(mol)))

330
pepper/impl/pepper.py Normal file
View File

@ -0,0 +1,330 @@
import importlib.resources
import logging
import math
import os
import pickle
from collections import defaultdict
from typing import List
import numpy as np
import polars as pl
import yaml
from joblib import Parallel, delayed
from scipy.cluster import hierarchy
from scipy.spatial.distance import squareform
from scipy.stats import spearmanr
from sklearn.feature_selection import VarianceThreshold
from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import FunctionTransformer, MinMaxScaler
from .bayesian import Bayesian
from .descriptors import Mordred
class Pepper:
def __init__(self, config_path=None, random_state=42):
self.random_state = random_state
if config_path is None:
config_path = importlib.resources.files("pepper.impl.config").joinpath(
"regressor_settings_singlevalue_soil_paper_GPR_optimized.yml"
)
with open(config_path, "r") as file:
regressor_settings = yaml.safe_load(file)
if len(regressor_settings) > 1:
logging.warning(
f"More than one regressor config found in {config_path}, using the first one"
)
self.regressor_settings = regressor_settings[list(regressor_settings.keys())[0]]
if "kernel" in self.regressor_settings["regressor_params"]:
from sklearn.gaussian_process.kernels import ConstantKernel, Matern # noqa: F401
# We could hard-code the kernels they have, maybe better than using eval
self.regressor_settings["regressor_params"]["kernel"] = eval(
self.regressor_settings["regressor_params"]["kernel"]
)
# We assume the YAML has the key regressor containing a regressor name
self.regressor = self.get_regressor_by_name(self.regressor_settings["regressor"])
if "regressor_params" in self.regressor_settings: # Set params if any are given
self.regressor.set_params(**self.regressor_settings["regressor_params"])
# TODO we could make this configurable
self.descriptors = Mordred()
self.descriptor_subset = None
self.min_max_scaler = MinMaxScaler().set_output(transform="polars")
self.feature_preselector = Pipeline(
[
(
"variance_threshold",
VarianceThreshold(threshold=0.02).set_output(transform="polars"),
),
# Feature selection based on variance threshold
(
"custom_feature_selection",
FunctionTransformer(
func=self.remove_highly_correlated_features,
validate=False,
kw_args={"corr_method": "spearman", "cluster_threshold": 0.01},
).set_output(transform="polars"),
),
]
)
def get_regressor_by_name(self, regressor_string):
"""
Load regressor function from a regressor name
:param regressor_string: name of regressor as defined in config file (function name with parentheses)
:return: Regressor object
"""
# if regressor_string == 'RandomForestRegressor':
# return RandomForestRegressor(random_state=self.random_state)
# elif regressor_string == 'GradientBoostingRegressor':
# return GradientBoostingRegressor(random_state=self.random_state)
# elif regressor_string == 'AdaBoostRegressor':
# return AdaBoostRegressor(random_state=self.random_state)
# elif regressor_string == 'MLPRegressor':
# return MLPRegressor(random_state=self.random_state)
# elif regressor_string == 'SVR':
# return SVR()
# elif regressor_string == 'KNeighborsRegressor':
# return KNeighborsRegressor()
if regressor_string == "GaussianProcessRegressor":
return GaussianProcessRegressor(random_state=self.random_state)
# elif regressor_string == 'DecisionTreeRegressor':
# return DecisionTreeRegressor(random_state=self.random_state)
# elif regressor_string == 'Ridge':
# return Ridge(random_state=self.random_state)
# elif regressor_string == 'SGDRegressor':
# return SGDRegressor(random_state=self.random_state)
# elif regressor_string == 'KernelRidge':
# return KernelRidge()
# elif regressor_string == 'LinearRegression':
# return LinearRegression()
# elif regressor_string == 'LSVR':
# return SVR(kernel='linear') # Linear Support Vector Regressor
else:
raise NotImplementedError(
f"No regressor type defined for regressor_string = {regressor_string}"
)
def train_model(self, train_data, preprocess=True):
"""
Fit self.regressor and preprocessors. train_data is a pl.DataFrame
"""
if preprocess:
# Compute the mean and std of half-lives per structure
train_data = self.preprocess_data(train_data)
# train_data structure:
# columns = [
# "structure_id",
# "smiles",
# "dt50_log",
# "dt50_bayesian_mean",
# "dt50_bayesian_std",
# ] + self.descriptors.get_descriptor_names()
# only select descriptor features for feature preselector
df = train_data[self.descriptors.get_descriptor_names()]
# Remove columns having at least None, nan, inf, "" value
df = Pepper.keep_clean_columns(df)
# Scale and Remove highly correlated features as well as features having a low variance
x_train_normal = self.min_max_scaler.fit_transform(df)
x_train_normal = self.feature_preselector.fit_transform(x_train_normal)
# Store subset, as this is the input used for prediction
self.descriptor_subset = x_train_normal.columns
y_train = train_data["dt50_bayesian_mean"].to_numpy()
y_train_std = train_data["dt50_bayesian_std"].to_numpy()
self.regressor.set_params(alpha=y_train_std)
self.regressor.fit(x_train_normal, y_train)
return self, train_data
@staticmethod
def keep_clean_columns(df: pl.DataFrame) -> pl.DataFrame:
"""
Filters out columns from the DataFrame that contain null values, NaN, or infinite values.
This static method takes a DataFrame as input and evaluates each of its columns to determine
if the column contains invalid values. Columns that have null values, NaN, or infinite values
are excluded from the resulting DataFrame. The method is especially useful for cleaning up a
dataset by keeping only the valid columns.
Parameters:
df (polars.DataFrame): The input DataFrame to be cleaned.
Returns:
polars.DataFrame: A DataFrame containing only columns without null, NaN, or infinite values.
"""
valid_cols = []
for col in df.columns:
s = df[col]
# Check nulls
has_null = s.null_count() > 0
# Check NaN and inf only for numeric columns
if s.dtype.is_numeric():
has_nan = s.is_nan().any()
has_inf = s.is_infinite().any()
else:
has_nan = False
has_inf = False
if not (has_null or has_nan or has_inf):
valid_cols.append(col)
return df.select(valid_cols)
def preprocess_data(self, dataset):
groups = [group for group in dataset.group_by("structure_id")]
# Unless explicitly set compute everything serial
n_threads = int(os.environ.get("N_PEPPER_THREADS", 1))
if n_threads > 1:
results = Parallel(n_jobs=n_threads)(
delayed(compute_bayes_per_group)(group[1])
for group in dataset.group_by("structure_id")
)
else:
results = []
for g in groups:
results.append(compute_bayes_per_group(g[1]))
bayes_stats = pl.concat(results, how="vertical")
dataset = dataset.join(bayes_stats, on="structure_id", how="left")
# Remove duplicates after calculating mean, std
dataset = dataset.unique(subset="structure_id")
# Calculate and normalise features, make a "desc" column with the features
dataset = dataset.with_columns(
pl.col("smiles")
.map_elements(
self.descriptors.get_molecule_descriptors, return_dtype=pl.List(pl.Float64)
)
.alias("desc")
)
# If a SMILES fails to get desc it is removed
dataset = dataset.filter(pl.col("desc").is_not_null() & (pl.col("desc").list.len() > 0))
# Flatten the features into the dataset
dataset = dataset.with_columns(
pl.col("desc").list.to_struct(fields=self.descriptors.get_descriptor_names())
).unnest("desc")
return dataset
def predict_batch(self, batch: List[str], is_smiles: bool = True) -> List[List[float | None]]:
if is_smiles:
rows = [self.descriptors.get_molecule_descriptors(smiles) for smiles in batch]
else:
rows = batch
# Create Dataframe with all descriptors
initial_desc_rows_df = pl.DataFrame(
data=rows, schema=self.descriptors.get_descriptor_names(), orient="row"
)
# Before checking for invalid values per row, select only required columns
initial_desc_rows_df = initial_desc_rows_df.select(
list(self.min_max_scaler.feature_names_in_)
)
to_pad = []
adjusted_rows = []
for i, row in enumerate(initial_desc_rows_df.rows()):
# neither infs nor nans are found -> rows seems to be valid input
if row and not any(math.isinf(x) for x in row) and not any(math.isnan(x) for x in row):
adjusted_rows.append(row)
else:
to_pad.append(i)
if adjusted_rows:
desc_rows_df = pl.DataFrame(
data=adjusted_rows, schema=list(self.min_max_scaler.feature_names_in_), orient="row"
)
x_normal = self.min_max_scaler.transform(desc_rows_df)
x_normal = x_normal[self.descriptor_subset]
res = self.regressor.predict(x_normal, return_std=True)
# Convert to lists
res = [list(res[0]), list(res[1])]
# If we had rows containing bad input (inf, nan) insert Nones at the correct position
if to_pad:
for i in to_pad:
res[0].insert(i, None)
res[1].insert(i, None)
return res
else:
return [[None] * len(batch), [None] * len(batch)]
@staticmethod
def remove_highly_correlated_features(
X_train,
corr_method: str = "spearman",
cluster_threshold: float = 0.01,
ignore=False,
):
if ignore:
return X_train
# pass
else:
# Using spearmanr from scipy to achieve pandas.corr in polars
corr = spearmanr(X_train, axis=0).statistic
# Ensure the correlation matrix is symmetric
corr = (corr + corr.T) / 2
np.fill_diagonal(corr, 1)
corr = np.nan_to_num(corr)
# code from https://scikit-learn.org/stable/auto_examples/inspection/
# plot_permutation_importance_multicollinear.html
# We convert the correlation matrix to a distance matrix before performing
# hierarchical clustering using Ward's linkage.
distance_matrix = 1 - np.abs(corr)
dist_linkage = hierarchy.ward(squareform(distance_matrix))
cluster_ids = hierarchy.fcluster(dist_linkage, cluster_threshold, criterion="distance")
cluster_id_to_feature_ids = defaultdict(list)
for idx, cluster_id in enumerate(cluster_ids):
cluster_id_to_feature_ids[cluster_id].append(idx)
my_selected_features = [v[0] for v in cluster_id_to_feature_ids.values()]
X_train_sel = X_train[:, my_selected_features]
return X_train_sel
def save_model(self, path):
with open(path, "wb") as save_file:
pickle.dump(self, save_file, protocol=5)
@staticmethod
def load_model(path) -> "Pepper":
with open(path, "rb") as load_file:
return pickle.load(load_file)
def compute_bayes_per_group(group):
"""Get mean and std using bayesian"""
mean, std = Bayesian(group["dt50_log"]).get_posterior_distribution()
return pl.DataFrame(
{
"structure_id": [group["structure_id"][0]],
"dt50_bayesian_mean": [mean],
"dt50_bayesian_std": [std],
}
)

View File

@ -31,12 +31,14 @@ dependencies = [
"setuptools>=80.8.0",
"nh3==0.3.2",
"polars==1.35.1",
"whitenoise>=6.12.0",
"xmlschema>=3.0.0",
]
[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-additional-information = { git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git", rev = "v0.4.2" }
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" }
[project.optional-dependencies]
@ -51,7 +53,13 @@ dev = [
"pytest-django>=4.11.1",
"pytest-cov>=7.0.0",
]
pepper-plugin = [
"matplotlib>=3.10.8",
"pyyaml>=6.0.3",
"emcee>=3.1.6",
"mordredcommunity==2.0.7",
"padelpy" # Remove once we're certain we'll go with mordred
]
[tool.ruff]
line-length = 100
@ -133,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" }
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "envipath.settings"
addopts = "--verbose --capture=no --durations=10"
testpaths = ["tests", "*/tests"]
pythonpath = ["."]
@ -149,4 +158,5 @@ markers = [
"frontend: Frontend tests",
"end2end: End-to-end tests",
"slow: Slow tests",
"iuclid: IUCLID i6z export tests",
]

View File

@ -88,6 +88,7 @@ document.addEventListener("alpine:init", () => {
options.debugErrors ??
(typeof window !== "undefined" &&
window.location?.search?.includes("debugErrors=1")),
attach_object: options.attach_object || null,
async init() {
if (options.schemaUrl) {

View File

@ -161,8 +161,18 @@ document.addEventListener("alpine:init", () => {
set value(v) {
this.data[this.fieldName] = v;
},
get multiple() {
return !!(this.fieldSchema.items && this.fieldSchema.items.enum);
},
get options() {
return this.fieldSchema.enum || [];
if (this.fieldSchema.enum) {
return this.fieldSchema.enum;
} else if (this.fieldSchema.items && this.fieldSchema.items.enum) {
return this.fieldSchema.items.enum;
} else {
return [];
}
},
}),
);

View File

@ -453,6 +453,29 @@ function draw(pathway, elem) {
}
}
if (predictedPropertyViewEnabled) {
var tempContent = "";
if (Object.keys(n.predicted_properties).length > 0) {
if ("PepperPrediction" in n.predicted_properties) {
// TODO needs to be generic once we store it as AddInf
for (var s of n.predicted_properties["PepperPrediction"]) {
if (s["mean"] != null) {
tempContent += "<b>DT50 predicted via Pepper:</b> " + s["mean"].toFixed(2) + "<br>"
}
}
}
}
if (tempContent === "") {
tempContent = "<b>No predicted properties for this Node</b><br>";
}
popupContent += tempContent
}
popupContent += "<img src='" + n.image + "'><br>"
if (n.scenarios.length > 0) {
popupContent += '<b>Half-lives and related scenarios:</b><br>'
@ -473,7 +496,6 @@ function draw(pathway, elem) {
popupContent = "<a href='" + e.url + "'>" + e.name + "</a><br><br>";
if (e.reaction.rules) {
console.log(e.reaction.rules);
for (var rule of e.reaction.rules) {
popupContent += "Rule <a href='" + rule.url + "'>" + rule.name + "</a><br>";
}
@ -615,44 +637,56 @@ function draw(pathway, elem) {
node.filter(d => !d.pseudo).each(function (d, i) {
const g = d3.select(this);
// Parse the SVG string
const parser = new DOMParser();
const svgDoc = parser.parseFromString(d.image_svg, "image/svg+xml");
const svgElem = svgDoc.documentElement;
if (d.image_type === "svg") {
// Parse the SVG string
const parser = new DOMParser();
const svgDoc = parser.parseFromString(d.image_svg, "image/svg+xml");
const svgElem = svgDoc.documentElement;
// Create a unique prefix per node
const prefix = `node-${i}-`;
// Create a unique prefix per node
const prefix = `node-${i}-`;
// Rename all IDs and fix <use> references
svgElem.querySelectorAll('[id]').forEach(el => {
const oldId = el.id;
const newId = prefix + oldId;
el.id = newId;
// Rename all IDs and fix <use> references
svgElem.querySelectorAll("[id]").forEach(el => {
const oldId = el.id;
const newId = prefix + oldId;
el.id = newId;
const XLINK_NS = "http://www.w3.org/1999/xlink";
// Update <use> elements that reference this old ID
const uses = Array.from(svgElem.querySelectorAll('use')).filter(
u => u.getAttributeNS(XLINK_NS, 'href') === `#${oldId}`
);
const XLINK_NS = "http://www.w3.org/1999/xlink";
// Update <use> elements that reference this old ID
const uses = Array.from(svgElem.querySelectorAll("use")).filter(
u => u.getAttributeNS(XLINK_NS, "href") === `#${oldId}`
);
uses.forEach(u => {
u.setAttributeNS(XLINK_NS, 'href', `#${newId}`);
uses.forEach(u => {
u.setAttributeNS(XLINK_NS, "href", `#${newId}`);
});
});
});
g.node().appendChild(svgElem);
g.node().appendChild(svgElem);
const vb = svgElem.viewBox.baseVal;
const svgWidth = vb.width || 40;
const svgHeight = vb.height || 40;
const vb = svgElem.viewBox.baseVal;
const svgWidth = vb.width || 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

View File

@ -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>

View File

@ -1,3 +1,5 @@
{% load envipytags %}
{% if meta.can_edit %}
<li>
<a
@ -15,6 +17,11 @@
<i class="glyphicon glyphicon-plus"></i> Add Reaction</a
>
</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>
{% endif %}
<li>
@ -41,6 +48,14 @@
<i class="glyphicon glyphicon-floppy-save"></i> Download Pathway as Image</a
>
</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>
<a
role="button"

View File

@ -9,6 +9,39 @@
<input type="hidden" name="job-name" value="batch-predict" />
<fieldset class="flex flex-col gap-4 md:flex-3/4">
<!-- CSV Upload Section -->
<div class="mb-6 rounded-lg border-2 border-dashed border-base-300 p-6">
<div class="flex flex-col gap-4">
<div
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="flex-1">
<h3 class="text-base font-medium text-base-content mb-1">
Load from CSV
</h3>
<p class="text-sm text-base-content/70">
Upload a CSV file with SMILES and name columns, or insert
manually in the table below
</p>
</div>
<div class="flex-shrink-0">
<input
type="file"
id="csv-file"
accept=".csv,.txt"
class="file-input file-input-bordered file-input-sm w-full sm:w-auto"
/>
</div>
</div>
<div
class="text-xs text-base-content/50 border-t border-base-300 pt-3"
>
<strong>Format:</strong> First column = SMILES, Second column =
Name (headers optional) • Maximum 30 rows
</div>
</div>
</div>
<table class="table table-zebra w-full">
<thead>
<tr>
@ -113,10 +146,16 @@
<script>
const tableBody = document.getElementById("smiles-table-body");
const addRowBtn = document.getElementById("add-row-btn");
const csvFileInput = document.getElementById("csv-file");
const form = document.getElementById("smiles-form");
const hiddenField = document.getElementById("substrates");
addRowBtn.addEventListener("click", () => {
// Function to create a new table row
function createTableRow(
smilesValue = "",
nameValue = "",
placeholder = true,
) {
const row = document.createElement("tr");
const tdSmiles = document.createElement("td");
@ -125,19 +164,147 @@
const smilesInput = document.createElement("input");
smilesInput.type = "text";
smilesInput.className = "input input-bordered w-full smiles-input";
smilesInput.placeholder = "SMILES";
smilesInput.placeholder = placeholder ? "SMILES" : "";
smilesInput.value = smilesValue;
const nameInput = document.createElement("input");
nameInput.type = "text";
nameInput.className = "input input-bordered w-full name-input";
nameInput.placeholder = "Name";
nameInput.placeholder = placeholder ? "Name" : "";
nameInput.value = nameValue;
tdSmiles.appendChild(smilesInput);
tdName.appendChild(nameInput);
const smilesLabel = document.createElement("label");
smilesLabel.appendChild(smilesInput);
tdSmiles.appendChild(smilesLabel);
const nameLabel = document.createElement("label");
nameLabel.appendChild(nameInput);
tdName.appendChild(nameLabel);
row.appendChild(tdSmiles);
row.appendChild(tdName);
return row;
}
// Function to clear the table
function clearTable() {
tableBody.innerHTML = "";
}
// Function to populate table from CSV data
function populateTableFromCSV(csvData) {
const lines = csvData.trim().split("\n");
const maxRows = 30;
// Clear existing table
clearTable();
// Skip header row if it looks like headers
const startIndex =
lines.length > 0 &&
(lines[0].toLowerCase().includes("smiles") ||
lines[0].toLowerCase().includes("name"))
? 1
: 0;
let rowCount = 0;
for (let i = startIndex; i < lines.length && rowCount < maxRows; i++) {
const line = lines[i].trim();
if (!line) continue;
// Parse CSV line - split by comma, first part is SMILES, rest is name
const firstCommaIndex = line.indexOf(",");
let smiles = "";
let name = "";
if (firstCommaIndex === -1) {
// No comma found, treat entire line as SMILES
smiles = line.trim().replace(/^"(.*)"$/, "$1");
} else {
// Split at first comma only
smiles = line
.substring(0, firstCommaIndex)
.trim()
.replace(/^"(.*)"$/, "$1");
name = line
.substring(firstCommaIndex + 1)
.trim()
.replace(/^"(.*)"$/, "$1");
}
// Skip empty rows
if (!smiles && !name) continue;
const row = createTableRow(smiles, name, false);
tableBody.appendChild(row);
rowCount++;
}
// Add at least one empty row if no data was loaded
if (rowCount === 0) {
const row = createTableRow();
tableBody.appendChild(row);
}
// Show success message
if (rowCount > 0) {
const message =
rowCount >= maxRows
? `Loaded ${rowCount} rows (maximum reached)`
: `Loaded ${rowCount} rows from CSV`;
// Create temporary success notification
const notification = document.createElement("div");
notification.className = "alert alert-success mb-4";
notification.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>${message}</span>
`;
// Insert notification before the table
const tableContainer = document.querySelector("table").parentNode;
tableContainer.insertBefore(
notification,
document.querySelector("table"),
);
// Remove notification after 3 seconds
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, 3000);
}
}
// Handle CSV file selection
csvFileInput.addEventListener("change", function (event) {
const file = event.target.files[0];
if (!file) return;
// Check file type
if (!file.name.match(/\.(csv|txt)$/i)) {
alert("Please select a CSV or TXT file.");
return;
}
const reader = new FileReader();
reader.onload = function (e) {
try {
populateTableFromCSV(e.target.result);
} catch (error) {
console.error("Error parsing CSV:", error);
alert("Error parsing CSV file. Please check the file format.");
}
};
reader.readAsText(file);
});
// Handle add row button
addRowBtn.addEventListener("click", () => {
const row = createTableRow();
tableBody.appendChild(row);
});
@ -154,7 +321,7 @@
const smiles = smilesInputs[i].value.trim();
const name = nameInputs[i]?.value.trim() ?? "";
// Skip emtpy rows
// Skip empty rows
if (!smiles && !name) {
continue;
}

View File

@ -1,21 +1,34 @@
{% extends "collections/paginated_base.html" %}
{% load envipytags %}
{% block page_title %}Compounds{% endblock %}
{% block action_button %}
{% if meta.can_edit %}
<button
type="button"
class="btn btn-primary btn-sm"
onclick="document.getElementById('new_compound_modal').showModal(); return false;"
>
New Compound
</button>
{% endif %}
<div class="flex items-center gap-2">
{% if meta.can_edit %}
<button
type="button"
class="btn btn-primary btn-sm"
onclick="document.getElementById('new_compound_modal').showModal(); return false;"
>
New Compound
</button>
{% endif %}
{% epdb_slot_templates "epdb.actions.collections.compound" as action_button_templates %}
{% for tpl in action_button_templates %}
{% include tpl %}
{% endfor %}
</div>
{% endblock action_button %}
{% block action_modals %}
{% 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 %}
{% block description %}

Some files were not shown because too many files have changed in this diff Show More