28 Commits

Author SHA1 Message Date
3b5d299128 API PES
Some checks failed
CI / test (pull_request) Failing after 14s
API CI / api-tests (pull_request) Failing after 20s
2026-05-12 13:16:39 +02:00
38e901a51e PW interactions 2026-05-12 13:16:39 +02:00
c92fccaf8e minor 2026-05-12 13:16:39 +02:00
5eb3ebac89 Wip 2026-05-12 13:16:39 +02:00
d9530ce755 adjusted migration
Initial bayer app

Show Pack Classification

Adjusted docker compose to bayer specifics

Adjusted Dockerfile for Bayer

Adding secret flags to group, add secret pools to packages

Adjusted View for Package creation

Prep configs, added Package Create Modal

wip

More on PES

wip

wip
2026-05-12 13:16:39 +02:00
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
164 changed files with 71687 additions and 4427 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= ALLOWED_HOSTS=
DEBUG= DEBUG=
LOG_LEVEL= LOG_LEVEL=
MODEL_BUILDING_ENABLED=
APPLICABILITY_DOMAIN_ENABLED=
ENVIFORMER_PRESENT= ENVIFORMER_PRESENT=
FLAG_CELERY_PRESENT= ENVIFORMER_DEVICE=
SERVER_URL=
PLUGINS_ENABLED= PLUGINS_ENABLED=
SERVER_URL=
SERVER_PATH=
ADMIN_APPROVAL_REQUIRED=
REGISTRATION_MANDATORY=
LOG_DIR=
# Celery
FLAG_CELERY_PRESENT=
CELERY_BROKER_URL=
CELERY_RESULT_BACKEND=
# DB # DB
POSTGRES_SERVICE_NAME= POSTGRES_SERVICE_NAME=
POSTGRES_DB= POSTGRES_DB=
@ -16,5 +26,30 @@ POSTGRES_PORT=
# MAIL # MAIL
EMAIL_HOST_USER= EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD= EMAIL_HOST_PASSWORD=
# MATOMO DEFAULT_FROM_EMAIL=
MATOMO_SITE_ID SERVER_EMAIL=
# SENTRY
SENTRY_ENABLED=
SENTRY_DSN=
SENTRY_ENVIRONMENT=
# MS ENTRA
MS_ENTRA_ENABLED=
MS_CLIENT_ID=
MS_CLIENT_SECRET=
MS_TENANT_ID=
MS_REDIRECT_URI=
MS_SCOPES=
# Tenant
TENANT=
EPDB_PACKAGE_MODEL=
# Captcha
CAP_ENABLED=
CAP_API_BASE=
CAP_SITE_KEY=
CAP_SECRET_KEY=
# QUARKUS (JAVA)
ENVIRULE_ENABLED=
FINGERPRINT_URL=
# Biotransformer
BIOTRANSFORMER_ENABLED=
BIOTRANSFORMER_URL=

View File

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

100
Dockerfile Normal file
View File

@ -0,0 +1,100 @@
FROM python:3.12-slim AS builder
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
UV_LINK_MODE=copy
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
curl \
openssh-client \
git \
nodejs \
npm \
&& rm -rf /var/lib/apt/lists/*
# Install pnpm
RUN npm install -g pnpm
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
ENV PATH="/root/.local/bin:${PATH}"
# Install dependencies first (cached layer — only invalidated when lockfile changes)
COPY pyproject.toml uv.lock ./
# Add key from git.envipath.com to known_hosts
RUN mkdir -p -m 0700 /root/.ssh \
&& ssh-keyscan git.envipath.com >> /root/.ssh/known_hosts
# We'll need access to private repos, let docker make use of host ssh agent and use it like:
# docker build --ssh default -t envipath/envipy-bayer:1.0 .
RUN --mount=type=ssh \
uv sync --locked --extra ms-login --extra pepper-plugin
# Now copy source and do a final sync to install the project itself
# Ensure .dockerignore is reasonable
COPY bb4g bb4g
COPY biotransformer biotransformer
COPY bayer bayer
COPY bridge bridge
COPY envipath envipath
COPY epapi epapi
COPY epauth epauth
COPY epdb epdb
COPY epiuclid epiuclid
COPY fixtures fixtures
COPY migration migration
COPY pepper pepper
COPY scripts scripts
COPY static static
COPY templates templates
COPY tests tests
COPY utilities utilities
COPY manage.py .
# Install frontend deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# Build frontend assets
RUN uv run python scripts/pnpm_wrapper.py install
RUN uv run python scripts/pnpm_wrapper.py run build
FROM python:3.12-slim AS production
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/app/.venv/bin:$PATH"
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
libxrender1 \
libxext6 \
libfontconfig1 \
nano \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -ms /bin/bash django
# Create directories in /opt and set ownership
RUN mkdir -p /opt/enviPy \
&& mkdir -p /opt/enviPy/celery \
&& mkdir -p /opt/enviPy/log \
&& mkdir -p /opt/enviPy/models \
&& mkdir -p /opt/enviPy/plugins \
&& mkdir -p /opt/enviPy/static \
&& chown -R django:django /opt/enviPy
COPY --from=builder --chown=django:django /app /app
RUN touch /app/.env && chown -R django:django /app/.env
USER django
EXPOSE 8000
CMD ["gunicorn", "envipath.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]

0
bayer/__init__.py Normal file
View File

19
bayer/admin.py Normal file
View File

@ -0,0 +1,19 @@
from django.contrib import admin
# Register your models here.
from .models import (
PESCompound,
PESStructure
)
class PESCompoundAdmin(admin.ModelAdmin):
pass
class PESStructureAdmin(admin.ModelAdmin):
pass
admin.site.register(PESCompound, PESCompoundAdmin)
admin.site.register(PESStructure, PESStructureAdmin)

6
bayer/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class BayerConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'bayer'

39
bayer/epdb_hooks.py Normal file
View File

@ -0,0 +1,39 @@
import logging
from epdb.template_registry import register_template
logger = logging.getLogger(__name__)
# PES Create
register_template(
"epdb.actions.collections.compound",
"actions/collections/new_pes.html",
)
register_template(
"modals.collections.compound",
"modals/collections/new_pes_modal.html",
)
register_template(
"epdb.actions.objects.pathway.add",
"actions/objects/pathway_add_pes.html",
)
register_template(
"epdb.modals.objects.pathway.add",
"modals/objects/add_pathway_pes_node_modal.html"
)
# PES Viz
register_template(
"epdb.objects.compound.viz",
"objects/compound_viz.html",
)
register_template(
"epdb.objects.compound_structure.viz",
"objects/compound_structure_viz.html",
)
register_template(
"epdb.objects.node.viz",
"objects/node_viz.html",
)

View File

@ -0,0 +1,35 @@
# Generated by Django 5.2.7 on 2026-03-06 10:51
import django.utils.timezone
import model_utils.fields
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Package',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('reviewed', models.BooleanField(default=False, verbose_name='Reviewstatus')),
('classification_level', models.IntegerField(choices=[(0, 'Internal'), (10, 'Restricted'), (20, 'Secret')], default=10)),
],
options={
'db_table': 'epdb_package',
},
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 5.2.7 on 2026-03-06 10:51
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('bayer', '0001_initial'),
('epdb', '0019_remove_scenario_additional_information_and_more'),
]
operations = [
migrations.AddField(
model_name='package',
name='license',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.license', verbose_name='License'),
),
]

View File

@ -0,0 +1,41 @@
# Generated by Django 6.0.3 on 2026-04-17 21:22
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bayer', '0002_initial'),
('epdb', '0023_alter_compoundstructure_options_and_more'),
]
operations = [
migrations.CreateModel(
name='PESCompound',
fields=[
('compound_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.compound')),
],
options={
'abstract': False,
},
bases=('epdb.compound',),
),
migrations.CreateModel(
name='PESStructure',
fields=[
('compoundstructure_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.compoundstructure')),
('pes_link', models.URLField(verbose_name='PES Link')),
],
options={
'abstract': False,
},
bases=('epdb.compoundstructure',),
),
migrations.AddField(
model_name='package',
name='data_pool',
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.group', verbose_name='Data pool'),
),
]

View File

236
bayer/models.py Normal file
View File

@ -0,0 +1,236 @@
from typing import List
import urllib.parse
import nh3
from django.conf import settings as s
from django.db import models, transaction
from django.db.models import QuerySet
from django.urls import reverse
from epdb.models import (
EnviPathModel,
Compound,
CompoundStructure,
ParallelRule,
SequentialRule,
SimpleAmbitRule,
SimpleRDKitRule,
)
from utilities.chem import FormatConverter
class Package(EnviPathModel):
reviewed = models.BooleanField(verbose_name="Reviewstatus", default=False)
license = models.ForeignKey(
"epdb.License", on_delete=models.SET_NULL, blank=True, null=True, verbose_name="License"
)
class Classification(models.IntegerChoices):
INTERNAL = 0, "Internal"
RESTRICTED = 10 , "Restricted"
SECRET = 20, "Secret"
classification_level = models.IntegerField(
choices=Classification,
default=Classification.RESTRICTED,
)
data_pool = models.ForeignKey("epdb.Group", on_delete=models.SET_NULL, blank=True, null=True,
verbose_name="Data pool", default=None)
def delete(self, *args, **kwargs):
# explicitly handle related Rules
for r in self.rules.all():
r.delete()
super().delete(*args, **kwargs)
def __str__(self):
return f"{self.name} (pk={self.pk})"
@property
def compounds(self) -> QuerySet:
return self.compound_set.all()
@property
def rules(self) -> QuerySet:
return self.rule_set.all()
@property
def reactions(self) -> QuerySet:
return self.reaction_set.all()
@property
def pathways(self) -> QuerySet:
return self.pathway_set.all()
@property
def scenarios(self) -> QuerySet:
return self.scenario_set.all()
@property
def models(self) -> QuerySet:
return self.epmodel_set.all()
def _url(self):
return "{}/package/{}".format(s.SERVER_URL, self.uuid)
def get_applicable_rules(self) -> List["Rule"]:
"""
Returns a ordered set of rules where the following applies:
1. All Composite will be added to result
2. All SimpleRules will be added if theres no CompositeRule present using the SimpleRule
Ordering is based on "url" field.
"""
rules = []
rule_qs = self.rules
reflected_simple_rules = set()
for r in rule_qs:
if isinstance(r, ParallelRule) or isinstance(r, SequentialRule):
rules.append(r)
for sr in r.simple_rules.all():
reflected_simple_rules.add(sr)
for r in rule_qs:
if isinstance(r, SimpleAmbitRule) or isinstance(r, SimpleRDKitRule):
if r not in reflected_simple_rules:
rules.append(r)
rules = sorted(rules, key=lambda x: x.url)
return rules
class Meta:
db_table = "epdb_package"
class PESCompound(Compound):
@staticmethod
@transaction.atomic
def create(
package: "Package", pes_data: dict, name: str = None, description: str = None, *args, **kwargs
) -> "Compound":
pes_url = pes_data["pes_url"]
# Check if we find a direct match for a given pes_link
if PESStructure.objects.filter(pes_link=pes_url, compound__package=package).exists():
# Due to normalization we might end up in having multiple structures
# All of them point to the same compound -> pick any
return PESStructure.objects.filter(pes_link=pes_url, compound__package=package).first().compound
# Generate Compound
c = PESCompound()
c.package = package
if name is not None:
# Clean for potential XSS
name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
if name is None or name == "":
name = f"Compound {Compound.objects.filter(package=package).count() + 1}"
c.name = name
# We have a default here only set the value if it carries some payload
if description is not None and description.strip() != "":
c.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
c.save()
molfile = pes_data.get("representativeStructures", [{}])[0].get("ctab")
if molfile is None:
raise ValueError("PES data does not contain a valid mol file!")
smiles = FormatConverter.to_smiles(FormatConverter.from_molfile(molfile))
standardized_smiles = FormatConverter.standardize(smiles, remove_stereo=True)
is_standardized = standardized_smiles == smiles
if not is_standardized:
_ = PESStructure.create(
c,
pes_url,
molfile,
standardized_smiles,
name="Normalized structure of {}".format(name),
description="{} (in its normalized form)".format(description),
normalized_structure=True,
)
cs = PESStructure.create(
c,
pes_url,
molfile,
smiles,
name=name,
description=description,
normalized_structure=is_standardized
)
c.default_structure = cs
c.save()
return c
class PESStructure(CompoundStructure):
pes_link = models.URLField(blank=False, null=False, verbose_name="PES Link")
@staticmethod
@transaction.atomic
def create(
compound: Compound,
pes_link: str,
mol_file: str,
smiles: str,
name: str = None,
description: str = None,
*args,
**kwargs
):
if compound.pk is None:
raise ValueError("Unpersisted Compound! Persist compound first!")
cs = PESStructure()
# Clean for potential XSS
if name is not None:
cs.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
if description is not None:
cs.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
cs.smiles = smiles
cs.mol_file = mol_file
cs.pes_link = pes_link
cs.compound = compound
if "normalized_structure" in kwargs:
cs.normalized_structure = kwargs["normalized_structure"]
cs.save()
return cs
@transaction.atomic
def add_structure(
self,
smiles: str,
name: str = None,
description: str = None,
default_structure: bool = False,
*args,
**kwargs,
) -> "CompoundStructure":
raise ValueError("Not supported!")
def d3_json(self):
return {
"is_pes": True,
"pes_link": self.pes_link,
# Will overwrite image from Node
"image": f"{reverse("depict_pes")}?pesLink={urllib.parse.quote(self.pes_link)}"
}

View File

@ -0,0 +1,9 @@
{% if meta.can_edit %}
<button
type="button"
class="btn btn-primary btn-sm"
onclick="document.getElementById('new_pes_modal').showModal(); return false;"
>
New PES
</button>
{% endif %}

View File

@ -0,0 +1,8 @@
<li>
<a
class="button"
onclick="document.getElementById('add_pathway_pes_node_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-plus"></i> Add PES</a
>
</li>

View File

@ -0,0 +1,175 @@
{% load static %}
<dialog
id="new_package_modal"
class="modal"
x-data="{
isSubmitting: false,
packageClassification: null,
reset() {
this.isSubmitting = false;
this.packageClassification = null;
},
setFormData(data) {
this.formData = data;
},
get isSecret() {
return this.packageClassification === '20';
},
submit(formId) {
const form = document.getElementById(formId);
// Remove previously injected inputs
form.querySelectorAll('.dynamic-param').forEach(el => el.remove());
// Add values from dynamic form into the html form
if (this.formData) {
Object.entries(this.formData).forEach(([key, value]) => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = key;
input.value = value;
input.classList.add('dynamic-param');
form.appendChild(input);
});
}
if (form && form.checkValidity()) {
this.isSubmitting = true;
form.submit();
} else if (form) {
form.reportValidity();
}
}
}"
@close="reset()"
>
<div class="modal-box max-w-3xl">
<!-- Header -->
<h3 class="text-lg font-bold">New Package</h3>
<!-- Close button (X) -->
<form method="dialog">
<button
class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2"
:disabled="isSubmitting"
>
</button>
</form>
<!-- Body -->
<div class="py-4">
<form
id="new_package_form"
accept-charset="UTF-8"
action=""
method="post"
>
{% csrf_token %}
<!-- Name -->
<div class="form-control mb-3">
<label class="label" for="package-name">
<span class="label-text">Name</span>
</label>
<input
id="package-name"
class="input input-bordered w-full"
name="package-name"
placeholder="Name"
required
/>
</div>
<!-- Description -->
<div class="form-control mb-3">
<label class="label" for="package-description">
<span class="label-text">Description</span>
</label>
<input
id="package-description"
type="text"
class="input input-bordered w-full"
placeholder="Description..."
name="package-description"
/>
</div>
<!-- Classification Level -->
<div class="form-control mb-3">
<label class="label" for="package-classification">
<span class="label-text">Package Classification</span>
</label>
<select
id="package-classification"
name="package-classification"
class="select select-bordered w-full"
x-model="packageClassification"
required
>
<option value="null" disabled selected>Select Classification</option>
<option value="0">Internal</option>
<option value="10">Restricted</option>
<option value="20">Secret</option>
</select>
</div>
<!-- Secret Groups -->
<div class="form-control mb-3" x-show="isSecret" x-cloak>
<label class="label" for="package-data-pool">
<span class="label-text">Data Pool for SECRET Package</span>
</label>
<p>Only users with this role can be granted access to this package</p>
<select
id="package-data-pool"
name="package-data-pool"
class="select select-bordered w-full"
>
<option value="" disabled selected>Select Data Pool</option>
{% for obj in meta.secret_groups %}
<option value="{{ obj.url }}">{{ obj.name|safe }}</option>
{% endfor %}
</select>
</div>
</form>
</div>
<!-- Footer -->
<div class="modal-action">
<button
type="button"
class="btn"
onclick="this.closest('dialog').close()"
:disabled="isSubmitting"
>
Cancel
</button>
<button
type="button"
class="btn btn-primary"
@click="submit('new_package_form')"
:disabled="isSubmitting || !selectedType || loadingSchemas"
>
<span x-show="!isSubmitting">Submit</span>
<span
x-show="isSubmitting"
class="loading loading-spinner loading-sm"
></span>
<span x-show="isSubmitting">Creating...</span>
</button>
</div>
</div>
<!-- Backdrop -->
<form method="dialog" class="modal-backdrop">
<button :disabled="isSubmitting">close</button>
</form>
</dialog>

View File

@ -0,0 +1,174 @@
{% load static %}
<dialog
id="new_pes_modal"
class="modal"
x-data="{
isSubmitting: false,
pesLink: null,
pesVizHtml: '',
reset() {
this.isSubmitting = false;
},
get isPESSet() {
console.log(this.pesLink);
return this.pesLink !== null;
},
updatePesViz() {
if (!this.isPESSet) {
this.pesVizHtml = '';
return;
}
const img = new Image();
img.src = '{% url 'depict_pes' %}?pesLink=' + encodeURIComponent(this.pesLink);
img.style.width = '100%';
img.style.height = '100%';
img.style.objectFit = 'cover';
img.onload = () => {
this.pesVizHtml = img.outerHTML;
};
img.onerror = () => {
this.pesVizHtml = `
<div class='alert alert-error' role='alert'>
<h4 class='alert-heading'>Could not render PES!</h4>
<p>Could not render PES - Do you have access?</p>
</div>`;
};
},
submit(formId) {
const form = document.getElementById(formId);
// Remove previously injected inputs
form.querySelectorAll('.dynamic-param').forEach(el => el.remove());
// Add values from dynamic form into the html form
if (this.formData) {
Object.entries(this.formData).forEach(([key, value]) => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = key;
input.value = value;
input.classList.add('dynamic-param');
form.appendChild(input);
});
}
if (form && form.checkValidity()) {
this.isSubmitting = true;
form.submit();
} else if (form) {
form.reportValidity();
}
}
}"
@close="reset()"
>
<div class="modal-box max-w-3xl">
<!-- Header -->
<h3 class="text-lg font-bold">New PES</h3>
<!-- Close button (X) -->
<form method="dialog">
<button
class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2"
:disabled="isSubmitting"
>
</button>
</form>
<!-- Body -->
<div class="py-4">
<form
id="new-pes-modal-form"
accept-charset="UTF-8"
action="{% url 'create pes' meta.current_package.uuid %}"
method="post"
>
{% csrf_token %}
<div class="form-control mb-3">
<label class="label" for="compound-name">
<span class="label-text">Name</span>
</label>
<input
id="compound-name"
class="input input-bordered w-full"
name="compound-name"
placeholder="Name"
required
/>
</div>
<div class="form-control mb-3">
<label class="label" for="compound-description">
<span class="label-text">Description</span>
</label>
<input
id="compound-description"
class="input input-bordered w-full"
name="compound-description"
placeholder="Description"
/>
</div>
<div class="form-control mb-3">
<label class="label" for="pes-link">
<span class="label-text">Link to PES</span>
</label>
<input
id="pes-link"
name="pes-link"
type="text"
class="input input-bordered w-full"
placeholder="Link to PES e.g. https://pesregapp-test.cropkey-np.ag/entities/PES-000126"
x-model="pesLink"
@input="updatePesViz()"
required
/>
</div>
<div id="pes-viz" class="mb-3" x-html="pesVizHtml"></div>
</form>
</div>
<!-- Footer -->
<div class="modal-action">
<button
type="button"
class="btn"
onclick="this.closest('dialog').close()"
:disabled="isSubmitting"
>
Close
</button>
<button
type="button"
class="btn btn-primary"
@click="submit('new-pes-modal-form')"
:disabled="isSubmitting"
>
<span x-show="!isSubmitting">Submit</span>
<span
x-show="isSubmitting"
class="loading loading-spinner loading-sm"
></span>
<span x-show="isSubmitting">Creating...</span>
</button>
</div>
</div>
<!-- Backdrop -->
<form method="dialog" class="modal-backdrop">
<button :disabled="isSubmitting">close</button>
</form>
</dialog>

View File

@ -0,0 +1,174 @@
{% load static %}
<dialog
id="add_pathway_pes_node_modal"
class="modal"
x-data="{
isSubmitting: false,
pesLink: null,
pesVizHtml: '',
reset() {
this.isSubmitting = false;
},
get isPESSet() {
console.log(this.pesLink);
return this.pesLink !== null;
},
updatePesViz() {
if (!this.isPESSet) {
this.pesVizHtml = '';
return;
}
const img = new Image();
img.src = '{% url 'depict_pes' %}?pesLink=' + encodeURIComponent(this.pesLink);
img.style.width = '100%';
img.style.height = '100%';
img.style.objectFit = 'cover';
img.onload = () => {
this.pesVizHtml = img.outerHTML;
};
img.onerror = () => {
this.pesVizHtml = `
<div class='alert alert-error' role='alert'>
<h4 class='alert-heading'>Could not render PES!</h4>
<p>Could not render PES - Do you have access?</p>
</div>`;
};
},
submit(formId) {
const form = document.getElementById(formId);
// Remove previously injected inputs
form.querySelectorAll('.dynamic-param').forEach(el => el.remove());
// Add values from dynamic form into the html form
if (this.formData) {
Object.entries(this.formData).forEach(([key, value]) => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = key;
input.value = value;
input.classList.add('dynamic-param');
form.appendChild(input);
});
}
if (form && form.checkValidity()) {
this.isSubmitting = true;
form.submit();
} else if (form) {
form.reportValidity();
}
}
}"
@close="reset()"
>
<div class="modal-box max-w-3xl">
<!-- Header -->
<h3 class="text-lg font-bold">New PES</h3>
<!-- Close button (X) -->
<form method="dialog">
<button
class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2"
:disabled="isSubmitting"
>
</button>
</form>
<!-- Body -->
<div class="py-4">
<form
id="new-pes-node-modal-form"
accept-charset="UTF-8"
action="{% url 'create pes node' current_object.package.uuid current_object.uuid %}"
method="post"
>
{% csrf_token %}
<div class="form-control mb-3">
<label class="label" for="compound-name">
<span class="label-text">Name</span>
</label>
<input
id="compound-name"
class="input input-bordered w-full"
name="compound-name"
placeholder="Name"
required
/>
</div>
<div class="form-control mb-3">
<label class="label" for="compound-description">
<span class="label-text">Description</span>
</label>
<input
id="compound-description"
class="input input-bordered w-full"
name="compound-description"
placeholder="Description"
/>
</div>
<div class="form-control mb-3">
<label class="label" for="pes-link">
<span class="label-text">Link to PES</span>
</label>
<input
id="pes-link"
name="pes-link"
type="text"
class="input input-bordered w-full"
placeholder="Link to PES e.g. https://pesregapp-test.cropkey-np.ag/entities/PES-000126"
x-model="pesLink"
@input="updatePesViz()"
required
/>
</div>
<div id="pes-viz" class="mb-3" x-html="pesVizHtml"></div>
</form>
</div>
<!-- Footer -->
<div class="modal-action">
<button
type="button"
class="btn"
onclick="this.closest('dialog').close()"
:disabled="isSubmitting"
>
Close
</button>
<button
type="button"
class="btn btn-primary"
@click="submit('new-pes-node-modal-form')"
:disabled="isSubmitting"
>
<span x-show="!isSubmitting">Submit</span>
<span
x-show="isSubmitting"
class="loading loading-spinner loading-sm"
></span>
<span x-show="isSubmitting">Creating...</span>
</button>
</div>
</div>
<!-- Backdrop -->
<form method="dialog" class="modal-backdrop">
<button :disabled="isSubmitting">close</button>
</form>
</dialog>

View File

@ -0,0 +1,19 @@
{% if compound_structure.pes_link %}
<!-- PES -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Link to PES</div>
<div class="collapse-content">{{ compound_structure.pes_link }}</div>
</div>
<!-- Image Representation -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">PES Image Representation</div>
<div class="collapse-content">
<div class="flex justify-center">
<img src='{% url 'depict_pes' %}?pesLink={{ compound_structure.pes_link|urlencode }}'/>
</div>
</div>
</div>
{% endif %}

View File

@ -0,0 +1,19 @@
{% if compound.default_structure.pes_link %}
<!-- PES -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Link to PES</div>
<div class="collapse-content">{{ compound.default_structure.pes_link }}</div>
</div>
<!-- Image Representation -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">PES Image Representation</div>
<div class="collapse-content">
<div class="flex justify-center">
<img src='{% url 'depict_pes' %}?pesLink={{ compound.default_structure.pes_link|urlencode }}'/>
</div>
</div>
</div>
{% endif %}

View File

@ -0,0 +1,19 @@
{% if node.default_node_label.pes_link %}
<!-- PES -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Link to PES</div>
<div class="collapse-content">{{ node.default_node_label.pes_link }}</div>
</div>
<!-- Image Representation -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">PES Image Representation</div>
<div class="collapse-content">
<div class="flex justify-center">
<img src='{% url 'depict_pes' %}?pesLink={{ node.default_node_label.pes_link|urlencode }}'/>
</div>
</div>
</div>
{% endif %}

View File

@ -0,0 +1,97 @@
{% extends "framework_modern.html" %}
{% load static %}
{% block content %}
{% block action_modals %}
{% include "modals/objects/edit_package_modal.html" %}
{% include "modals/objects/edit_package_permissions_modal.html" %}
{% include "modals/objects/publish_package_modal.html" %}
{% include "modals/objects/set_license_modal.html" %}
{% include "modals/objects/export_package_modal.html" %}
{% include "modals/objects/generic_delete_modal.html" %}
{% endblock action_modals %}
<div class="space-y-2 p-4">
<!-- Header Section -->
<div class="card bg-base-100">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-2xl">{{ package.name }} {% if meta.url_contains_package and meta.current_package.get_classification_level_display == "Restricted" %}<img src="{% static 'images/restricted_mid.png' %}" width="100">{% elif meta.url_contains_package and meta.current_package.get_classification_level_display == "Secret" %}<img src="{% static 'images/secret_mid.png' %}" width="60">{% endif %}</h2>
<div id="actionsButton" class="dropdown dropdown-e nd hidden">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-wrench"
>
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
/>
</svg>
Actions
</div>
<ul
tabindex="-1"
class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2"
>
{% block actions %}
{% include "actions/objects/package.html" %}
{% endblock %}
</ul>
</div>
</div>
<p class="mt-2">{{ package.description|safe }}</p>
<ul class="menu bg-base-200 rounded-box mt-4 w-full">
<li>
<a href="{{ package.url }}/pathway" class="hover:bg-base-300"
>Pathways ({{ package.pathways.count }})</a
>
</li>
<li>
<a href="{{ package.url }}/rule" class="hover:bg-base-300"
>Rules ({{ package.rules.count }})</a
>
</li>
<li>
<a href="{{ package.url }}/compound" class="hover:bg-base-300"
>Compounds ({{ package.compounds.count }})</a
>
</li>
<li>
<a href="{{ package.url }}/reaction" class="hover:bg-base-300"
>Reactions ({{ package.reactions.count }})</a
>
</li>
<li>
<a href="{{ package.url }}/model" class="hover:bg-base-300"
>Models ({{ package.models.count }})</a
>
</li>
<li>
<a href="{{ package.url }}/scenario" class="hover:bg-base-300"
>Scenarios ({{ package.scenarios.count }})</a
>
</li>
</ul>
</div>
</div>
</div>
<script>
// Show actions button if there are actions
document.addEventListener("DOMContentLoaded", function () {
const actionsButton = document.getElementById("actionsButton");
const actionsList = actionsButton?.querySelector("ul");
if (actionsList && actionsList.children.length > 0) {
actionsButton?.classList.remove("hidden");
}
});
</script>
{% endblock content %}

View File

@ -0,0 +1,154 @@
{% extends "static/login_base.html" %}
{% load static %}
{% block title %}enviPath - Sign In{% endblock %}
{% block extra_styles %}
<style>
/* Tab styling */
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
input[type="radio"].tab-radio {
display: none;
}
.tab-label {
cursor: pointer;
padding: 0.75rem 1.5rem;
border-bottom: 2px solid transparent;
transition: all 0.3s ease;
}
.tab-label:hover {
background-color: rgba(0, 0, 0, 0.05);
}
input[type="radio"].tab-radio:checked + .tab-label {
border-bottom-color: #3b82f6;
font-weight: 600;
}
</style>
{% endblock %}
{% block content %}
<div>
<img src="{% static 'images/bayer-logo.svg' %}">
</div>
<div class="flex flex-col space-y-4 ...">
<div><p></p></div>
<div><p></p></div>
</div>
<!-- Tab Navigation -->
<div class="border-base-300 mb-6 border-b" hidden>
<div class="flex justify-start">
<input
type="radio"
name="auth-tab"
id="tab-sso"
class="tab-radio"
checked
/>
<label for="tab-sso" class="tab-label">SSO</label>
<input
type="radio"
name="auth-tab"
id="tab-signin"
class="tab-radio"
/>
<label for="tab-signin" class="tab-label">Local User</label>
</div>
</div>
<!-- SSO Tab -->
<div id="content-sso" class="tab-content active">
<button role="link" onclick="window.location.href='/entra/login'" name="sso" class="btn btn-primary w-full">
Login with Microsoft
</button>
</div>
<!-- Sign In Tab -->
<div id="content-signin" class="tab-content">
<form method="post" action="{% url 'login' %}" class="space-y-4">
{% csrf_token %}
<input type="hidden" name="login" value="true" />
<div class="form-control">
<label class="label" for="username">
<span class="label-text">Account</span>
</label>
<input
type="text"
id="username"
name="username"
placeholder="Username or Email"
class="input input-bordered w-full"
required
autocomplete="username"
/>
</div>
<div class="form-control">
<label class="label" for="passwordinput">
<span class="label-text">Password</span>
</label>
<input
type="password"
id="passwordinput"
name="password"
placeholder="••••••••"
class="input input-bordered w-full"
required
autocomplete="current-password"
/>
</div>
<div class="text-right">
<a href="{% url 'password_reset' %}" class="link link-primary text-sm"
>Forgot password?</a
>
</div>
<input type="hidden" name="next" value="{{ next }}" />
<button type="submit" name="signin" class="btn btn-primary w-full">
Sign In
</button>
</form>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// Tab switching functionality
document.querySelectorAll('input[name="auth-tab"]').forEach((radio) => {
radio.addEventListener("change", function () {
// Hide all content
document.querySelectorAll(".tab-content").forEach((content) => {
content.classList.remove("active");
});
// Show selected content
const contentId = "content-" + this.id.replace("tab-", "");
document.getElementById(contentId).classList.add("active");
});
});
// Check for hash in URL to auto-select tab
window.addEventListener("DOMContentLoaded", function () {
const hash = window.location.hash.substring(1); // Remove the # symbol
if (hash === "signup" || hash === "signin") {
const tabRadio = document.getElementById("tab-" + hash);
if (tabRadio) {
tabRadio.checked = true;
// Trigger change event to show correct content
tabRadio.dispatchEvent(new Event("change"));
}
}
});
</script>
{% endblock %}

3
bayer/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

0
bayer/tests/__init__.py Normal file
View File

View File

174
bayer/tests/pes/test_pes.py Normal file

File diff suppressed because one or more lines are too long

19
bayer/urls.py Normal file
View File

@ -0,0 +1,19 @@
from django.urls import re_path
from . import views as v
UUID = "[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}"
urlpatterns = [
re_path(r"^depict_pes$", v.visualize_pes, name="depict_pes"),
re_path(
rf"^package/(?P<package_uuid>{UUID})/pes$",
v.create_pes,
name="create pes",
),
re_path(
rf"^package/(?P<package_uuid>{UUID})/pathway/(?P<pathway_uuid>{UUID})/pes$",
v.create_pes_node,
name="create pes node",
),
]

155
bayer/views.py Normal file
View File

@ -0,0 +1,155 @@
import base64
import requests
from django.conf import settings as s
from django.core.exceptions import BadRequest
from django.http import HttpResponse
from django.shortcuts import redirect
from bayer.models import PESCompound
from epdb.logic import PackageManager
from epdb.models import Pathway, Node
from epdb.views import _anonymous_or_real
from utilities.decorators import package_permission_required
Package = s.GET_PACKAGE_MODEL()
@package_permission_required()
def create_pes(request, package_uuid):
current_user = _anonymous_or_real(request)
current_package = PackageManager.get_package_by_id(current_user, package_uuid)
if request.method == "POST":
if current_package.classification_level == Package.Classification.INTERNAL:
raise BadRequest("Cannot create PESs for internal packages.")
compound_name = request.POST.get('compound-name')
compound_description = request.POST.get('compound-description')
pes_link = request.POST.get('pes-link')
if pes_link:
try:
pes_data = fetch_pes(request, pes_link)
except ValueError as e:
return BadRequest(f"Could not fetch PES data for {pes_link}")
classification = pes_data.get("classificationLevel", "")
if "secret" == classification.lower():
data_pools = pes_data.get("dataPools")
if data_pools:
if s.DATA_POOL_MAPPING[current_package.data_pool.name] not in data_pools:
return BadRequest(
f"PES data pool {s.DATA_POOL_MAPPING[current_package.data_pool.name]} not found in PES data")
pes = PESCompound.create(current_package, pes_data, compound_name, compound_description)
return redirect(pes.url)
else:
return BadRequest("Please provide a PES link.")
else:
pass
@package_permission_required()
def create_pes_node(request, package_uuid, pathway_uuid):
current_user = _anonymous_or_real(request)
current_package = PackageManager.get_package_by_id(current_user, package_uuid)
current_pathway = Pathway.objects.get(package=current_package, uuid=pathway_uuid)
if request.method == "POST":
if current_package.classification_level == Package.Classification.INTERNAL:
raise BadRequest("Cannot create PESs for internal packages.")
compound_name = request.POST.get('compound-name')
compound_description = request.POST.get('compound-description')
pes_link = request.POST.get('pes-link')
if pes_link:
try:
pes_data = fetch_pes(request, pes_link)
except ValueError as e:
return BadRequest(f"Could not fetch PES data for {pes_link}")
classification = pes_data.get("classificationLevel", "")
if "secret" == classification.lower():
data_pools = pes_data.get("dataPools")
if data_pools:
if s.DATA_POOL_MAPPING[current_package.data_pool.name] not in data_pools:
return BadRequest(
f"PES data pool {s.DATA_POOL_MAPPING[current_package.data_pool.name]} not found in PES data")
pes = PESCompound.create(current_package, pes_data, compound_name, compound_description)
n = Node()
n.stereo_removed = False
n.pathway = current_pathway
n.depth = 0
n.default_node_label = pes.default_structure
n.save()
n.node_labels.add(pes.default_structure)
n.save()
return redirect(current_pathway.url)
else:
return BadRequest("Please provide a PES link.")
else:
pass
def fetch_pes(request, pes_url) -> dict:
from epauth.views import get_access_token_from_request
token = get_access_token_from_request(request)
if token:
for k, v in s.PES_API_MAPPING.items():
if pes_url.startswith(k):
pes_id = pes_url.split('/')[-1]
if pes_id == 'dummy':
import json
res_data = json.load(open(s.BASE_DIR / "fixtures/pes.json"))
res_data["pes_url"] = pes_url
return res_data
else:
headers = {"Authorization": f"Bearer {token['access_token']}"}
params = {"pes_reg_entity_corporate_id": pes_id}
res = requests.get(v, headers=headers, params=params, proxies=s.PROXIES or None)
try:
res.raise_for_status()
pes_data = res.json()
if len(pes_data) == 0:
raise ValueError(f"PES with id {pes_id} not found")
res_data = pes_data[0]
res_data["pes_url"] = pes_url
return res_data
except requests.exceptions.HTTPError as e:
raise ValueError(f"Error fetching PES with id {pes_id}: {e}")
else:
raise ValueError(f"Unknown URL {pes_url}")
else:
raise ValueError("Could not fetch access token from request.")
def visualize_pes(request):
pes_link = request.GET.get('pesLink')
if pes_link:
pes_data = fetch_pes(request, pes_link)
representations = pes_data.get('representations')
for rep in representations:
if rep.get('type') == 'color':
image_data = base64.b64decode(rep.get('base64').replace("data:image/png;base64,", ""))
return HttpResponse(image_data, content_type="image/png")

183
bb4g/__init__.py Normal file
View File

@ -0,0 +1,183 @@
import json
import math
from datetime import datetime
from typing import List
import enum
import requests
from django.conf import settings as s
from envipy_additional_information import EnviPyModel, UIConfig, WidgetType
from envipy_additional_information import register
from bridge.contracts import Classifier # noqa: I001
from bridge.dto import (
BuildResult,
EnviPyDTO,
EvaluationResult,
RunResult,
TransformationProductPrediction,
) # noqa: I001
class SamplingAlgorithm(enum.Enum):
EXACT = "exact"
@register("bb4gconfig")
class BB4GConfig(EnviPyModel):
sampling_algorithm: SamplingAlgorithm = SamplingAlgorithm.EXACT
cutoff: int = -5
class UI:
title = "BB4G Configuration"
sampling_algorithm = UIConfig(
widget=WidgetType.SELECT,
label="BB4G Sampling Algorithm",
order=1,
placeholder="If unset defaults to 'exact'"
)
cutoff = UIConfig(
widget=WidgetType.NUMBER,
label="BB4G Cutoff",
order=2,
placeholder="If unset defaults to -5"
)
# Once stable these will be exposed by enviPy-plugins lib
class BB4G(Classifier):
Config = BB4GConfig
def __init__(self, config: BB4GConfig | None = None):
super().__init__(config)
self.url = f"{s.BB4G_URL}"
self.token = self.acquire_token()
self.header = {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
}
def acquire_token(self):
BB4G_TENANT_ID = s.BB4G_TENANT_ID
BB4G_CLIENT_ID = s.BB4G_CLIENT_ID
BB4G_CLIENT_SECRET = s.BB4G_CLIENT_SECRET
BB4G_SCOPE = s.BB4G_SCOPE
BB4G_TOKEN_URL = f"https://login.microsoftonline.com/{BB4G_TENANT_ID}/oauth2/v2.0/token"
payload = {
"client_id": BB4G_CLIENT_ID,
"client_secret": BB4G_CLIENT_SECRET,
"scope": BB4G_SCOPE,
"grant_type": "client_credentials"
}
# No Proxy required, URL is whitelisted
res = requests.post(BB4G_TOKEN_URL, data=payload)
res.raise_for_status()
return res.json()["access_token"]
def start(self):
header = {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
}
started = False
retries = 0
while not started and retries < 5:
res = requests.post(f"{self.url}/start", headers=header, data={}, proxies=s.PROXIES or None)
if res.status_code == 200:
started = True
elif res.status_code in [500, 502]:
retries += 1
import time
time.sleep(5)
else:
raise ValueError(f"Unexpected status code: {res.status_code}")
@classmethod
def requires_rule_packages(cls) -> bool:
return False
@classmethod
def requires_data_packages(cls) -> bool:
return False
@classmethod
def identifier(cls) -> str:
return "bb4g"
@classmethod
def name(cls) -> str:
return "BB4G Template Free Model"
@classmethod
def display(cls) -> str:
return "BB4G Template Free Model"
def build(self, eP: EnviPyDTO, *args, **kwargs) -> BuildResult | None:
return
def run(self, eP: EnviPyDTO, *args, **kwargs) -> RunResult:
# Ensure Service is running
self.start()
smiles = [c.smiles for c in eP.get_compounds()]
preds = self._post(smiles)
results = []
for substrate in preds.keys():
results.append(
TransformationProductPrediction(
substrate=substrate,
products=preds[substrate],
)
)
return RunResult(
producer=eP.get_context().url,
description=f"Generated at {datetime.now()}",
result=results,
)
def evaluate(self, eP: EnviPyDTO, *args, **kwargs) -> EvaluationResult:
pass
def build_and_evaluate(self, eP: EnviPyDTO, *args, **kwargs) -> EvaluationResult:
pass
def _post(self, smiles: List[str]) -> dict[str, dict[str, float]]:
header = {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
}
result = {}
for smi in smiles:
data = {
"smiles": smi,
"sampling_alg": self.config.sampling_algorithm.value,
"cutoff": self.config.cutoff,
}
resp = requests.post(f"{self.url}/compute", headers=header, data=json.dumps(data), proxies=s.PROXIES or None)
resp.raise_for_status()
for substrate, predictions in resp.json().items():
preds = {}
for pred in predictions:
prod = pred["prediction"]
prob = math.exp(pred["log_likelihood"])
preds[prod] = prob
result[substrate] = preds
return result

112
biotransformer/__init__.py Normal file
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"]

View File

@ -1,6 +1,8 @@
import enum import enum
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from envipy_additional_information import EnviPyModel
from .dto import BuildResult, EnviPyDTO, EvaluationResult, RunResult from .dto import BuildResult, EnviPyDTO, EvaluationResult, RunResult
@ -27,12 +29,14 @@ class Plugin(ABC):
""" """
@classmethod
@abstractmethod @abstractmethod
def identifier(self) -> str: def identifier(cls) -> str:
pass pass
@classmethod
@abstractmethod @abstractmethod
def name(self) -> str: def name(cls) -> str:
""" """
Represents an abstract method that provides a contract for implementing a method Represents an abstract method that provides a contract for implementing a method
to return a name as a string. Must be implemented in subclasses. to return a name as a string. Must be implemented in subclasses.
@ -46,8 +50,9 @@ class Plugin(ABC):
""" """
pass pass
@classmethod
@abstractmethod @abstractmethod
def display(self) -> str: def display(cls) -> str:
""" """
An abstract method that must be implemented by subclasses to display An abstract method that must be implemented by subclasses to display
specific information or behavior. The method ensures that all subclasses specific information or behavior. The method ensures that all subclasses
@ -64,8 +69,9 @@ class Plugin(ABC):
class Property(Plugin): class Property(Plugin):
@classmethod
@abstractmethod @abstractmethod
def requires_rule_packages(self) -> bool: def requires_rule_packages(cls) -> bool:
""" """
Defines an abstract method to determine whether rule packages are required. Defines an abstract method to determine whether rule packages are required.
@ -79,8 +85,9 @@ class Property(Plugin):
""" """
pass pass
@classmethod
@abstractmethod @abstractmethod
def requires_data_packages(self) -> bool: def requires_data_packages(cls) -> bool:
""" """
Defines an abstract method to determine whether data packages are required. Defines an abstract method to determine whether data packages are required.
@ -231,3 +238,171 @@ class Property(Plugin):
NotImplementedError: If the method is not implemented by a subclass. NotImplementedError: If the method is not implemented by a subclass.
""" """
pass pass
class Classifier(Plugin):
Config: type[EnviPyModel] | None = None
def __init__(self, config: EnviPyModel | None = None):
self.config = config
@classmethod
def has_config(cls) -> bool:
return cls.Config is not None
@classmethod
def parse_config(cls, data: dict | None = None) -> EnviPyModel | None:
if cls.Config is None:
return None
# remove empty strings a.k.a unset params to not overwrite defaults
cpy = {}
if data is not None:
for k, v in data.items():
if v != "":
cpy[k] = v
return cls.Config(**cpy)
@classmethod
def create(cls, data: dict | None = None):
return cls(cls.parse_config(data))
@classmethod
@abstractmethod
def requires_rule_packages(cls) -> bool:
"""
Defines an abstract method to determine whether rule packages are required.
This method should be implemented by subclasses to specify if they depend
on rule packages for their functioning.
Raises:
NotImplementedError: If the subclass has not implemented this method.
@return: A boolean indicating if rule packages are required.
"""
pass
@classmethod
@abstractmethod
def requires_data_packages(cls) -> bool:
"""
Defines an abstract method to determine whether data packages are required.
This method should be implemented by subclasses to specify if they depend
on data packages for their functioning.
Raises:
NotImplementedError: If the subclass has not implemented this method.
Returns:
bool: True if the service requires data packages, False otherwise.
"""
pass
@abstractmethod
def build(self, eP: EnviPyDTO, *args, **kwargs) -> BuildResult | None:
"""
Abstract method to prepare and construct a specific build process based on the provided
environment data transfer object (EnviPyDTO). This method should be implemented by
subclasses to handle the particular requirements of the environment.
Parameters:
eP : EnviPyDTO
The data transfer object containing environment details for the build process.
*args :
Additional positional arguments required for the build.
**kwargs :
Additional keyword arguments to offer flexibility and customization for
the build process.
Returns:
BuildResult | None
Returns a BuildResult instance if the build operation succeeds, else returns None.
Raises:
NotImplementedError
If the method is not implemented in a subclass.
"""
pass
@abstractmethod
def run(self, eP: EnviPyDTO, *args, **kwargs) -> RunResult:
"""
Represents an abstract base class for executing a generic process with
provided parameters and returning a standardized result.
Attributes:
None.
Methods:
run(eP, *args, **kwargs):
Executes a task with specified input parameters and optional
arguments, returning the outcome in the form of a RunResult object.
This is an abstract method and must be implemented in subclasses.
Raises:
NotImplementedError: If the subclass does not implement the abstract
method.
Parameters:
eP (EnviPyDTO): The primary object containing information or data required
for processing. Mandatory.
*args: Variable length argument list for additional positional arguments.
**kwargs: Arbitrary keyword arguments for additional options or settings.
Returns:
RunResult: A result object encapsulating the status, output, or details
of the process execution.
"""
pass
@abstractmethod
def evaluate(self, eP: EnviPyDTO, *args, **kwargs) -> EvaluationResult | None:
"""
Abstract method for evaluating data based on the given input and additional arguments.
This method is intended to be implemented by subclasses and provides
a mechanism to perform an evaluation procedure based on input encapsulated
in an EnviPyDTO object.
Parameters:
eP : EnviPyDTO
The data transfer object containing necessary input for evaluation.
*args : tuple
Additional positional arguments for the evaluation process.
**kwargs : dict
Additional keyword arguments for the evaluation process.
Returns:
EvaluationResult
The result of the evaluation performed by the method.
Raises:
NotImplementedError
If the method is not implemented in the subclass.
"""
pass
@abstractmethod
def build_and_evaluate(self, eP: EnviPyDTO, *args, **kwargs) -> EvaluationResult | None:
"""
An abstract method designed to build and evaluate a model or system using the provided
environmental parameters and additional optional arguments.
Args:
eP (EnviPyDTO): The environmental parameters required for building and evaluating.
*args: Additional positional arguments.
**kwargs: Additional keyword arguments.
Returns:
EvaluationResult: The result of the evaluation process.
Raises:
NotImplementedError: If the method is not implemented by a subclass.
"""
pass

View File

@ -59,10 +59,19 @@ class EnviPyDTO(Protocol):
) -> List["ProductSet"]: ... ) -> List["ProductSet"]: ...
class PredictedProperty(EnviPyModel): class EnviPyPrediction(EnviPyModel):
pass pass
class PropertyPrediction(EnviPyPrediction):
pass
class TransformationProductPrediction(EnviPyPrediction):
substrate: str
products: dict[str, float]
@register("buildresult") @register("buildresult")
class BuildResult(EnviPyModel): class BuildResult(EnviPyModel):
data: dict[str, Any] | List[dict[str, Any]] | None data: dict[str, Any] | List[dict[str, Any]] | None
@ -72,7 +81,7 @@ class BuildResult(EnviPyModel):
class RunResult(EnviPyModel): class RunResult(EnviPyModel):
producer: HttpUrl producer: HttpUrl
description: Optional[str] = None description: Optional[str] = None
result: PredictedProperty | List[PredictedProperty] result: EnviPyPrediction | List[EnviPyPrediction]
@register("evaluationresult") @register("evaluationresult")

View File

@ -1,15 +1,15 @@
services: services:
db: db:
image: postgres:18 image: postgres:18
container_name: envipath-postgres container_name: eppostgres
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: envipath POSTGRES_DB: ${POSTGRES_DB}
ports: ports:
- "5432:5432" - "5432:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql - ep_bayer_postgres_data:/var/lib/postgresql
healthcheck: healthcheck:
test: [ "CMD-SHELL", "pg_isready -U postgres" ] test: [ "CMD-SHELL", "pg_isready -U postgres" ]
interval: 5s interval: 5s
@ -18,9 +18,37 @@ services:
redis: redis:
image: redis:7-alpine image: redis:7-alpine
container_name: envipath-redis container_name: epredis
ports: ports:
- "6379:6379" - "6379:6379"
volumes:
- ep_bayer_redis_data:/data
biotransformer3:
image: envipath/biotransformer3:1.0
container_name: epbiotransformer3
# web:
# image: envipath/envipy-bayer:1.0
# container_name: epdjango
# ports:
# - "127.0.0.1:8000:8000"
# env_file:
# - .env
# command: gunicorn envipath.wsgi:application --bind 0.0.0.0:8000 --workers 3
# volumes:
# - ep_bayer_data:/opt/enviPy/
celery_worker:
image: envipath/envipy-bayer:1.0
container_name: epcelery
env_file:
- .env.dev
command: celery -A envipath worker --concurrency=6 -Q model,predict,background --pool threads
volumes:
- ep_bayer_data:/opt/enviPy/
volumes: volumes:
postgres_data: ep_bayer_postgres_data:
ep_bayer_redis_data:
ep_bayer_data:

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_bayer_postgres_data:/var/lib/postgresql
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U postgres" ]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: epredis
volumes:
- ep_bayer_redis_data:/data
biotransformer3:
image: envipath/biotransformer3:1.0
container_name: epbiotransformer3
web:
image: envipath/envipy-bayer:1.0
container_name: epdjango
ports:
- "127.0.0.1:8000:8000"
env_file:
- .env
command: gunicorn envipath.wsgi:application --bind 0.0.0.0:8000 --workers 3
volumes:
- ep_bayer_data:/opt/enviPy/
celery_worker:
image: envipath/envipy-bayer:1.0
container_name: epcelery
env_file:
- .env
command: celery -A envipath worker --concurrency=6 -Q model,predict,background --pool threads
volumes:
- ep_bayer_data:/opt/enviPy/
volumes:
ep_bayer_postgres_data:
ep_bayer_redis_data:
ep_bayer_data:

View File

@ -9,7 +9,7 @@ https://docs.djangoproject.com/en/4.2/topics/settings/
For the full list of settings and their values, see For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.2/ref/settings/ https://docs.djangoproject.com/en/4.2/ref/settings/
""" """
import json
import os import os
from pathlib import Path from pathlib import Path
@ -20,7 +20,7 @@ from sklearn.tree import DecisionTreeClassifier
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
ENV_PATH = os.environ.get("ENV_PATH", BASE_DIR / ".env") ENV_PATH = os.environ.get("ENV_PATH", BASE_DIR / ".env.dev")
print(f"Loading env from {ENV_PATH}") print(f"Loading env from {ENV_PATH}")
load_dotenv(ENV_PATH, override=False) load_dotenv(ENV_PATH, override=False)
@ -37,7 +37,6 @@ ALLOWED_HOSTS = os.environ["ALLOWED_HOSTS"].split(",")
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
"django.contrib.admin", "django.contrib.admin",
"django.contrib.auth", "django.contrib.auth",
@ -45,6 +44,7 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"django.contrib.postgres",
# 3rd party # 3rd party
"django_extensions", "django_extensions",
"oauth2_provider", "oauth2_provider",
@ -74,6 +74,7 @@ AUTHENTICATION_BACKENDS = [
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
@ -92,10 +93,19 @@ if os.environ.get("REGISTRATION_MANDATORY", False) == "True":
ROOT_URLCONF = "envipath.urls" 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 = [ TEMPLATES = [
{ {
"BACKEND": "django.template.backends.django.DjangoTemplates", "BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": (os.path.join(BASE_DIR, "templates"),), "DIRS": TEMPLATE_DIRS,
"APP_DIRS": True, "APP_DIRS": True,
"OPTIONS": { "OPTIONS": {
"context_processors": [ "context_processors": [
@ -133,6 +143,12 @@ if os.environ.get("USE_TEMPLATE_DB", False) == "True":
"TEMPLATE": os.environ["TEMPLATE_DB"], "TEMPLATE": os.environ["TEMPLATE_DB"],
} }
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "unique-snowflake",
}
}
# Password validation # Password validation
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
@ -181,11 +197,21 @@ ADMIN_APPROVAL_REQUIRED = os.environ.get("ADMIN_APPROVAL_REQUIRED", "False") ==
# SESAME_MAX_AGE = 300 # SESAME_MAX_AGE = 300
# # TODO set to "home" # # TODO set to "home"
# LOGIN_REDIRECT_URL = "/" # LOGIN_REDIRECT_URL = "/"
SERVER_HOST = os.environ.get("SERVER_URL", "http://localhost:8000")
SERVER_PATH = os.environ.get("SERVER_PATH", "")
SERVER_URL = SERVER_HOST
if SERVER_PATH:
SERVER_URL = os.path.join(SERVER_HOST, SERVER_PATH)
LOGIN_URL = "/login/" LOGIN_URL = "/login/"
if SERVER_PATH:
LOGIN_URL = f"/{SERVER_PATH}/login/"
SERVER_URL = os.environ.get("SERVER_URL", "http://localhost:8000") CSRF_TRUSTED_ORIGINS = [SERVER_HOST]
CSRF_TRUSTED_ORIGINS = [SERVER_URL]
AMBIT_URL = "http://localhost:9001" AMBIT_URL = "http://localhost:9001"
DEFAULT_VALUES = {"description": "no description"} DEFAULT_VALUES = {"description": "no description"}
@ -207,19 +233,20 @@ if not os.path.exists(LOG_DIR):
os.mkdir(LOG_DIR) os.mkdir(LOG_DIR)
PLUGIN_DIR = os.path.join(EP_DATA_DIR, "plugins") PLUGIN_DIR = os.path.join(EP_DATA_DIR, "plugins")
if not os.path.exists(PLUGIN_DIR):
os.mkdir(PLUGIN_DIR)
API_PAGINATION_DEFAULT_PAGE_SIZE = int(os.environ.get("API_PAGINATION_DEFAULT_PAGE_SIZE", 50)) API_PAGINATION_DEFAULT_PAGE_SIZE = int(os.environ.get("API_PAGINATION_DEFAULT_PAGE_SIZE", 50))
PAGINATION_MAX_PER_PAGE_SIZE = int( PAGINATION_MAX_PER_PAGE_SIZE = int(
os.environ.get("API_PAGINATION_MAX_PAGE_SIZE", 100) os.environ.get("API_PAGINATION_MAX_PAGE_SIZE", 100)
) # Ninja override ) # Ninja override
if not os.path.exists(PLUGIN_DIR):
os.mkdir(PLUGIN_DIR)
# Set this as our static root dir # Set this as our static root dir
STATIC_ROOT = STATIC_DIR STATIC_ROOT = STATIC_DIR
STATIC_URL = "/static/" STATIC_URL = "/static/"
if SERVER_PATH:
STATIC_URL = f"/{SERVER_PATH}/static/"
# Where the sources are stored... # Where the sources are stored...
STATICFILES_DIRS = (BASE_DIR / "static",) STATICFILES_DIRS = (BASE_DIR / "static",)
@ -283,9 +310,8 @@ if not FLAG_CELERY_PRESENT:
# Celery Configuration Options # Celery Configuration Options
CELERY_TIMEZONE = "Europe/Berlin" CELERY_TIMEZONE = "Europe/Berlin"
# Celery Configuration CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "redis://localhost:6379/0")
CELERY_BROKER_URL = "redis://localhost:6379/0" # Use Redis as message broker CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", "redis://localhost:6379/1")
CELERY_RESULT_BACKEND = "redis://localhost:6379/1"
CELERY_ACCEPT_CONTENT = ["json"] CELERY_ACCEPT_CONTENT = ["json"]
CELERY_TASK_SERIALIZER = "json" CELERY_TASK_SERIALIZER = "json"
@ -323,9 +349,11 @@ DEFAULT_MODEL_THRESHOLD = 0.25
# Loading Plugins # Loading Plugins
PLUGINS_ENABLED = os.environ.get("PLUGINS_ENABLED", "False") == "True" PLUGINS_ENABLED = os.environ.get("PLUGINS_ENABLED", "False") == "True"
BASE_PLUGINS = [ BASE_PLUGINS = os.environ.get("BASE_PLUGINS", None)
"pepper.PEPPER", if BASE_PLUGINS:
] BASE_PLUGINS = BASE_PLUGINS.split(",")
else:
BASE_PLUGINS = []
CLASSIFIER_PLUGINS = {} CLASSIFIER_PLUGINS = {}
PROPERTY_PLUGINS = {} PROPERTY_PLUGINS = {}
@ -353,6 +381,10 @@ if SENTRY_ENABLED:
before_send=before_send, before_send=before_send,
) )
IUCLID_EXPORT_ENABLED = os.environ.get("IUCLID_EXPORT_ENABLED", "False") == "True"
if IUCLID_EXPORT_ENABLED:
INSTALLED_APPS.append("epiuclid")
# compile into digestible flags # compile into digestible flags
FLAGS = { FLAGS = {
"MODEL_BUILDING": MODEL_BUILDING_ENABLED, "MODEL_BUILDING": MODEL_BUILDING_ENABLED,
@ -361,6 +393,7 @@ FLAGS = {
"SENTRY": SENTRY_ENABLED, "SENTRY": SENTRY_ENABLED,
"ENVIFORMER": ENVIFORMER_PRESENT, "ENVIFORMER": ENVIFORMER_PRESENT,
"APPLICABILITY_DOMAIN": APPLICABILITY_DOMAIN_ENABLED, "APPLICABILITY_DOMAIN": APPLICABILITY_DOMAIN_ENABLED,
"IUCLID_EXPORT": IUCLID_EXPORT_ENABLED,
} }
# path of the URL are checked via "startswith" # path of the URL are checked via "startswith"
@ -373,7 +406,6 @@ LOGIN_EXEMPT_URLS = [
"/o/userinfo/", "/o/userinfo/",
"/password_reset/", "/password_reset/",
"/reset/", "/reset/",
"/microsoft/",
"/terms", "/terms",
"/privacy", "/privacy",
"/cookie-policy", "/cookie-policy",
@ -382,8 +414,13 @@ LOGIN_EXEMPT_URLS = [
"/careers", "/careers",
"/cite", "/cite",
"/legal", "/legal",
"/entra/",
"/auth/",
] ]
if SERVER_PATH:
LOGIN_EXEMPT_URLS = [f"/{SERVER_PATH}{x}" for x in LOGIN_EXEMPT_URLS]
# MS AD/Entra # MS AD/Entra
MS_ENTRA_ENABLED = os.environ.get("MS_ENTRA_ENABLED", "False") == "True" MS_ENTRA_ENABLED = os.environ.get("MS_ENTRA_ENABLED", "False") == "True"
if MS_ENTRA_ENABLED: if MS_ENTRA_ENABLED:
@ -399,3 +436,58 @@ if MS_ENTRA_ENABLED:
# Site ID 10 -> beta.envipath.org # Site ID 10 -> beta.envipath.org
MATOMO_SITE_ID = os.environ.get("MATOMO_SITE_ID", "10") MATOMO_SITE_ID = os.environ.get("MATOMO_SITE_ID", "10")
# CAP
CAP_ENABLED = os.environ.get("CAP_ENABLED", "False") == "True"
CAP_API_BASE = os.environ.get("CAP_API_BASE", None)
CAP_SITE_KEY = os.environ.get("CAP_SITE_KEY", None)
CAP_SECRET_KEY = os.environ.get("CAP_SECRET_KEY", None)
# Biotransformer
BIOTRANSFORMER_ENABLED = os.environ.get("BIOTRANSFORMER_ENABLED", "False") == "True"
FLAGS["BIOTRANSFORMER"] = BIOTRANSFORMER_ENABLED
if BIOTRANSFORMER_ENABLED:
BIOTRANSFORMER_URL = os.environ.get("BIOTRANSFORMER_URL", None)
# PES
PES_API_MAPPING = os.environ.get("PES_API_MAPPING", None)
if PES_API_MAPPING:
import json
PES_API_MAPPING = json.loads(PES_API_MAPPING)
else:
PES_API_MAPPING = {}
# Entra Groups
ENTRA_GROUPS = os.environ.get("ENTRA_GROUPS", None)
if ENTRA_GROUPS:
import json
ENTRA_GROUPS = json.loads(ENTRA_GROUPS)
else:
ENTRA_GROUPS = {}
ENTRA_SECRET_GROUPS = os.environ.get("ENTRA_SECRET_GROUPS", None)
if ENTRA_SECRET_GROUPS:
import json
ENTRA_SECRET_GROUPS = json.loads(ENTRA_SECRET_GROUPS)
else:
ENTRA_SECRET_GROUPS = {}
# PES Data Pools vs Entra Mapping
DATA_POOL_MAPPING = os.environ.get("DATA_POOL_MAPPING", None)
if DATA_POOL_MAPPING:
import json
DATA_POOL_MAPPING = json.loads(DATA_POOL_MAPPING)
else:
DATA_POOL_MAPPING = {}
PROXIES = {}
if os.environ.get("HTTP_PROXY"):
PROXIES["http"] = os.environ.get("HTTP_PROXY")
PROXIES["https"] = os.environ.get("HTTPS_PROXY")
# BB4g
BB4G_URL = os.environ.get("BB4G_URL")
BB4G_TENANT_ID = os.environ.get("BB4G_TENANT_ID")
BB4G_CLIENT_ID = os.environ.get("BB4G_CLIENT_ID")
BB4G_CLIENT_SECRET = os.environ.get("BB4G_CLIENT_SECRET")
BB4G_SCOPE = os.environ.get("BB4G_SCOPE")

View File

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

View File

@ -60,7 +60,7 @@ class ScenarioCreationAPITests(TestCase):
) )
self.assertEqual(response.status_code, 404) 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): def test_create_scenario_insufficient_permissions(self):
"""Test that unauthorized access returns 403.""" """Test that unauthorized access returns 403."""

View File

@ -74,7 +74,6 @@ class TestSchemaGeneration:
assert all(isinstance(g, str) for g in groups), ( assert all(isinstance(g, str) for g in groups), (
f"{model_name}: all groups should be strings, got {groups}" f"{model_name}: all groups should be strings, got {groups}"
) )
assert len(groups) > 0, f"{model_name}: should have at least one group"
@pytest.mark.parametrize("model_name,model_cls", list(registry.list_models().items())) @pytest.mark.parametrize("model_name,model_cls", list(registry.list_models().items()))
def test_form_data_matches_schema(self, model_name: str, model_cls: Type[BaseModel]): def test_form_data_matches_schema(self, model_name: str, model_cls: Type[BaseModel]):

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 uuid import UUID
from django.conf import settings as s
from django.db.models import Model
from epdb.logic import PackageManager
from epdb.models import CompoundStructure, User, Compound, Scenario
from .errors import EPAPINotFoundError, EPAPIPermissionDeniedError from .errors import EPAPINotFoundError, EPAPIPermissionDeniedError
Package = s.GET_PACKAGE_MODEL()
def get_compound_for_read(user, compound_uuid: UUID): def get_compound_for_read(user, compound_uuid: UUID):
""" """
@ -41,6 +45,24 @@ def get_package_for_read(user, package_uuid: UUID):
return package 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): def get_scenario_for_read(user, scenario_uuid: UUID):
"""Get scenario by UUID with read permission check.""" """Get scenario by UUID with read permission check."""
try: 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,7 +9,6 @@ import logging
import json import json
from epdb.models import Scenario from epdb.models import Scenario
from epdb.logic import PackageManager
from epdb.views import _anonymous_or_real from epdb.views import _anonymous_or_real
from ..pagination import EnhancedPageNumberPagination from ..pagination import EnhancedPageNumberPagination
from ..schemas import ( from ..schemas import (
@ -17,7 +16,7 @@ from ..schemas import (
ScenarioOutSchema, ScenarioOutSchema,
ScenarioCreateSchema, ScenarioCreateSchema,
) )
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 from envipy_additional_information import registry
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -58,7 +57,7 @@ def create_scenario(request, package_uuid: UUID, payload: ScenarioCreateSchema =
user = _anonymous_or_real(request) user = _anonymous_or_real(request)
try: try:
current_package = PackageManager.get_package_by_id(user, package_uuid) current_package = get_package_for_write(user, package_uuid)
except ValueError as e: except ValueError as e:
error_msg = str(e) error_msg = str(e)
if "does not exist" in error_msg: if "does not exist" in error_msg:

View File

@ -15,9 +15,9 @@ router = Router()
EnhancedPageNumberPagination, EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE, page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
) )
def list_all_pathways(request): def list_all_settings(request):
""" """
List all pathways from reviewed packages. List all settings the user has access to.
""" """
user = request.user user = request.user
return SettingManager.get_all_settings(user) return SettingManager.get_all_settings(user)

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

View File

@ -126,3 +126,10 @@ class SettingOutSchema(Schema):
url: str = "" url: str = ""
name: str name: str
description: str description: str
class GroupOutSchema(Schema):
uuid: UUID
url: str = ""
name: str
description: str

View File

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

View File

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

View File

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

View File

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

5
epdb/autodiscovery.py Normal file
View File

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

View File

@ -1,18 +1,30 @@
from collections import defaultdict
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import jwt
import nh3 import nh3
import requests
from django.conf import settings as s from django.conf import settings as s
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.http import HttpResponse, JsonResponse from django.http import HttpResponse, JsonResponse
from django.shortcuts import redirect from django.shortcuts import redirect
from jwt import InvalidIssuerError
from ninja import Field, Form, Query, Router, Schema from ninja import Field, Form, Query, Router, Schema
from ninja.security import SessionAuth from ninja.security import HttpBearer
from utilities.chem import FormatConverter from utilities.chem import FormatConverter
from utilities.misc import PackageExporter from utilities.misc import PackageExporter
from .logic import (
from .logic import GroupManager, PackageManager, SearchManager, SettingManager, UserManager EPDBURLParser,
GroupManager,
PackageManager,
SearchManager,
SettingManager,
UserManager,
)
from .models import ( from .models import (
AdditionalInformation,
Compound, Compound,
CompoundStructure, CompoundStructure,
Edge, Edge,
@ -37,6 +49,26 @@ from .models import (
Package = s.GET_PACKAGE_MODEL() Package = s.GET_PACKAGE_MODEL()
def get_cached_jwks(tenant_id: str, force=False) -> Dict:
"""Get JWKS using Django cache"""
cache_key = f"jwks_{tenant_id}"
jwks = cache.get(cache_key)
if jwks is None or force:
# Cache miss, fetch new keys
jwks_uri = f"https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys"
response = requests.get(jwks_uri)
response.raise_for_status()
jwks = response.json()
# Cache for 1 hour (3600 seconds)
cache.set(cache_key, jwks, 3600)
return jwks
def get_package_for_write(user, package_uuid): def get_package_for_write(user, package_uuid):
p = PackageManager.get_package_by_id(user, package_uuid) p = PackageManager.get_package_by_id(user, package_uuid)
if not PackageManager.writable(user, p): if not PackageManager.writable(user, p):
@ -50,7 +82,52 @@ def _anonymous_or_real(request):
return get_user_model().objects.get(username="anonymous") return get_user_model().objects.get(username="anonymous")
router = Router(auth=SessionAuth(csrf=False)) def validate_token(token: str) -> dict:
TENANT_ID = s.MS_ENTRA_TENANT_ID
CLIENT_ID = s.MS_ENTRA_CLIENT_ID
jwks = get_cached_jwks(TENANT_ID)
header = jwt.get_unverified_header(token)
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(
next(k for k in jwks["keys"] if k["kid"] == header["kid"])
)
# Handle V1 and V2 tokens
try:
claims = jwt.decode(
token,
public_key,
algorithms=["RS256"],
audience=[CLIENT_ID, f"api://{CLIENT_ID}"],
issuer=[
f"https://sts.windows.net/{TENANT_ID}/",
f"https://login.microsoftonline.com/{TENANT_ID}/v2.0"
]
)
except Exception as e:
raise ValueError(f"Token verification failed! - {e}")
return claims
class MSBearerTokenAuth(HttpBearer):
def authenticate(self, request, token):
if token is None:
return None
claims = validate_token(token)
if not User.objects.filter(uuid=claims['oid']).exists():
return None
request.user = User.objects.get(uuid=claims['oid'])
return request.user
router = Router(auth=MSBearerTokenAuth())
class Error(Schema): class Error(Schema):
@ -94,6 +171,8 @@ class SimpleObject(Schema):
return "reviewed" if obj.compound.package.reviewed else "unreviewed" return "reviewed" if obj.compound.package.reviewed else "unreviewed"
elif isinstance(obj, Node) or isinstance(obj, Edge): elif isinstance(obj, Node) or isinstance(obj, Edge):
return "reviewed" if obj.pathway.package.reviewed else "unreviewed" 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: else:
raise ValueError("Object has no package") raise ValueError("Object has no package")
@ -142,21 +221,6 @@ class SimpleModel(SimpleObject):
identifier: str = "relative-reasoning" identifier: str = "relative-reasoning"
################
# Login/Logout #
################
@router.post("/", response={200: SimpleUser, 403: Error}, auth=None)
def login(request, loginusername: Form[str], loginpassword: Form[str]):
from django.contrib.auth import authenticate, login
email = User.objects.get(username=loginusername).email
user = authenticate(username=email, password=loginpassword)
if user:
login(request, user)
return user
else:
return 403, {"message": "Invalid username and/or password"}
######## ########
# User # # User #
@ -756,6 +820,7 @@ class CreateCompound(Schema):
compoundName: str | None = None compoundName: str | None = None
compoundDescription: str | None = None compoundDescription: str | None = None
inchi: str | None = None inchi: str | None = None
pesLink: str | None = None
@router.post("/package/{uuid:package_uuid}/compound") @router.post("/package/{uuid:package_uuid}/compound")
@ -767,6 +832,25 @@ def create_package_compound(
try: try:
p = get_package_for_write(request.user, package_uuid) p = get_package_for_write(request.user, package_uuid)
# inchi is not used atm # inchi is not used atm
if c.pesLink is not None:
from bayer.views import fetch_pes
from bayer.models import PESCompound
try:
pes_data = fetch_pes(request, c.pesLink)
except ValueError as e:
return 400, {"message": f"Could not fetch PES data for {c.pesLink}"}
classification = pes_data.get("classificationLevel", "")
if "secret" == classification.lower():
data_pools = pes_data.get("dataPools")
if data_pools:
if s.DATA_POOL_MAPPING[p.data_pool.name] not in data_pools:
return 400, { "messsage": f"PES data pool {s.DATA_POOL_MAPPING[p.data_pool.name]} not found in PES data"}
c = PESCompound.create(p, pes_data, c.compoundName, c.compoundDescription)
else:
c = Compound.create( c = Compound.create(
p, c.compoundSmiles, c.compoundName, c.compoundDescription, inchi=c.inchi p, c.compoundSmiles, c.compoundName, c.compoundDescription, inchi=c.inchi
) )
@ -1327,7 +1411,14 @@ class ScenarioSchema(Schema):
@staticmethod @staticmethod
def resolve_collection(obj: Scenario): def resolve_collection(obj: Scenario):
return obj.additional_information res = defaultdict(list)
for ai in obj.get_additional_information(direct_only=False):
data = ai.data
data["related"] = ai.content_object.simple_json() if ai.content_object else None
res[ai.type].append(data)
return res
@staticmethod @staticmethod
def resolve_review_status(obj: Rule): def resolve_review_status(obj: Rule):
@ -1392,7 +1483,11 @@ def create_package_scenario(request, package_uuid):
study_type = request.POST.get("type") study_type = request.POST.get("type")
ais = [] ais = []
types = request.POST.getlist("adInfoTypes[]") types = request.POST.get("adInfoTypes[]", [])
if types:
types = types.split(",")
for t in types: for t in types:
ais.append(build_additional_information_from_request(request, t)) ais.append(build_additional_information_from_request(request, t))
@ -1434,6 +1529,49 @@ def delete_scenario(request, package_uuid, scenario_uuid):
} }
@router.post(
"/package/{uuid:package_uuid}/additional-information", response={200: str | Any, 403: Error}
)
def create_package_additional_information(request, package_uuid):
from utilities.legacy import build_additional_information_from_request
try:
p = get_package_for_write(request.user, package_uuid)
scen = request.POST.get("scenario")
scenario = Scenario.objects.get(package=p, url=scen)
url_parser = EPDBURLParser(request.POST.get("attach_obj"))
attach_obj = url_parser.get_object()
if not hasattr(attach_obj, "additional_information"):
raise ValueError("Can't attach additional information to this object!")
if not attach_obj.url.startswith(p.url):
raise ValueError(
"Additional Information can only be set to objects stored in the same package!"
)
types = request.POST.get("adInfoTypes[]", "").split(",")
for t in types:
ai = build_additional_information_from_request(request, t)
AdditionalInformation.create(
p,
ai,
scenario=scenario,
content_object=attach_obj,
)
# TODO implement additional information endpoint ?
return redirect(f"{scenario.url}")
except ValueError:
return 403, {
"message": f"Getting Package with id {package_uuid} failed due to insufficient rights!"
}
########### ###########
# Pathway # # Pathway #
########### ###########
@ -1464,7 +1602,7 @@ class PathwayEdge(Schema):
class PathwayNode(Schema): class PathwayNode(Schema):
atomCount: int = Field(None, alias="atom_count") 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") dt50s: List[Dict[str, str]] = Field([], alias="dt50s")
engineeredIntermediate: bool = Field(None, alias="engineered_intermediate") engineeredIntermediate: bool = Field(None, alias="engineered_intermediate")
id: str = Field(None, alias="url") id: str = Field(None, alias="url")
@ -1736,6 +1874,7 @@ class CreateNode(Schema):
nodeName: str | None = None nodeName: str | None = None
nodeReason: str | None = None nodeReason: str | None = None
nodeDepth: str | None = None nodeDepth: str | None = None
pesLink: str | None = None
@router.post( @router.post(
@ -1747,14 +1886,43 @@ def add_pathway_node(request, package_uuid, pathway_uuid, n: Form[CreateNode]):
p = get_package_for_write(request.user, package_uuid) p = get_package_for_write(request.user, package_uuid)
pw = Pathway.objects.get(package=p, uuid=pathway_uuid) pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
if n.pesLink:
from bayer.views import fetch_pes
from bayer.models import PESCompound
try:
pes_data = fetch_pes(request, c.pesLink)
except ValueError as e:
return 400, {"message": f"Could not fetch PES data for {c.pesLink}"}
classification = pes_data.get("classificationLevel", "")
if "secret" == classification.lower():
data_pools = pes_data.get("dataPools")
if data_pools:
if s.DATA_POOL_MAPPING[p.data_pool.name] not in data_pools:
return 400, { "messsage": f"PES data pool {s.DATA_POOL_MAPPING[p.data_pool.name]} not found in PES data"}
c = PESCompound.create(p, pes_data, c.compoundName, c.compoundDescription)
node = Node()
node.stereo_removed = False
node.pathway = pw
node.depth = 0
node.default_node_label = c.default_structure
node.save()
node.node_labels.add(c.default_structure)
node.save()
else:
if n.nodeDepth is not None and n.nodeDepth.strip() != "": if n.nodeDepth is not None and n.nodeDepth.strip() != "":
node_depth = int(n.nodeDepth) node_depth = int(n.nodeDepth)
else: else:
node_depth = -1 node_depth = -1
n = Node.create(pw, n.nodeAsSmiles, node_depth, n.nodeName, n.nodeReason) node = Node.create(pw, n.nodeAsSmiles, node_depth, n.nodeName, n.nodeReason)
return redirect(n.url) return redirect(node.url)
except ValueError: except ValueError:
return 403, {"message": "Adding node failed!"} return 403, {"message": "Adding node failed!"}
@ -1805,7 +1973,7 @@ class EdgeSchema(Schema):
startNodes: List["EdgeNode"] = Field([], alias="start_nodes") startNodes: List["EdgeNode"] = Field([], alias="start_nodes")
@staticmethod @staticmethod
def resolve_review_status(obj: Node): def resolve_review_status(obj: Edge):
return "reviewed" if obj.pathway.package.reviewed else "unreviewed" return "reviewed" if obj.pathway.package.reviewed else "unreviewed"
@ -1864,13 +2032,16 @@ def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
educts = [] educts = []
products = [] products = []
subclasses = CompoundStructure.__subclasses__()
if e.edgeAsSmirks: if e.edgeAsSmirks:
for ed in e.edgeAsSmirks.split(">>")[0].split("\\."): for ed in e.edgeAsSmirks.split(">>")[0].split("\\."):
stand_ed = FormatConverter.standardize(ed, remove_stereo=True) stand_ed = FormatConverter.standardize(ed, remove_stereo=True)
educts.append( educts.append(
Node.objects.get( Node.objects.get(
pathway=pw, pathway=pw,
default_node_label=CompoundStructure.objects.get( default_node_label=CompoundStructure.objects.not_instance_of(*subclasses).
get(
compound__package=p, smiles=stand_ed compound__package=p, smiles=stand_ed
).compound.default_structure, ).compound.default_structure,
) )
@ -1881,7 +2052,8 @@ def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
products.append( products.append(
Node.objects.get( Node.objects.get(
pathway=pw, pathway=pw,
default_node_label=CompoundStructure.objects.get( default_node_label=CompoundStructure.objects.not_instance_of(*subclasses).
get(
compound__package=p, smiles=stand_pr compound__package=p, smiles=stand_pr
).compound.default_structure, ).compound.default_structure,
) )
@ -1902,9 +2074,12 @@ def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
description=e.edgeReason, description=e.edgeReason,
) )
# Update depths as sideeffect of above operation
pw.update_depths()
return redirect(new_e.url) return redirect(new_e.url)
except ValueError: except ValueError:
return 403, {"message": "Adding node failed!"} return 403, {"message": "Adding Edge failed!"}
@router.delete("/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}/edge/{uuid:edge_uuid}") @router.delete("/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}/edge/{uuid:edge_uuid}")

View File

@ -7,6 +7,7 @@ import nh3
from django.conf import settings as s from django.conf import settings as s
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import transaction from django.db import transaction
from django.db.models import QuerySet
from pydantic import ValidationError from pydantic import ValidationError
from epdb.models import ( from epdb.models import (
@ -264,8 +265,12 @@ class GroupManager(object):
return bool(re.findall(GroupManager.group_pattern, url)) return bool(re.findall(GroupManager.group_pattern, url))
@staticmethod @staticmethod
def create_group(current_user, name, description): def create_group(current_user, name, description, *args, **kwargs):
g = Group() g = Group()
if "uuid" in kwargs:
g.uuid = kwargs["uuid"]
# Clean for potential XSS # Clean for potential XSS
g.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() g.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
g.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() g.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
@ -341,52 +346,17 @@ class PackageManager(object):
@staticmethod @staticmethod
def readable(user, package): def readable(user, package):
if ( return (
UserPackagePermission.objects.filter(package=package, user=user).exists() PackageManager.has_package_permission(user, package, "read") | package.reviewed is True
or GroupPackagePermission.objects.filter(
package=package, group__in=GroupManager.get_groups(user)
) )
or package.reviewed is True
or user.is_superuser
):
return True
return False
@staticmethod @staticmethod
def writable(user, package): def writable(user, package):
if ( return PackageManager.has_package_permission(user, package, "write")
UserPackagePermission.objects.filter(
package=package, user=user, permission=Permission.WRITE[0]
).exists()
or GroupPackagePermission.objects.filter(
package=package,
group__in=GroupManager.get_groups(user),
permission=Permission.WRITE[0],
).exists()
or UserPackagePermission.objects.filter(
package=package, user=user, permission=Permission.ALL[0]
).exists()
or user.is_superuser
):
return True
return False
@staticmethod @staticmethod
def administrable(user, package): def administrable(user, package):
if ( return PackageManager.has_package_permission(user, package, "all")
UserPackagePermission.objects.filter(
package=package, user=user, permission=Permission.ALL[0]
).exists()
or GroupPackagePermission.objects.filter(
package=package,
group__in=GroupManager.get_groups(user),
permission=Permission.ALL[0],
).exists()
or user.is_superuser
):
return True
return False
@staticmethod @staticmethod
def has_package_permission(user: "User", package: Union[str, UUID, "Package"], permission: str): def has_package_permission(user: "User", package: Union[str, UUID, "Package"], permission: str):
@ -395,6 +365,14 @@ class PackageManager(object):
groups = GroupManager.get_groups(user) groups = GroupManager.get_groups(user)
# EDIT START
if package.classification_level == Package.Classification.SECRET:
if package.data_pool not in groups:
return False
# EDIT END
perms = {"all": ["all"], "write": ["all", "write"], "read": ["all", "write", "read"]} perms = {"all": ["all"], "write": ["all", "write"], "read": ["all", "write", "read"]}
valid_perms = perms.get(permission) valid_perms = perms.get(permission)
@ -437,6 +415,7 @@ class PackageManager(object):
try: try:
p = Package.objects.get(uuid=package_id) p = Package.objects.get(uuid=package_id)
if PackageManager.readable(user, p): if PackageManager.readable(user, p):
p = PackageManager.check_package_classification(user, p)
return p return p
else: else:
# FIXME: use custom exception to be translatable to 403 in API # FIXME: use custom exception to be translatable to 403 in API
@ -446,6 +425,37 @@ class PackageManager(object):
except Package.DoesNotExist: except Package.DoesNotExist:
raise ValueError("Package with ID {} does not exist!".format(package_id)) raise ValueError("Package with ID {} does not exist!".format(package_id))
# EDIT START
@staticmethod
def check_package_classification(user, pack: Package):
if pack.classification_level == Package.Classification.SECRET:
if pack.data_pool.user_member.filter(id=user.id).exists():
return pack
raise ValueError("Package is secret and not accessible to user!")
else:
return pack
@staticmethod
def check_package_classifications(user, package_qs: QuerySet[Package]):
non_secret = package_qs.exclude(classification_level=Package.Classification.SECRET)
secret = package_qs.filter(classification_level=Package.Classification.SECRET)
# TODO we should be able to do via the db
accessible_secret = []
for s_package in secret:
if s_package.data_pool.user_member.filter(id=user.id).exists():
accessible_secret.append(s_package.pk)
# Cannot combine a unique query with a non-unique query -> we have to call distinct
return Package.objects.filter(pk__in=accessible_secret).distinct() | non_secret.distinct()
# EDIT END
@staticmethod @staticmethod
def get_all_readable_packages(user, include_reviewed=False): def get_all_readable_packages(user, include_reviewed=False):
# UserPermission only exists if at least read is granted... # UserPermission only exists if at least read is granted...
@ -470,7 +480,13 @@ class PackageManager(object):
# remove package if user is owner and package is reviewed e.g. admin # remove package if user is owner and package is reviewed e.g. admin
qs = qs.filter(reviewed=False) qs = qs.filter(reviewed=False)
return qs.distinct() qs = qs.distinct()
# EDIT START
qs = PackageManager.check_package_classifications(user, qs)
# EDIT END
return qs
@staticmethod @staticmethod
def get_all_writeable_packages(user): def get_all_writeable_packages(user):
@ -514,11 +530,13 @@ class PackageManager(object):
qs = qs.filter(reviewed=False) qs = qs.filter(reviewed=False)
return qs.distinct() qs = qs.distinct()
@staticmethod # EDIT START
def get_packages(): qs = PackageManager.check_package_classifications(user, qs)
return Package.objects.all() # EDIT END
return qs
@staticmethod @staticmethod
@transaction.atomic @transaction.atomic
@ -623,6 +641,25 @@ class PackageManager(object):
else: else:
pack.reviewed = False pack.reviewed = False
# EDIT START
if data.get("classification"):
if data["classification"] == "INTERNAL":
pack.classification = Package.Classification.RESTRICTED
elif data["classification"] == "RESTRICTED":
pack.classification = Package.Classification.RESTRICTED
elif data["classification"] == "SECRET":
pack.classification = Package.Classification.SECRET
if not "datapool" in data:
raise ValueError("Missing datapool in package")
g = Group.objects.get(uuid=data["datapool"].split('/')[-1])
pack.data_pool = g
else:
raise ValueError(f"Invalid classification {data['classification']}")
# EDIT END
pack.description = data["description"] pack.description = data["description"]
pack.save() pack.save()
@ -708,7 +745,13 @@ class PackageManager(object):
default_structure = None default_structure = None
for structure in compound["structures"]: for structure in compound["structures"]:
if structure.get("pesLink"):
from bayer.models import PESStructure
struc = PESStructure()
struc.pes_link = structure["pesLink"]
else:
struc = CompoundStructure() struc = CompoundStructure()
# struc.object_url = Command.get_id(structure, keep_ids) # struc.object_url = Command.get_id(structure, keep_ids)
struc.compound = comp struc.compound = comp
struc.uuid = UUID(structure["id"].split("/")[-1]) if keep_ids else uuid4() struc.uuid = UUID(structure["id"].split("/")[-1]) if keep_ids else uuid4()
@ -716,6 +759,10 @@ class PackageManager(object):
struc.description = structure["description"] struc.description = structure["description"]
struc.aliases = structure.get("aliases", []) struc.aliases = structure.get("aliases", [])
struc.smiles = structure["smiles"] struc.smiles = structure["smiles"]
if structure.get("molfile"):
struc.molfile = structure["molfile"]
struc.save() struc.save()
for scen in structure["scenarios"]: for scen in structure["scenarios"]:
@ -1018,52 +1065,9 @@ class PackageManager(object):
print("Fixing Node depths...") print("Fixing Node depths...")
total_pws = Pathway.objects.filter(package=pack).count() total_pws = Pathway.objects.filter(package=pack).count()
for p, pw in enumerate(Pathway.objects.filter(package=pack)): for p, pw in enumerate(Pathway.objects.filter(package=pack)):
in_count = defaultdict(lambda: 0) pw.update_depths()
out_count = defaultdict(lambda: 0)
for e in pw.edges:
# TODO check if this will remain
for react in e.start_nodes.all():
out_count[str(react.uuid)] += 1
for prod in e.end_nodes.all():
in_count[str(prod.uuid)] += 1
root_nodes = []
for n in pw.nodes:
num_parents = in_count[str(n.uuid)]
if num_parents == 0:
# must be a root node or unconnected node
if n.depth != 0:
n.depth = 0
n.save()
# Only root node may have children
if out_count[str(n.uuid)] > 0:
root_nodes.append(n)
levels = [root_nodes]
seen = set()
# Do a bfs to determine depths starting with level 0 a.k.a. root nodes
for i, level_nodes in enumerate(levels):
new_level = []
for n in level_nodes:
for e in n.out_edges.all():
for prod in e.end_nodes.all():
if str(prod.uuid) not in seen:
old_depth = prod.depth
if old_depth != i + 1:
prod.depth = i + 1
prod.save()
new_level.append(prod)
seen.add(str(n.uuid))
if new_level:
levels.append(new_level)
print(f"{p + 1}/{total_pws} fixed.", end="\r") print(f"{p + 1}/{total_pws} fixed.", end="\r")
return pack return pack

View File

@ -1,594 +0,0 @@
# Generated by Django 5.2.1 on 2025-07-22 20:58
import datetime
import django.contrib.auth.models
import django.contrib.auth.validators
import django.contrib.postgres.fields
import django.db.models.deletion
import django.utils.timezone
import model_utils.fields
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name='Compound',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')),
],
),
migrations.CreateModel(
name='EPModel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
),
migrations.CreateModel(
name='Permission',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('permission', models.CharField(choices=[('read', 'Read'), ('write', 'Write'), ('all', 'All')], max_length=32)),
],
),
migrations.CreateModel(
name='License',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('link', models.URLField(verbose_name='link')),
('image_link', models.URLField(verbose_name='Image link')),
],
),
migrations.CreateModel(
name='Rule',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
),
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('email', models.EmailField(max_length=254, unique=True)),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name='APIToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('hashed_key', models.CharField(max_length=128, unique=True)),
('created', models.DateTimeField(auto_now_add=True)),
('expires_at', models.DateTimeField(blank=True, default=datetime.datetime(2025, 10, 20, 20, 58, 48, 351675, tzinfo=datetime.timezone.utc), null=True)),
('name', models.CharField(blank=True, help_text='Optional name for the token', max_length=100)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='CompoundStructure',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')),
('smiles', models.TextField(verbose_name='SMILES')),
('canonical_smiles', models.TextField(verbose_name='Canonical SMILES')),
('inchikey', models.TextField(max_length=27, verbose_name='InChIKey')),
('normalized_structure', models.BooleanField(default=False)),
('compound', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.compound')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='compound',
name='default_structure',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='compound_default_structure', to='epdb.compoundstructure', verbose_name='Default Structure'),
),
migrations.CreateModel(
name='Edge',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')),
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
),
migrations.CreateModel(
name='EnviFormer',
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)),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('epdb.epmodel',),
),
migrations.CreateModel(
name='PluginModel',
fields=[
('epmodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.epmodel')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('epdb.epmodel',),
),
migrations.CreateModel(
name='RuleBaseRelativeReasoning',
fields=[
('epmodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.epmodel')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('epdb.epmodel',),
),
migrations.CreateModel(
name='Group',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(verbose_name='Group name')),
('public', models.BooleanField(default=False, verbose_name='Public Group')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('group_member', models.ManyToManyField(related_name='groups_in_group', to='epdb.group', verbose_name='Group member')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Group Owner')),
('user_member', models.ManyToManyField(related_name='users_in_group', to=settings.AUTH_USER_MODEL, verbose_name='User members')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='user',
name='default_group',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_group', to='epdb.group', verbose_name='Default Group'),
),
migrations.CreateModel(
name='Node',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')),
('depth', models.IntegerField(verbose_name='Node depth')),
('default_node_label', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='default_node_structure', to='epdb.compoundstructure', verbose_name='Default Node Label')),
('node_labels', models.ManyToManyField(related_name='node_structures', to='epdb.compoundstructure', verbose_name='All Node Labels')),
('out_edges', models.ManyToManyField(to='epdb.edge', verbose_name='Outgoing Edges')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='edge',
name='end_nodes',
field=models.ManyToManyField(related_name='edge_products', to='epdb.node', verbose_name='End Nodes'),
),
migrations.AddField(
model_name='edge',
name='start_nodes',
field=models.ManyToManyField(related_name='edge_educts', to='epdb.node', verbose_name='Start Nodes'),
),
migrations.CreateModel(
name='Package',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('reviewed', models.BooleanField(default=False, verbose_name='Reviewstatus')),
('license', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.license', verbose_name='License')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='epmodel',
name='package',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.package', verbose_name='Package'),
),
migrations.AddField(
model_name='compound',
name='package',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.package', verbose_name='Package'),
),
migrations.AddField(
model_name='user',
name='default_package',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.package', verbose_name='Default Package'),
),
migrations.CreateModel(
name='SequentialRule',
fields=[
('rule_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.rule')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('epdb.rule',),
),
migrations.CreateModel(
name='SimpleRule',
fields=[
('rule_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.rule')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('epdb.rule',),
),
migrations.AddField(
model_name='rule',
name='package',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.package', verbose_name='Package'),
),
migrations.AddField(
model_name='rule',
name='polymorphic_ctype',
field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype'),
),
migrations.CreateModel(
name='Pathway',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')),
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.package', verbose_name='Package')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='node',
name='pathway',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.pathway', verbose_name='belongs to'),
),
migrations.AddField(
model_name='edge',
name='pathway',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.pathway', verbose_name='belongs to'),
),
migrations.CreateModel(
name='Reaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')),
('multi_step', models.BooleanField(verbose_name='Multistep Reaction')),
('medline_references', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), null=True, size=None, verbose_name='Medline References')),
('educts', models.ManyToManyField(related_name='reaction_educts', to='epdb.compoundstructure', verbose_name='Educts')),
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.package', verbose_name='Package')),
('products', models.ManyToManyField(related_name='reaction_products', to='epdb.compoundstructure', verbose_name='Products')),
('rules', models.ManyToManyField(related_name='reaction_rule', to='epdb.rule', verbose_name='Rule')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='edge',
name='edge_label',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.reaction', verbose_name='Edge label'),
),
migrations.CreateModel(
name='Scenario',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('scenario_date', models.CharField(default='No date', max_length=256)),
('scenario_type', models.CharField(default='Not specified', max_length=256)),
('additional_information', models.JSONField(verbose_name='Additional Information')),
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.package', verbose_name='Package')),
('parent', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='epdb.scenario')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='rule',
name='scenarios',
field=models.ManyToManyField(to='epdb.scenario', verbose_name='Attached Scenarios'),
),
migrations.AddField(
model_name='reaction',
name='scenarios',
field=models.ManyToManyField(to='epdb.scenario', verbose_name='Attached Scenarios'),
),
migrations.AddField(
model_name='pathway',
name='scenarios',
field=models.ManyToManyField(to='epdb.scenario', verbose_name='Attached Scenarios'),
),
migrations.AddField(
model_name='node',
name='scenarios',
field=models.ManyToManyField(to='epdb.scenario', verbose_name='Attached Scenarios'),
),
migrations.AddField(
model_name='edge',
name='scenarios',
field=models.ManyToManyField(to='epdb.scenario', verbose_name='Attached Scenarios'),
),
migrations.AddField(
model_name='compoundstructure',
name='scenarios',
field=models.ManyToManyField(to='epdb.scenario', verbose_name='Attached Scenarios'),
),
migrations.AddField(
model_name='compound',
name='scenarios',
field=models.ManyToManyField(to='epdb.scenario', verbose_name='Attached Scenarios'),
),
migrations.CreateModel(
name='Setting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('public', models.BooleanField(default=False)),
('global_default', models.BooleanField(default=False)),
('max_depth', models.IntegerField(default=5, verbose_name='Setting Max Depth')),
('max_nodes', models.IntegerField(default=30, verbose_name='Setting Max Number of Nodes')),
('model_threshold', models.FloatField(blank=True, default=0.25, null=True, verbose_name='Setting Model Threshold')),
('model', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.epmodel', verbose_name='Setting EPModel')),
('rule_packages', models.ManyToManyField(blank=True, related_name='setting_rule_packages', to='epdb.package', verbose_name='Setting Rule Packages')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='pathway',
name='setting',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='epdb.setting', verbose_name='Setting'),
),
migrations.AddField(
model_name='user',
name='default_setting',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.setting', verbose_name='The users default settings'),
),
migrations.CreateModel(
name='MLRelativeReasoning',
fields=[
('epmodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.epmodel')),
('threshold', models.FloatField(default=0.5)),
('model_status', models.CharField(choices=[('INITIAL', 'Initial'), ('INITIALIZING', 'Model is initializing.'), ('BUILDING', 'Model is building.'), ('BUILT_NOT_EVALUATED', 'Model is built and can be used for predictions, Model is not evaluated yet.'), ('EVALUATING', 'Model is evaluating'), ('FINISHED', 'Model has finished building and evaluation.'), ('ERROR', 'Model has failed.')], default='INITIAL')),
('eval_results', models.JSONField(blank=True, default=dict, null=True)),
('data_packages', models.ManyToManyField(related_name='data_packages', to='epdb.package', verbose_name='Data Packages')),
('eval_packages', models.ManyToManyField(related_name='eval_packages', to='epdb.package', verbose_name='Evaluation Packages')),
('rule_packages', models.ManyToManyField(related_name='rule_packages', to='epdb.package', verbose_name='Rule Packages')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('epdb.epmodel',),
),
migrations.CreateModel(
name='ApplicabilityDomain',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('num_neighbours', models.FloatField(default=5)),
('reliability_threshold', models.FloatField(default=0.5)),
('local_compatibilty_threshold', models.FloatField(default=0.5)),
('model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.mlrelativereasoning')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='SimpleAmbitRule',
fields=[
('simplerule_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.simplerule')),
('smirks', models.TextField(verbose_name='SMIRKS')),
('reactant_filter_smarts', models.TextField(null=True, verbose_name='Reactant Filter SMARTS')),
('product_filter_smarts', models.TextField(null=True, verbose_name='Product Filter SMARTS')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('epdb.simplerule',),
),
migrations.CreateModel(
name='SimpleRDKitRule',
fields=[
('simplerule_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.simplerule')),
('reaction_smarts', models.TextField(verbose_name='SMIRKS')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('epdb.simplerule',),
),
migrations.CreateModel(
name='SequentialRuleOrdering',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('order_index', models.IntegerField()),
('sequential_rule', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.sequentialrule')),
('simple_rule', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.simplerule')),
],
),
migrations.AddField(
model_name='sequentialrule',
name='simple_rules',
field=models.ManyToManyField(through='epdb.SequentialRuleOrdering', to='epdb.simplerule', verbose_name='Simple rules'),
),
migrations.CreateModel(
name='ParallelRule',
fields=[
('rule_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.rule')),
('simple_rules', models.ManyToManyField(to='epdb.simplerule', verbose_name='Simple rules')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('epdb.rule',),
),
migrations.AlterUniqueTogether(
name='compound',
unique_together={('uuid', 'package')},
),
migrations.CreateModel(
name='GroupPackagePermission',
fields=[
('permission_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, to='epdb.permission')),
('uuid', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='UUID of this object')),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.group', verbose_name='Permission to')),
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.package', verbose_name='Permission on')),
],
options={
'unique_together': {('package', 'group')},
},
bases=('epdb.permission',),
),
migrations.CreateModel(
name='UserPackagePermission',
fields=[
('permission_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, to='epdb.permission')),
('uuid', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='UUID of this object')),
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.package', verbose_name='Permission on')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Permission to')),
],
options={
'unique_together': {('package', 'user')},
},
bases=('epdb.permission',),
),
migrations.CreateModel(
name='UserSettingPermission',
fields=[
('permission_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, to='epdb.permission')),
('uuid', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='UUID of this object')),
('setting', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.setting', verbose_name='Permission on')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Permission to')),
],
options={
'unique_together': {('setting', 'user')},
},
bases=('epdb.permission',),
),
]

View File

@ -1,128 +0,0 @@
# Generated by Django 5.2.1 on 2025-08-25 18:07
import django.db.models.deletion
import django.utils.timezone
import model_utils.fields
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('epdb', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='ExternalDatabase',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('name', models.CharField(max_length=100, unique=True, verbose_name='Database Name')),
('full_name', models.CharField(blank=True, max_length=255, verbose_name='Full Database Name')),
('description', models.TextField(blank=True, verbose_name='Description')),
('base_url', models.URLField(blank=True, null=True, verbose_name='Base URL')),
('url_pattern', models.CharField(blank=True, help_text="URL pattern with {id} placeholder, e.g., 'https://pubchem.ncbi.nlm.nih.gov/compound/{id}'", max_length=500, verbose_name='URL Pattern')),
('is_active', models.BooleanField(default=True, verbose_name='Is Active')),
],
options={
'verbose_name': 'External Database',
'verbose_name_plural': 'External Databases',
'db_table': 'epdb_external_database',
'ordering': ['name'],
},
),
migrations.AlterModelOptions(
name='apitoken',
options={'ordering': ['-created'], 'verbose_name': 'API Token', 'verbose_name_plural': 'API Tokens'},
),
migrations.AlterModelOptions(
name='edge',
options={},
),
migrations.RemoveField(
model_name='edge',
name='polymorphic_ctype',
),
migrations.AddField(
model_name='apitoken',
name='is_active',
field=models.BooleanField(default=True, help_text='Whether this token is active'),
),
migrations.AddField(
model_name='apitoken',
name='modified',
field=model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified'),
),
migrations.AddField(
model_name='applicabilitydomain',
name='functional_groups',
field=models.JSONField(blank=True, default=dict, null=True),
),
migrations.AddField(
model_name='mlrelativereasoning',
name='app_domain',
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.applicabilitydomain'),
),
migrations.AlterField(
model_name='apitoken',
name='created',
field=model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created'),
),
migrations.AlterField(
model_name='apitoken',
name='expires_at',
field=models.DateTimeField(blank=True, help_text='Token expiration time (null for no expiration)', null=True),
),
migrations.AlterField(
model_name='apitoken',
name='hashed_key',
field=models.CharField(help_text='SHA-256 hash of the token key', max_length=128, unique=True),
),
migrations.AlterField(
model_name='apitoken',
name='name',
field=models.CharField(help_text='Descriptive name for this token', max_length=100),
),
migrations.AlterField(
model_name='apitoken',
name='user',
field=models.ForeignKey(help_text='User who owns this token', on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='applicabilitydomain',
name='num_neighbours',
field=models.IntegerField(default=5),
),
migrations.AlterModelTable(
name='apitoken',
table='epdb_api_token',
),
migrations.CreateModel(
name='ExternalIdentifier',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('object_id', models.IntegerField()),
('identifier_value', models.CharField(max_length=255, verbose_name='Identifier Value')),
('url', models.URLField(blank=True, null=True, verbose_name='Direct URL')),
('is_primary', models.BooleanField(default=False, help_text='Mark this as the primary identifier for this database', verbose_name='Is Primary')),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
('database', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.externaldatabase', verbose_name='External Database')),
],
options={
'verbose_name': 'External Identifier',
'verbose_name_plural': 'External Identifiers',
'db_table': 'epdb_external_identifier',
'indexes': [models.Index(fields=['content_type', 'object_id'], name='epdb_extern_content_b76813_idx'), models.Index(fields=['database', 'identifier_value'], name='epdb_extern_databas_486422_idx')],
'unique_together': {('content_type', 'object_id', 'database', 'identifier_value')},
},
),
]

View File

@ -1,228 +0,0 @@
# Generated by Django 5.2.1 on 2025-08-26 17:05
from django.db import migrations, models
def populate_url(apps, schema_editor):
MODELS = [
'User',
'Group',
'Package',
'Compound',
'CompoundStructure',
'Pathway',
'Edge',
'Node',
'Reaction',
'SimpleAmbitRule',
'SimpleRDKitRule',
'ParallelRule',
'SequentialRule',
'Scenario',
'Setting',
'MLRelativeReasoning',
'EnviFormer',
'ApplicabilityDomain',
]
for model in MODELS:
obj_cls = apps.get_model("epdb", model)
for obj in obj_cls.objects.all():
obj.url = assemble_url(obj)
if obj.url is None:
raise ValueError(f"Could not assemble url for {obj}")
obj.save()
def assemble_url(obj):
from django.conf import settings as s
match obj.__class__.__name__:
case 'User':
return '{}/user/{}'.format(s.SERVER_URL, obj.uuid)
case 'Group':
return '{}/group/{}'.format(s.SERVER_URL, obj.uuid)
case 'Package':
return '{}/package/{}'.format(s.SERVER_URL, obj.uuid)
case 'Compound':
return '{}/compound/{}'.format(obj.package.url, obj.uuid)
case 'CompoundStructure':
return '{}/structure/{}'.format(obj.compound.url, obj.uuid)
case 'SimpleAmbitRule':
return '{}/simple-ambit-rule/{}'.format(obj.package.url, obj.uuid)
case 'SimpleRDKitRule':
return '{}/simple-rdkit-rule/{}'.format(obj.package.url, obj.uuid)
case 'ParallelRule':
return '{}/parallel-rule/{}'.format(obj.package.url, obj.uuid)
case 'SequentialRule':
return '{}/sequential-rule/{}'.format(obj.compound.url, obj.uuid)
case 'Reaction':
return '{}/reaction/{}'.format(obj.package.url, obj.uuid)
case 'Pathway':
return '{}/pathway/{}'.format(obj.package.url, obj.uuid)
case 'Node':
return '{}/node/{}'.format(obj.pathway.url, obj.uuid)
case 'Edge':
return '{}/edge/{}'.format(obj.pathway.url, obj.uuid)
case 'MLRelativeReasoning':
return '{}/model/{}'.format(obj.package.url, obj.uuid)
case 'EnviFormer':
return '{}/model/{}'.format(obj.package.url, obj.uuid)
case 'ApplicabilityDomain':
return '{}/model/{}/applicability-domain/{}'.format(obj.model.package.url, obj.model.uuid, obj.uuid)
case 'Scenario':
return '{}/scenario/{}'.format(obj.package.url, obj.uuid)
case 'Setting':
return '{}/setting/{}'.format(s.SERVER_URL, obj.uuid)
case _:
raise ValueError(f"Unknown model {obj.__class__.__name__}")
class Migration(migrations.Migration):
dependencies = [
('epdb', '0002_externaldatabase_alter_apitoken_options_and_more'),
]
operations = [
migrations.AddField(
model_name='applicabilitydomain',
name='url',
field=models.TextField(null=True, unique=False, verbose_name='URL'),
),
migrations.AddField(
model_name='compound',
name='url',
field=models.TextField(null=True, unique=False, verbose_name='URL'),
),
migrations.AddField(
model_name='compoundstructure',
name='url',
field=models.TextField(null=True, unique=False, verbose_name='URL'),
),
migrations.AddField(
model_name='edge',
name='url',
field=models.TextField(null=True, unique=False, verbose_name='URL'),
),
migrations.AddField(
model_name='epmodel',
name='url',
field=models.TextField(null=True, unique=False, verbose_name='URL'),
),
migrations.AddField(
model_name='group',
name='url',
field=models.TextField(null=True, unique=False, verbose_name='URL'),
),
migrations.AddField(
model_name='node',
name='url',
field=models.TextField(null=True, unique=False, verbose_name='URL'),
),
migrations.AddField(
model_name='package',
name='url',
field=models.TextField(null=True, unique=False, verbose_name='URL'),
),
migrations.AddField(
model_name='pathway',
name='url',
field=models.TextField(null=True, unique=False, verbose_name='URL'),
),
migrations.AddField(
model_name='reaction',
name='url',
field=models.TextField(null=True, unique=False, verbose_name='URL'),
),
migrations.AddField(
model_name='rule',
name='url',
field=models.TextField(null=True, unique=False, verbose_name='URL'),
),
migrations.AddField(
model_name='scenario',
name='url',
field=models.TextField(null=True, unique=False, verbose_name='URL'),
),
migrations.AddField(
model_name='setting',
name='url',
field=models.TextField(null=True, unique=False, verbose_name='URL'),
),
migrations.AddField(
model_name='user',
name='url',
field=models.TextField(null=True, unique=False, verbose_name='URL'),
),
migrations.RunPython(populate_url, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name='applicabilitydomain',
name='url',
field=models.TextField(null=True, unique=True, verbose_name='URL'),
),
migrations.AlterField(
model_name='compound',
name='url',
field=models.TextField(null=True, unique=True, verbose_name='URL'),
),
migrations.AlterField(
model_name='compoundstructure',
name='url',
field=models.TextField(null=True, unique=True, verbose_name='URL'),
),
migrations.AlterField(
model_name='edge',
name='url',
field=models.TextField(null=True, unique=True, verbose_name='URL'),
),
migrations.AlterField(
model_name='epmodel',
name='url',
field=models.TextField(null=True, unique=True, verbose_name='URL'),
),
migrations.AlterField(
model_name='group',
name='url',
field=models.TextField(null=True, unique=True, verbose_name='URL'),
),
migrations.AlterField(
model_name='node',
name='url',
field=models.TextField(null=True, unique=True, verbose_name='URL'),
),
migrations.AlterField(
model_name='package',
name='url',
field=models.TextField(null=True, unique=True, verbose_name='URL'),
),
migrations.AlterField(
model_name='pathway',
name='url',
field=models.TextField(null=True, unique=True, verbose_name='URL'),
),
migrations.AlterField(
model_name='reaction',
name='url',
field=models.TextField(null=True, unique=True, verbose_name='URL'),
),
migrations.AlterField(
model_name='rule',
name='url',
field=models.TextField(null=True, unique=True, verbose_name='URL'),
),
migrations.AlterField(
model_name='scenario',
name='url',
field=models.TextField(null=True, unique=True, verbose_name='URL'),
),
migrations.AlterField(
model_name='setting',
name='url',
field=models.TextField(null=True, unique=True, verbose_name='URL'),
),
migrations.AlterField(
model_name='user',
name='url',
field=models.TextField(null=True, unique=True, verbose_name='URL'),
),
]

View File

@ -1,55 +0,0 @@
# Generated by Django 5.2.1 on 2025-09-09 09:21
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epdb', '0001_squashed_0003_applicabilitydomain_url_compound_url_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='mlrelativereasoning',
options={},
),
migrations.AlterField(
model_name='mlrelativereasoning',
name='data_packages',
field=models.ManyToManyField(related_name='%(app_label)s_%(class)s_data_packages', to='epdb.package', verbose_name='Data Packages'),
),
migrations.AlterField(
model_name='mlrelativereasoning',
name='eval_packages',
field=models.ManyToManyField(related_name='%(app_label)s_%(class)s_eval_packages', to='epdb.package', verbose_name='Evaluation Packages'),
),
migrations.AlterField(
model_name='mlrelativereasoning',
name='rule_packages',
field=models.ManyToManyField(related_name='%(app_label)s_%(class)s_rule_packages', to='epdb.package', verbose_name='Rule Packages'),
),
migrations.CreateModel(
name='RuleBasedRelativeReasoning',
fields=[
('epmodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.epmodel')),
('threshold', models.FloatField(default=0.5)),
('eval_results', models.JSONField(blank=True, default=dict, null=True)),
('model_status', models.CharField(choices=[('INITIAL', 'Initial'), ('INITIALIZING', 'Model is initializing.'), ('BUILDING', 'Model is building.'), ('BUILT_NOT_EVALUATED', 'Model is built and can be used for predictions, Model is not evaluated yet.'), ('EVALUATING', 'Model is evaluating'), ('FINISHED', 'Model has finished building and evaluation.'), ('ERROR', 'Model has failed.')], default='INITIAL')),
('min_count', models.IntegerField(default=10)),
('max_count', models.IntegerField(default=0)),
('app_domain', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.applicabilitydomain')),
('data_packages', models.ManyToManyField(related_name='%(app_label)s_%(class)s_data_packages', to='epdb.package', verbose_name='Data Packages')),
('eval_packages', models.ManyToManyField(related_name='%(app_label)s_%(class)s_eval_packages', to='epdb.package', verbose_name='Evaluation Packages')),
('rule_packages', models.ManyToManyField(related_name='%(app_label)s_%(class)s_rule_packages', to='epdb.package', verbose_name='Rule Packages')),
],
options={
'abstract': False,
},
bases=('epdb.epmodel',),
),
migrations.DeleteModel(
name='RuleBaseRelativeReasoning',
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.1 on 2025-09-11 06:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epdb', '0004_alter_mlrelativereasoning_options_and_more'),
]
operations = [
migrations.AlterField(
model_name='group',
name='group_member',
field=models.ManyToManyField(blank=True, related_name='groups_in_group', to='epdb.group', verbose_name='Group member'),
),
]

View File

@ -1,23 +0,0 @@
# Generated by Django 5.2.1 on 2025-09-18 06:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epdb', '0005_alter_group_group_member'),
]
operations = [
migrations.AddField(
model_name='mlrelativereasoning',
name='multigen_eval',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='rulebasedrelativereasoning',
name='multigen_eval',
field=models.BooleanField(default=False),
),
]

View File

@ -1,53 +0,0 @@
# Generated by Django 5.2.1 on 2025-10-07 08:19
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epdb', '0006_mlrelativereasoning_multigen_eval_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='enviformer',
options={},
),
migrations.AddField(
model_name='enviformer',
name='app_domain',
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.applicabilitydomain'),
),
migrations.AddField(
model_name='enviformer',
name='data_packages',
field=models.ManyToManyField(related_name='%(app_label)s_%(class)s_data_packages', to='epdb.package', verbose_name='Data Packages'),
),
migrations.AddField(
model_name='enviformer',
name='eval_packages',
field=models.ManyToManyField(related_name='%(app_label)s_%(class)s_eval_packages', to='epdb.package', verbose_name='Evaluation Packages'),
),
migrations.AddField(
model_name='enviformer',
name='eval_results',
field=models.JSONField(blank=True, default=dict, null=True),
),
migrations.AddField(
model_name='enviformer',
name='model_status',
field=models.CharField(choices=[('INITIAL', 'Initial'), ('INITIALIZING', 'Model is initializing.'), ('BUILDING', 'Model is building.'), ('BUILT_NOT_EVALUATED', 'Model is built and can be used for predictions, Model is not evaluated yet.'), ('EVALUATING', 'Model is evaluating'), ('FINISHED', 'Model has finished building and evaluation.'), ('ERROR', 'Model has failed.')], default='INITIAL'),
),
migrations.AddField(
model_name='enviformer',
name='multigen_eval',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='enviformer',
name='rule_packages',
field=models.ManyToManyField(related_name='%(app_label)s_%(class)s_rule_packages', to='epdb.package', verbose_name='Rule Packages'),
),
]

View File

@ -1,64 +0,0 @@
# Generated by Django 5.2.7 on 2025-10-10 06:58
import django.db.models.deletion
import django.utils.timezone
import model_utils.fields
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("epdb", "0007_alter_enviformer_options_enviformer_app_domain_and_more"),
]
operations = [
migrations.CreateModel(
name="EnzymeLink",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
(
"created",
model_utils.fields.AutoCreatedField(
default=django.utils.timezone.now, editable=False, verbose_name="created"
),
),
(
"modified",
model_utils.fields.AutoLastModifiedField(
default=django.utils.timezone.now, editable=False, verbose_name="modified"
),
),
(
"uuid",
models.UUIDField(
default=uuid.uuid4, unique=True, verbose_name="UUID of this object"
),
),
("name", models.TextField(default="no name", verbose_name="Name")),
(
"description",
models.TextField(default="no description", verbose_name="Descriptions"),
),
("url", models.TextField(null=True, unique=True, verbose_name="URL")),
("kv", models.JSONField(blank=True, default=dict, null=True)),
("ec_number", models.TextField(verbose_name="EC Number")),
("classification_level", models.IntegerField(verbose_name="Classification Level")),
("linking_method", models.TextField(verbose_name="Linking Method")),
("edge_evidence", models.ManyToManyField(to="epdb.edge")),
("reaction_evidence", models.ManyToManyField(to="epdb.reaction")),
(
"rule",
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="epdb.rule"),
),
],
options={
"abstract": False,
},
),
]

View File

@ -1,66 +0,0 @@
# Generated by Django 5.2.7 on 2025-10-27 09:39
import django.db.models.deletion
import django.utils.timezone
import model_utils.fields
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("epdb", "0008_enzymelink"),
]
operations = [
migrations.CreateModel(
name="JobLog",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
(
"created",
model_utils.fields.AutoCreatedField(
default=django.utils.timezone.now, editable=False, verbose_name="created"
),
),
(
"modified",
model_utils.fields.AutoLastModifiedField(
default=django.utils.timezone.now, editable=False, verbose_name="modified"
),
),
("task_id", models.UUIDField(unique=True)),
("job_name", models.TextField()),
(
"status",
models.CharField(
choices=[
("INITIAL", "Initial"),
("SUCCESS", "Success"),
("FAILURE", "Failure"),
("REVOKED", "Revoked"),
("IGNORED", "Ignored"),
],
default="INITIAL",
max_length=20,
),
),
("done_at", models.DateTimeField(blank=True, default=None, null=True)),
("task_result", models.TextField(blank=True, default=None, null=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
options={
"abstract": False,
},
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.7 on 2025-11-11 14:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("epdb", "0009_joblog"),
]
operations = [
migrations.AddField(
model_name="license",
name="cc_string",
field=models.TextField(default="by-nc-sa", verbose_name="CC string"),
preserve_default=False,
),
]

View File

@ -1,59 +0,0 @@
# Generated by Django 5.2.7 on 2025-11-11 14:13
import re
from django.contrib.postgres.aggregates import ArrayAgg
from django.db import migrations
from django.db.models import Min
def set_cc(apps, schema_editor):
License = apps.get_model("epdb", "License")
# For all existing licenses extract cc_string from link
for license in License.objects.all():
pattern = r"/licenses/([^/]+)/4\.0"
match = re.search(pattern, license.link)
if match:
license.cc_string = match.group(1)
license.save()
else:
raise ValueError(f"Could not find license for {license.link}")
# Ensure we have all licenses
cc_strings = ["by", "by-nc", "by-nc-nd", "by-nc-sa", "by-nd", "by-sa"]
for cc_string in cc_strings:
if not License.objects.filter(cc_string=cc_string).exists():
new_license = License()
new_license.cc_string = cc_string
new_license.link = f"https://creativecommons.org/licenses/{cc_string}/4.0/"
new_license.image_link = f"https://licensebuttons.net/l/{cc_string}/4.0/88x31.png"
new_license.save()
# As we might have existing Licenses representing the same License,
# get min pk and all pks as a list
license_lookup_qs = License.objects.values("cc_string").annotate(
lowest_pk=Min("id"), all_pks=ArrayAgg("id", order_by=("id",))
)
license_lookup = {
row["cc_string"]: (row["lowest_pk"], row["all_pks"]) for row in license_lookup_qs
}
Packages = apps.get_model("epdb", "Package")
for k, v in license_lookup.items():
# Set min pk to all packages pointing to any of the duplicates
Packages.objects.filter(pk__in=v[1]).update(license_id=v[0])
# remove the min pk from "other" pks as we use them for deletion
v[1].remove(v[0])
# Delete redundant License objects
License.objects.filter(pk__in=v[1]).delete()
class Migration(migrations.Migration):
dependencies = [
("epdb", "0010_license_cc_string"),
]
operations = [migrations.RunPython(set_cc)]

View File

@ -1,22 +0,0 @@
# Generated by Django 5.2.7 on 2025-12-02 13:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("epdb", "0011_auto_20251111_1413"),
]
operations = [
migrations.AddField(
model_name="node",
name="stereo_removed",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="pathway",
name="predicted",
field=models.BooleanField(default=False),
),
]

View File

@ -1,25 +0,0 @@
# Generated by Django 5.2.7 on 2025-12-14 11:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("epdb", "0012_node_stereo_removed_pathway_predicted"),
]
operations = [
migrations.AddField(
model_name="setting",
name="expansion_schema",
field=models.CharField(
choices=[
("BFS", "Breadth First Search"),
("DFS", "Depth First Search"),
("GREEDY", "Greedy"),
],
default="BFS",
max_length=20,
),
),
]

View File

@ -1,17 +0,0 @@
# Generated by Django 5.2.7 on 2025-12-14 16:02
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("epdb", "0013_setting_expansion_schema"),
]
operations = [
migrations.RenameField(
model_name="setting",
old_name="expansion_schema",
new_name="expansion_scheme",
),
]

View File

@ -1,17 +0,0 @@
# Generated by Django 5.2.7 on 2026-01-19 19:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("epdb", "0014_rename_expansion_schema_setting_expansion_scheme"),
]
operations = [
migrations.AddField(
model_name="user",
name="is_reviewer",
field=models.BooleanField(default=False),
),
]

View File

@ -1,179 +0,0 @@
# 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

@ -1,93 +0,0 @@
# 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

@ -1,132 +0,0 @@
# 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

@ -1,20 +1,741 @@
# Generated by Django 5.2.7 on 2026-02-23 08:45 # Generated by Django 5.2.7 on 2026-03-06 10:51
from django.db import migrations import django.contrib.auth.models
import django.contrib.auth.validators
import django.contrib.postgres.fields
import django.db.models.deletion
import django.utils.timezone
import model_utils.fields
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True
dependencies = [ dependencies = [
("epdb", "0018_auto_20260220_1203"), ('auth', '0012_alter_user_first_name_max_length'),
('contenttypes', '0002_remove_content_type_name'),
migrations.swappable_dependency(settings.EPDB_PACKAGE_MODEL),
] ]
operations = [ operations = [
migrations.RemoveField( migrations.CreateModel(
model_name="scenario", name='ApplicabilityDomain',
name="additional_information", fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('num_neighbours', models.IntegerField(default=5)),
('reliability_threshold', models.FloatField(default=0.5)),
('local_compatibilty_threshold', models.FloatField(default=0.5)),
('functional_groups', models.JSONField(blank=True, default=dict, null=True)),
],
options={
'abstract': False,
},
), ),
migrations.RemoveField( migrations.CreateModel(
model_name="scenario", name='Edge',
name="parent", fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='EPModel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('model_status', models.CharField(choices=[('INITIAL', 'Initial'), ('INITIALIZING', 'Model is initializing.'), ('BUILDING', 'Model is building.'), ('BUILT_NOT_EVALUATED', 'Model is built and can be used for predictions, Model is not evaluated yet.'), ('EVALUATING', 'Model is evaluating'), ('FINISHED', 'Model has finished building and evaluation.'), ('ERROR', 'Model has failed.')], default='INITIAL')),
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.EPDB_PACKAGE_MODEL, verbose_name='Package')),
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
),
migrations.CreateModel(
name='ExternalDatabase',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('name', models.CharField(max_length=100, unique=True, verbose_name='Database Name')),
('full_name', models.CharField(blank=True, max_length=255, verbose_name='Full Database Name')),
('description', models.TextField(blank=True, verbose_name='Description')),
('base_url', models.URLField(blank=True, null=True, verbose_name='Base URL')),
('url_pattern', models.CharField(blank=True, help_text="URL pattern with {id} placeholder, e.g., 'https://pubchem.ncbi.nlm.nih.gov/compound/{id}'", max_length=500, verbose_name='URL Pattern')),
('is_active', models.BooleanField(default=True, verbose_name='Is Active')),
],
options={
'verbose_name': 'External Database',
'verbose_name_plural': 'External Databases',
'db_table': 'epdb_external_database',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Permission',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('permission', models.CharField(choices=[('read', 'Read'), ('write', 'Write'), ('all', 'All')], max_length=32)),
],
),
migrations.CreateModel(
name='License',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('cc_string', models.TextField(verbose_name='CC string')),
('link', models.URLField(verbose_name='link')),
('image_link', models.URLField(verbose_name='Image link')),
],
),
migrations.CreateModel(
name='Rule',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')),
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.EPDB_PACKAGE_MODEL, verbose_name='Package')),
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
),
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('email', models.EmailField(max_length=254, unique=True)),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('is_reviewer', models.BooleanField(default=False)),
('default_package', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.EPDB_PACKAGE_MODEL, verbose_name='Default Package')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name='APIToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('hashed_key', models.CharField(help_text='SHA-256 hash of the token key', max_length=128, unique=True)),
('expires_at', models.DateTimeField(blank=True, help_text='Token expiration time (null for no expiration)', null=True)),
('name', models.CharField(help_text='Descriptive name for this token', max_length=100)),
('is_active', models.BooleanField(default=True, help_text='Whether this token is active')),
('user', models.ForeignKey(help_text='User who owns this token', on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'API Token',
'verbose_name_plural': 'API Tokens',
'db_table': 'epdb_api_token',
'ordering': ['-created'],
},
),
migrations.CreateModel(
name='Compound',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')),
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.EPDB_PACKAGE_MODEL, verbose_name='Package')),
],
),
migrations.CreateModel(
name='CompoundStructure',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')),
('smiles', models.TextField(verbose_name='SMILES')),
('canonical_smiles', models.TextField(verbose_name='Canonical SMILES')),
('inchikey', models.TextField(max_length=27, verbose_name='InChIKey')),
('normalized_structure', models.BooleanField(default=False)),
('compound', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.compound')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='compound',
name='default_structure',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='compound_default_structure', to='epdb.compoundstructure', verbose_name='Default Structure'),
),
migrations.CreateModel(
name='PropertyPluginModel',
fields=[
('epmodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.epmodel')),
('threshold', models.FloatField(default=0.5)),
('eval_results', models.JSONField(blank=True, default=dict, null=True)),
('multigen_eval', models.BooleanField(default=False)),
('plugin_identifier', models.CharField(max_length=255)),
],
options={
'abstract': False,
},
bases=('epdb.epmodel',),
),
migrations.CreateModel(
name='Group',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('name', models.TextField(verbose_name='Group name')),
('public', models.BooleanField(default=False, verbose_name='Public Group')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('group_member', models.ManyToManyField(blank=True, related_name='groups_in_group', to='epdb.group', verbose_name='Group member')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Group Owner')),
('user_member', models.ManyToManyField(related_name='users_in_group', to=settings.AUTH_USER_MODEL, verbose_name='User members')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='user',
name='default_group',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_group', to='epdb.group', verbose_name='Default Group'),
),
migrations.CreateModel(
name='JobLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('task_id', models.UUIDField(unique=True)),
('job_name', models.TextField()),
('status', models.CharField(choices=[('INITIAL', 'Initial'), ('SUCCESS', 'Success'), ('FAILURE', 'Failure'), ('REVOKED', 'Revoked'), ('IGNORED', 'Ignored')], default='INITIAL', max_length=20)),
('done_at', models.DateTimeField(blank=True, default=None, null=True)),
('task_result', models.TextField(blank=True, default=None, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Package',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('reviewed', models.BooleanField(default=False, verbose_name='Reviewstatus')),
('license', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.license', verbose_name='License')),
],
options={
'swappable': 'EPDB_PACKAGE_MODEL',
},
),
migrations.CreateModel(
name='Node',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')),
('depth', models.IntegerField(verbose_name='Node depth')),
('stereo_removed', models.BooleanField(default=False)),
('default_node_label', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='default_node_structure', to='epdb.compoundstructure', verbose_name='Default Node Label')),
('node_labels', models.ManyToManyField(related_name='node_structures', to='epdb.compoundstructure', verbose_name='All Node Labels')),
('out_edges', models.ManyToManyField(to='epdb.edge', verbose_name='Outgoing Edges')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='edge',
name='end_nodes',
field=models.ManyToManyField(related_name='edge_products', to='epdb.node', verbose_name='End Nodes'),
),
migrations.AddField(
model_name='edge',
name='start_nodes',
field=models.ManyToManyField(related_name='edge_educts', to='epdb.node', verbose_name='Start Nodes'),
),
migrations.CreateModel(
name='SequentialRule',
fields=[
('rule_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.rule')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('epdb.rule',),
),
migrations.CreateModel(
name='SimpleRule',
fields=[
('rule_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.rule')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('epdb.rule',),
),
migrations.CreateModel(
name='Pathway',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')),
('predicted', models.BooleanField(default=False)),
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.EPDB_PACKAGE_MODEL, verbose_name='Package')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='node',
name='pathway',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.pathway', verbose_name='belongs to'),
),
migrations.AddField(
model_name='edge',
name='pathway',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.pathway', verbose_name='belongs to'),
),
migrations.CreateModel(
name='Reaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('aliases', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=list, size=None, verbose_name='Aliases')),
('multi_step', models.BooleanField(verbose_name='Multistep Reaction')),
('medline_references', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), null=True, size=None, verbose_name='Medline References')),
('educts', models.ManyToManyField(related_name='reaction_educts', to='epdb.compoundstructure', verbose_name='Educts')),
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.EPDB_PACKAGE_MODEL, verbose_name='Package')),
('products', models.ManyToManyField(related_name='reaction_products', to='epdb.compoundstructure', verbose_name='Products')),
('rules', models.ManyToManyField(related_name='reaction_rule', to='epdb.rule', verbose_name='Rule')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='EnzymeLink',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('ec_number', models.TextField(verbose_name='EC Number')),
('classification_level', models.IntegerField(verbose_name='Classification Level')),
('linking_method', models.TextField(verbose_name='Linking Method')),
('edge_evidence', models.ManyToManyField(to='epdb.edge')),
('rule', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.rule')),
('reaction_evidence', models.ManyToManyField(to='epdb.reaction')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='edge',
name='edge_label',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.reaction', verbose_name='Edge label'),
),
migrations.CreateModel(
name='Scenario',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('scenario_date', models.CharField(default='No date', max_length=256)),
('scenario_type', models.CharField(default='Not specified', max_length=256)),
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.EPDB_PACKAGE_MODEL, verbose_name='Package')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='rule',
name='scenarios',
field=models.ManyToManyField(to='epdb.scenario', verbose_name='Attached Scenarios'),
),
migrations.AddField(
model_name='reaction',
name='scenarios',
field=models.ManyToManyField(to='epdb.scenario', verbose_name='Attached Scenarios'),
),
migrations.AddField(
model_name='pathway',
name='scenarios',
field=models.ManyToManyField(to='epdb.scenario', verbose_name='Attached Scenarios'),
),
migrations.AddField(
model_name='node',
name='scenarios',
field=models.ManyToManyField(to='epdb.scenario', verbose_name='Attached Scenarios'),
),
migrations.AddField(
model_name='edge',
name='scenarios',
field=models.ManyToManyField(to='epdb.scenario', verbose_name='Attached Scenarios'),
),
migrations.AddField(
model_name='compoundstructure',
name='scenarios',
field=models.ManyToManyField(to='epdb.scenario', verbose_name='Attached Scenarios'),
),
migrations.AddField(
model_name='compound',
name='scenarios',
field=models.ManyToManyField(to='epdb.scenario', verbose_name='Attached Scenarios'),
),
migrations.CreateModel(
name='Setting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
('name', models.TextField(default='no name', verbose_name='Name')),
('description', models.TextField(default='no description', verbose_name='Descriptions')),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('public', models.BooleanField(default=False)),
('global_default', models.BooleanField(default=False)),
('max_depth', models.IntegerField(default=5, verbose_name='Setting Max Depth')),
('max_nodes', models.IntegerField(default=30, verbose_name='Setting Max Number of Nodes')),
('model_threshold', models.FloatField(blank=True, default=0.25, null=True, verbose_name='Setting Model Threshold')),
('expansion_scheme', models.CharField(choices=[('BFS', 'Breadth First Search'), ('DFS', 'Depth First Search'), ('GREEDY', 'Greedy')], default='BFS', max_length=20)),
('model', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.epmodel', verbose_name='Setting EPModel')),
('rule_packages', models.ManyToManyField(blank=True, related_name='setting_rule_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Setting Rule Packages')),
('property_models', models.ManyToManyField(blank=True, related_name='settings', to='epdb.propertypluginmodel', verbose_name='Setting Property Models')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='pathway',
name='setting',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='epdb.setting', verbose_name='Setting'),
),
migrations.AddField(
model_name='user',
name='default_setting',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.setting', verbose_name='The users default settings'),
),
migrations.CreateModel(
name='EnviFormer',
fields=[
('epmodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.epmodel')),
('threshold', models.FloatField(default=0.5)),
('eval_results', models.JSONField(blank=True, default=dict, null=True)),
('multigen_eval', models.BooleanField(default=False)),
('app_domain', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.applicabilitydomain')),
('data_packages', models.ManyToManyField(related_name='%(app_label)s_%(class)s_data_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Data Packages')),
('eval_packages', models.ManyToManyField(blank=True, related_name='%(app_label)s_%(class)s_eval_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Evaluation Packages')),
('rule_packages', models.ManyToManyField(blank=True, related_name='%(app_label)s_%(class)s_rule_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Rule Packages')),
],
options={
'abstract': False,
},
bases=('epdb.epmodel',),
),
migrations.CreateModel(
name='MLRelativeReasoning',
fields=[
('epmodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.epmodel')),
('threshold', models.FloatField(default=0.5)),
('eval_results', models.JSONField(blank=True, default=dict, null=True)),
('multigen_eval', models.BooleanField(default=False)),
('app_domain', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.applicabilitydomain')),
('data_packages', models.ManyToManyField(related_name='%(app_label)s_%(class)s_data_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Data Packages')),
('eval_packages', models.ManyToManyField(blank=True, related_name='%(app_label)s_%(class)s_eval_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Evaluation Packages')),
('rule_packages', models.ManyToManyField(blank=True, related_name='%(app_label)s_%(class)s_rule_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Rule Packages')),
],
options={
'abstract': False,
},
bases=('epdb.epmodel',),
),
migrations.AddField(
model_name='applicabilitydomain',
name='model',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.mlrelativereasoning'),
),
migrations.AddField(
model_name='propertypluginmodel',
name='app_domain',
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.applicabilitydomain'),
),
migrations.AddField(
model_name='propertypluginmodel',
name='data_packages',
field=models.ManyToManyField(blank=True, related_name='%(app_label)s_%(class)s_data_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Data Packages'),
),
migrations.AddField(
model_name='propertypluginmodel',
name='eval_packages',
field=models.ManyToManyField(blank=True, related_name='%(app_label)s_%(class)s_eval_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Evaluation Packages'),
),
migrations.AddField(
model_name='propertypluginmodel',
name='rule_packages',
field=models.ManyToManyField(blank=True, related_name='%(app_label)s_%(class)s_rule_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Rule Packages'),
),
migrations.CreateModel(
name='RuleBasedRelativeReasoning',
fields=[
('epmodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.epmodel')),
('threshold', models.FloatField(default=0.5)),
('eval_results', models.JSONField(blank=True, default=dict, null=True)),
('multigen_eval', models.BooleanField(default=False)),
('min_count', models.IntegerField(default=10)),
('max_count', models.IntegerField(default=0)),
('app_domain', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.applicabilitydomain')),
('data_packages', models.ManyToManyField(related_name='%(app_label)s_%(class)s_data_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Data Packages')),
('eval_packages', models.ManyToManyField(blank=True, related_name='%(app_label)s_%(class)s_eval_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Evaluation Packages')),
('rule_packages', models.ManyToManyField(blank=True, related_name='%(app_label)s_%(class)s_rule_packages', to=settings.EPDB_PACKAGE_MODEL, verbose_name='Rule Packages')),
],
options={
'abstract': False,
},
bases=('epdb.epmodel',),
),
migrations.CreateModel(
name='ExternalIdentifier',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('object_id', models.IntegerField()),
('identifier_value', models.CharField(max_length=255, verbose_name='Identifier Value')),
('url', models.URLField(blank=True, null=True, verbose_name='Direct URL')),
('is_primary', models.BooleanField(default=False, help_text='Mark this as the primary identifier for this database', verbose_name='Is Primary')),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
('database', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.externaldatabase', verbose_name='External Database')),
],
options={
'verbose_name': 'External Identifier',
'verbose_name_plural': 'External Identifiers',
'db_table': 'epdb_external_identifier',
'indexes': [models.Index(fields=['content_type', 'object_id'], name='epdb_extern_content_b76813_idx'), models.Index(fields=['database', 'identifier_value'], name='epdb_extern_databas_486422_idx')],
'unique_together': {('content_type', 'object_id', 'database', 'identifier_value')},
},
),
migrations.CreateModel(
name='SimpleAmbitRule',
fields=[
('simplerule_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.simplerule')),
('smirks', models.TextField(verbose_name='SMIRKS')),
('reactant_filter_smarts', models.TextField(null=True, verbose_name='Reactant Filter SMARTS')),
('product_filter_smarts', models.TextField(null=True, verbose_name='Product Filter SMARTS')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('epdb.simplerule',),
),
migrations.CreateModel(
name='SimpleRDKitRule',
fields=[
('simplerule_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.simplerule')),
('reaction_smarts', models.TextField(verbose_name='SMIRKS')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('epdb.simplerule',),
),
migrations.CreateModel(
name='SequentialRuleOrdering',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('order_index', models.IntegerField()),
('sequential_rule', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.sequentialrule')),
('simple_rule', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.simplerule')),
],
),
migrations.AddField(
model_name='sequentialrule',
name='simple_rules',
field=models.ManyToManyField(through='epdb.SequentialRuleOrdering', to='epdb.simplerule', verbose_name='Simple rules'),
),
migrations.CreateModel(
name='ParallelRule',
fields=[
('rule_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.rule')),
('simple_rules', models.ManyToManyField(to='epdb.simplerule', verbose_name='Simple rules')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('epdb.rule',),
),
migrations.AlterUniqueTogether(
name='compound',
unique_together={('uuid', 'package')},
),
migrations.CreateModel(
name='AdditionalInformation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
('kv', models.JSONField(blank=True, default=dict, null=True)),
('type', models.TextField(verbose_name='Additional Information Type')),
('data', models.JSONField(blank=True, default=dict, null=True)),
('object_id', models.PositiveBigIntegerField(blank=True, null=True)),
('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.EPDB_PACKAGE_MODEL, verbose_name='Package')),
('scenario', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='scenario_additional_information', to='epdb.scenario')),
],
options={
'indexes': [models.Index(fields=['type'], name='epdb_additi_type_394349_idx'), models.Index(fields=['scenario', 'type'], name='epdb_additi_scenari_a59edf_idx'), models.Index(fields=['content_type', 'object_id'], name='epdb_additi_content_44d4b4_idx'), models.Index(fields=['scenario', 'content_type', 'object_id'], name='epdb_additi_scenari_ef2bf5_idx')],
'constraints': [models.CheckConstraint(condition=models.Q(models.Q(('content_type__isnull', True), ('object_id__isnull', True)), models.Q(('content_type__isnull', False), ('object_id__isnull', False)), _connector='OR'), name='ck_addinfo_gfk_pair'), models.CheckConstraint(condition=models.Q(('scenario__isnull', False), ('content_type__isnull', False), _connector='OR'), name='ck_addinfo_not_both_null')],
},
),
migrations.CreateModel(
name='GroupPackagePermission',
fields=[
('permission_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, to='epdb.permission')),
('uuid', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='UUID of this object')),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.group', verbose_name='Permission to')),
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.EPDB_PACKAGE_MODEL, verbose_name='Permission on')),
],
options={
'unique_together': {('package', 'group')},
},
bases=('epdb.permission',),
),
migrations.CreateModel(
name='UserPackagePermission',
fields=[
('permission_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, to='epdb.permission')),
('uuid', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='UUID of this object')),
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.EPDB_PACKAGE_MODEL, verbose_name='Permission on')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Permission to')),
],
options={
'unique_together': {('package', 'user')},
},
bases=('epdb.permission',),
),
migrations.CreateModel(
name='UserSettingPermission',
fields=[
('permission_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, to='epdb.permission')),
('uuid', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='UUID of this object')),
('setting', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='epdb.setting', verbose_name='Permission on')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Permission to')),
],
options={
'unique_together': {('setting', 'user')},
},
bases=('epdb.permission',),
), ),
] ]

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,54 @@
# 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"),
),
migrations.AddField(
model_name="group",
name="secret",
field=models.BooleanField(default=False, verbose_name="Secret Group"),
),
]

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

View File

@ -30,7 +30,7 @@ from sklearn.metrics import jaccard_score, precision_score, recall_score
from sklearn.model_selection import ShuffleSplit from sklearn.model_selection import ShuffleSplit
from bridge.contracts import Property from bridge.contracts import Property
from bridge.dto import RunResult, PredictedProperty from bridge.dto import RunResult, PropertyPrediction
from utilities.chem import FormatConverter, IndigoUtils, PredictionResult, ProductSet from utilities.chem import FormatConverter, IndigoUtils, PredictionResult, ProductSet
from utilities.ml import ( from utilities.ml import (
ApplicabilityDomainPCA, ApplicabilityDomainPCA,
@ -75,6 +75,7 @@ class User(AbstractUser):
blank=False, blank=False,
) )
is_reviewer = models.BooleanField(default=False) is_reviewer = models.BooleanField(default=False)
contacted = models.BooleanField(null=True, blank=True)
USERNAME_FIELD = "email" USERNAME_FIELD = "email"
REQUIRED_FIELDS = ["username"] REQUIRED_FIELDS = ["username"]
@ -203,6 +204,7 @@ class Group(TimeStampedModel):
name = models.TextField(blank=False, null=False, verbose_name="Group name") name = models.TextField(blank=False, null=False, verbose_name="Group name")
owner = models.ForeignKey("User", verbose_name="Group Owner", on_delete=models.CASCADE) owner = models.ForeignKey("User", verbose_name="Group Owner", on_delete=models.CASCADE)
public = models.BooleanField(verbose_name="Public Group", default=False) public = models.BooleanField(verbose_name="Public Group", default=False)
secret = models.BooleanField(verbose_name="Secret Group", default=False)
description = models.TextField( description = models.TextField(
blank=False, null=False, verbose_name="Descriptions", default="no description" blank=False, null=False, verbose_name="Descriptions", default="no description"
) )
@ -765,7 +767,12 @@ class Package(EnviPathModel):
class Compound( class Compound(
EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin, AdditionalInformationMixin PolymorphicModel,
EnviPathModel,
AliasMixin,
ScenarioMixin,
ChemicalIdentifierMixin,
AdditionalInformationMixin,
): ):
package = models.ForeignKey( package = models.ForeignKey(
s.EPDB_PACKAGE_MODEL, verbose_name="Package", on_delete=models.CASCADE, db_index=True s.EPDB_PACKAGE_MODEL, verbose_name="Package", on_delete=models.CASCADE, db_index=True
@ -861,18 +868,25 @@ class Compound(
standardized_smiles = FormatConverter.standardize(smiles, remove_stereo=True) standardized_smiles = FormatConverter.standardize(smiles, remove_stereo=True)
subclasses = CompoundStructure.__subclasses__()
qs = CompoundStructure.objects.filter(smiles=smiles, compound__package=package)
if subclasses:
qs = qs.not_instance_of(*subclasses)
# Check if we find a direct match for a given SMILES # Check if we find a direct match for a given SMILES
if CompoundStructure.objects.filter(smiles=smiles, compound__package=package).exists(): if qs.exists():
return CompoundStructure.objects.get(smiles=smiles, compound__package=package).compound return qs.first().compound
qs = CompoundStructure.objects.filter(smiles=standardized_smiles, compound__package=package)
if subclasses:
qs = qs.not_instance_of(*subclasses)
# Check if we can find the standardized one # Check if we can find the standardized one
if CompoundStructure.objects.filter( if qs.exists():
smiles=standardized_smiles, compound__package=package
).exists():
# TODO should we add a structure? # TODO should we add a structure?
return CompoundStructure.objects.get( return qs.first().compound
smiles=standardized_smiles, compound__package=package
).compound
# Generate Compound # Generate Compound
c = Compound() c = Compound()
@ -1095,13 +1109,19 @@ class Compound(
class CompoundStructure( class CompoundStructure(
EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin, AdditionalInformationMixin PolymorphicModel,
EnviPathModel,
AliasMixin,
ScenarioMixin,
ChemicalIdentifierMixin,
AdditionalInformationMixin,
): ):
compound = models.ForeignKey("epdb.Compound", on_delete=models.CASCADE, db_index=True) compound = models.ForeignKey("epdb.Compound", on_delete=models.CASCADE, db_index=True)
smiles = models.TextField(blank=False, null=False, verbose_name="SMILES") smiles = models.TextField(blank=False, null=False, verbose_name="SMILES")
canonical_smiles = models.TextField(blank=False, null=False, verbose_name="Canonical SMILES") canonical_smiles = models.TextField(blank=False, null=False, verbose_name="Canonical SMILES")
inchikey = models.TextField(max_length=27, blank=False, null=False, verbose_name="InChIKey") inchikey = models.TextField(max_length=27, blank=False, null=False, verbose_name="InChIKey")
normalized_structure = models.BooleanField(null=False, blank=False, default=False) normalized_structure = models.BooleanField(null=False, blank=False, default=False)
molfile = models.TextField(blank=True, null=True, verbose_name="Molfile")
external_identifiers = GenericRelation("ExternalIdentifier") external_identifiers = GenericRelation("ExternalIdentifier")
@ -1198,6 +1218,9 @@ class CompoundStructure(
return dict(hls) return dict(hls)
def d3_json(self):
return {}
class EnzymeLink(EnviPathModel, KEGGIdentifierMixin): class EnzymeLink(EnviPathModel, KEGGIdentifierMixin):
rule = models.ForeignKey("Rule", on_delete=models.CASCADE, db_index=True) rule = models.ForeignKey("Rule", on_delete=models.CASCADE, db_index=True)
@ -1775,9 +1798,9 @@ class Reaction(
edges = Edge.objects.filter(edge_label=self) edges = Edge.objects.filter(edge_label=self)
for e in edges: for e in edges:
for scen in e.scenarios.all(): for scen in e.scenarios.all():
for ai in scen.additional_information.keys(): for ai in scen.get_additional_information():
if ai == "Enzyme": if ai.type == "Enzyme":
res.extend(scen.additional_information[ai]) res.append(ai.get())
return res return res
@ -2163,6 +2186,56 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
): ):
return Edge.create(self, start_nodes, end_nodes, rule, name=name, description=description) return Edge.create(self, start_nodes, end_nodes, rule, name=name, description=description)
def update_depths(self):
# Collect number of in and out links per node
in_count = defaultdict(lambda: 0)
out_count = defaultdict(lambda: 0)
for e in self.edges:
for react in e.start_nodes.all():
out_count[str(react.uuid)] += 1
for prod in e.end_nodes.all():
in_count[str(prod.uuid)] += 1
depth_map = {}
depth_map[0] = list()
for n in self.nodes:
num_parents = in_count[str(n.uuid)]
if num_parents == 0:
# must be a root node or unconnected node
if n.depth != 0:
n.depth = 0
n.save()
# Only root node may have children
if out_count[str(n.uuid)] > 0:
depth_map[0].append(n)
# At most depth len(nodes) is possible
for i in range(self.nodes.count()):
level_nodes = depth_map.get(i, [])
if len(level_nodes) == 0:
break
unique_next_level = set()
for n in level_nodes:
for e in self.edges:
if n in e.start_nodes.all():
for p in e.end_nodes.all():
unique_next_level.add(p)
if len(unique_next_level) > 0:
depth_map[i + 1] = list(unique_next_level)
for depth, nodes in depth_map.items():
for n in nodes:
if n.depth != depth:
n.depth = depth
n.save()
class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin): class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin):
pathway = models.ForeignKey( pathway = models.ForeignKey(
@ -2201,10 +2274,14 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
predicted_properties = defaultdict(list) predicted_properties = defaultdict(list)
for ai in self.additional_information.all(): for ai in self.additional_information.all():
if isinstance(ai.get(), PredictedProperty): if isinstance(ai.get(), PropertyPrediction):
predicted_properties[ai.get().__class__.__name__].append(ai.data) predicted_properties[ai.get().__class__.__name__].append(ai.data)
return { # If we have Subclasses of a CompoundStructure we can overwrite keys (e.g. images)
# by overwriting keys
structure_data = self.default_node_label.d3_json()
res = {
"depth": self.depth, "depth": self.depth,
"stereo_removed": self.stereo_removed, "stereo_removed": self.stereo_removed,
"url": self.url, "url": self.url,
@ -2213,6 +2290,7 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
"image_svg": IndigoUtils.mol_to_svg( "image_svg": IndigoUtils.mol_to_svg(
self.default_node_label.smiles, width=40, height=40 self.default_node_label.smiles, width=40, height=40
), ),
"image_type": "svg",
"name": self.get_name(), "name": self.get_name(),
"smiles": self.default_node_label.smiles, "smiles": self.default_node_label.smiles,
"scenarios": [{"name": s.get_name(), "url": s.url} for s in self.scenarios.all()], "scenarios": [{"name": s.get_name(), "url": s.url} for s in self.scenarios.all()],
@ -2225,8 +2303,11 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
"predicted_properties": predicted_properties, "predicted_properties": predicted_properties,
"is_engineered_intermediate": self.kv.get("is_engineered_intermediate", False), "is_engineered_intermediate": self.kv.get("is_engineered_intermediate", False),
"timeseries": self.get_timeseries_data(), "timeseries": self.get_timeseries_data(),
**structure_data,
} }
return res
@staticmethod @staticmethod
@transaction.atomic @transaction.atomic
def create( def create(
@ -2334,7 +2415,10 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
"reaction_probability": self.kv.get("probability"), "reaction_probability": self.kv.get("probability"),
"start_node_urls": [x.url for x in self.start_nodes.all()], "start_node_urls": [x.url for x in self.start_nodes.all()],
"end_node_urls": [x.url for x in self.end_nodes.all()], "end_node_urls": [x.url for x in self.end_nodes.all()],
"scenarios": [{"name": s.get_name(), "url": s.url} for s in self.scenarios.all()], "scenarios": [
{"name": s.get_name(), "url": s.url, "review_status": s.package.reviewed}
for s in self.scenarios.all()
],
} }
for n in self.start_nodes.all(): for n in self.start_nodes.all():
@ -2486,6 +2570,7 @@ class PackageBasedModel(EPModel):
s.EPDB_PACKAGE_MODEL, s.EPDB_PACKAGE_MODEL,
verbose_name="Data Packages", verbose_name="Data Packages",
related_name="%(app_label)s_%(class)s_data_packages", related_name="%(app_label)s_%(class)s_data_packages",
blank=True,
) )
eval_packages = models.ManyToManyField( eval_packages = models.ManyToManyField(
s.EPDB_PACKAGE_MODEL, s.EPDB_PACKAGE_MODEL,
@ -3458,9 +3543,7 @@ class EnviFormer(PackageBasedModel):
def predict_batch(self, smiles: List[str], *args, **kwargs): def predict_batch(self, smiles: List[str], *args, **kwargs):
# Standardizer removes all but one compound from a raw SMILES string, so they need to be processed separately # Standardizer removes all but one compound from a raw SMILES string, so they need to be processed separately
canon_smiles = [ canon_smiles = [
".".join( ".".join([FormatConverter.standardize(s, remove_stereo=True) for s in smi.split(".")])
[FormatConverter.standardize(s, remove_stereo=True) for s in smiles.split(".")]
)
for smi in smiles for smi in smiles
] ]
logger.info(f"Submitting {canon_smiles} to {self.get_name()}") logger.info(f"Submitting {canon_smiles} to {self.get_name()}")
@ -3810,6 +3893,211 @@ class EnviFormer(PackageBasedModel):
return [] return []
class ClassifierPluginModel(PackageBasedModel):
plugin_identifier = models.CharField(max_length=255)
plugin_config = JSONField(null=True, blank=True, default=dict)
@staticmethod
@transaction.atomic
def create(
package: "Package",
plugin_identifier: str,
rule_packages: List["Package"] | None,
data_packages: List["Package"] | None,
name: "str" = None,
description: str = None,
config: EnviPyModel | None = None,
):
mod = ClassifierPluginModel()
mod.package = package
# Clean for potential XSS
if name is not None:
name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
if name is None or name == "":
name = f"ClassifierPluginModel {ClassifierPluginModel.objects.filter(package=package).count() + 1}"
mod.name = name
if description is not None and description.strip() != "":
mod.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
if plugin_identifier is None:
raise ValueError("Plugin identifier must be set")
impl = s.CLASSIFIER_PLUGINS.get(plugin_identifier, None)
if impl is None:
raise ValueError(f"Unknown plugin identifier: {plugin_identifier}")
mod.plugin_identifier = plugin_identifier
mod.plugin_config = config.__class__(
**json.loads(nh3.clean(config.model_dump_json()).strip())
).model_dump(mode="json")
if impl.requires_rule_packages() and (rule_packages is None or len(rule_packages) == 0):
raise ValueError("Plugin requires rules but none were provided")
elif not impl.requires_rule_packages() and (
rule_packages is not None and len(rule_packages) > 0
):
raise ValueError("Plugin does not require rules but some were provided")
if rule_packages is None:
rule_packages = []
if impl.requires_data_packages() and (data_packages is None or len(data_packages) == 0):
raise ValueError("Plugin requires data but none were provided")
elif not impl.requires_data_packages() and (
data_packages is not None and len(data_packages) > 0
):
raise ValueError("Plugin does not require data but some were provided")
if data_packages is None:
data_packages = []
mod.save()
for p in rule_packages:
mod.rule_packages.add(p)
for p in data_packages:
mod.data_packages.add(p)
mod.save()
return mod
def instance(self) -> "Property":
"""
Returns an instance of the plugin implementation.
This method retrieves the implementation of the plugin identified by
`self.plugin_identifier` from the `CLASSIFIER_PLUGINS` mapping, then
instantiates and returns it.
Returns:
object: An instance of the plugin implementation.
"""
impl = s.CLASSIFIER_PLUGINS[self.plugin_identifier]
conf = impl.parse_config(data=self.plugin_config)
instance = impl(conf)
return instance
def build_dataset(self):
"""
Required by general model contract but actual implementation resides in plugin.
"""
return
def build_model(self):
from bridge.dto import BaseDTO
self.model_status = self.BUILDING
self.save()
compounds = CompoundStructure.objects.filter(compound__package__in=self.data_packages.all())
reactions = Reaction.objects.filter(package__in=self.data_packages.all())
rules = Rule.objects.filter(package__in=self.rule_packages.all())
eP = BaseDTO(str(self.uuid), self.url, s.MODEL_DIR, compounds, reactions, rules)
instance = self.instance()
_ = instance.build(eP)
self.model_status = self.BUILT_NOT_EVALUATED
self.save()
def predict(self, smiles, *args, **kwargs) -> List["PredictionResult"]:
return self.predict_batch([smiles], *args, **kwargs)[0]
def predict_batch(self, smiles: List[str], *args, **kwargs) -> List[List["PredictionResult"]]:
from bridge.dto import BaseDTO, CompoundProto
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class TempCompound(CompoundProto):
url = None
name = None
smiles: str
batch = [TempCompound(smiles=smi) for smi in smiles]
reactions = Reaction.objects.filter(package__in=self.data_packages.all())
rules = Rule.objects.filter(package__in=self.rule_packages.all())
eP = BaseDTO(str(self.uuid), self.url, s.MODEL_DIR, batch, reactions, rules)
instance = self.instance()
rr: RunResult = instance.run(eP, *args, **kwargs)
res = []
for smi in smiles:
pred_res = rr.result
if not isinstance(pred_res, list):
pred_res = [pred_res]
for r in pred_res:
if smi == r.substrate:
sub_res = []
for prod, prob in r.products.items():
sub_res.append(PredictionResult([ProductSet(prod.split("."))], prob, None))
res.append(sub_res)
return res
def evaluate_model(self, multigen: bool, eval_packages: List["Package"] = None, **kwargs):
from bridge.dto import BaseDTO
if self.model_status != self.BUILT_NOT_EVALUATED:
raise ValueError("Model must be built before evaluation")
self.model_status = self.EVALUATING
self.save()
if eval_packages is not None:
for p in eval_packages:
self.eval_packages.add(p)
rules = Rule.objects.filter(package__in=self.rule_packages.all())
if self.eval_packages.count() > 0:
reactions = Reaction.objects.filter(package__in=self.data_packages.all())
compounds = CompoundStructure.objects.filter(
compound__package__in=self.data_packages.all()
)
else:
reactions = Reaction.objects.filter(package__in=self.eval_packages.all())
compounds = CompoundStructure.objects.filter(
compound__package__in=self.eval_packages.all()
)
eP = BaseDTO(str(self.uuid), self.url, s.MODEL_DIR, compounds, reactions, rules)
instance = self.instance()
try:
if self.eval_packages.count() > 0:
res = instance.evaluate(eP, **kwargs)
self.eval_results = res.data
else:
res = instance.build_and_evaluate(eP)
self.eval_results = res.data
self.model_status = self.FINISHED
self.save()
except Exception as e:
logger.error(f"Error during evaluation: {type(e).__name__}, {e}")
self.model_status = self.ERROR
self.save()
return res
class PropertyPluginModel(PackageBasedModel): class PropertyPluginModel(PackageBasedModel):
plugin_identifier = models.CharField(max_length=255) plugin_identifier = models.CharField(max_length=255)
@ -4138,7 +4426,7 @@ class Scenario(EnviPathModel):
ais = AdditionalInformation.objects.filter(scenario=self) ais = AdditionalInformation.objects.filter(scenario=self)
if direct_only: if direct_only:
return ais.filter(content_object__isnull=True) return ais.filter(object_id__isnull=True)
else: else:
return ais return ais
@ -4180,7 +4468,6 @@ class AdditionalInformation(models.Model):
ai: "EnviPyModel", ai: "EnviPyModel",
scenario=None, scenario=None,
content_object=None, content_object=None,
skip_cleaning=False,
): ):
add_inf = AdditionalInformation() add_inf = AdditionalInformation()
add_inf.package = package add_inf.package = package
@ -4241,7 +4528,7 @@ class AdditionalInformation(models.Model):
# Generic FK must be complete or empty # Generic FK must be complete or empty
models.CheckConstraint( models.CheckConstraint(
name="ck_addinfo_gfk_pair", name="ck_addinfo_gfk_pair",
check=( condition=(
(Q(content_type__isnull=True) & Q(object_id__isnull=True)) (Q(content_type__isnull=True) & Q(object_id__isnull=True))
| (Q(content_type__isnull=False) & Q(object_id__isnull=False)) | (Q(content_type__isnull=False) & Q(object_id__isnull=False))
), ),
@ -4249,7 +4536,7 @@ class AdditionalInformation(models.Model):
# Disallow "floating" info # Disallow "floating" info
models.CheckConstraint( models.CheckConstraint(
name="ck_addinfo_not_both_null", name="ck_addinfo_not_both_null",
check=Q(scenario__isnull=False) | Q(content_type__isnull=False), condition=Q(scenario__isnull=False) | Q(content_type__isnull=False),
), ),
] ]

17
epdb/template_registry.py Normal file
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 import AnyHttpUrl, ValidationError
from pydantic.type_adapter import TypeAdapter from pydantic.type_adapter import TypeAdapter
from epdb.template_registry import get_templates
register = template.Library() register = template.Library()
url_adapter = TypeAdapter(AnyHttpUrl) url_adapter = TypeAdapter(AnyHttpUrl)
@ -19,3 +21,8 @@ def is_url(value):
return True return True
except ValidationError: except ValidationError:
return False return False
@register.simple_tag
def epdb_slot_templates(slot):
return get_templates(slot)

View File

@ -3,6 +3,7 @@ import logging
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Iterable from typing import Any, Dict, List, Iterable
import requests
import nh3 import nh3
from django.conf import settings as s from django.conf import settings as s
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
@ -32,6 +33,7 @@ from .models import (
APIToken, APIToken,
Compound, Compound,
CompoundStructure, CompoundStructure,
ClassifierPluginModel,
Edge, Edge,
EnviFormer, EnviFormer,
EnzymeLink, EnzymeLink,
@ -147,6 +149,11 @@ def handler500(request):
def login(request): def login(request):
context = get_base_context(request) context = get_base_context(request)
if s.CAP_ENABLED:
context["CAP_ENABLED"] = s.CAP_ENABLED
context["CAP_API_BASE"] = s.CAP_API_BASE
context["CAP_SITE_KEY"] = s.CAP_SITE_KEY
if request.method == "GET": if request.method == "GET":
context["title"] = "enviPath" context["title"] = "enviPath"
context["next"] = request.GET.get("next", "") context["next"] = request.GET.get("next", "")
@ -224,6 +231,11 @@ def logout(request):
def register(request): def register(request):
context = get_base_context(request) context = get_base_context(request)
if s.CAP_ENABLED:
context["CAP_ENABLED"] = s.CAP_ENABLED
context["CAP_API_BASE"] = s.CAP_API_BASE
context["CAP_SITE_KEY"] = s.CAP_SITE_KEY
if request.method == "GET": if request.method == "GET":
# Redirect to unified login page with signup tab # Redirect to unified login page with signup tab
next_url = request.GET.get("next", "") next_url = request.GET.get("next", "")
@ -238,6 +250,33 @@ def register(request):
if next := request.POST.get("next"): if next := request.POST.get("next"):
context["next"] = next context["next"] = next
# Catpcha
if s.CAP_ENABLED:
cap_token = request.POST.get("cap-token")
if not cap_token:
context["message"] = "Missing CAP Token."
return render(request, "static/login.html", context)
verify_url = f"{s.CAP_API_BASE}/{s.CAP_SITE_KEY}/siteverify"
payload = {
"secret": s.CAP_SECRET_KEY,
"response": cap_token,
}
try:
resp = requests.post(verify_url, json=payload, timeout=10)
resp.raise_for_status()
verify_data = resp.json()
except requests.RequestException:
context["message"] = "Captcha verification failed."
return render(request, "static/login.html", context)
if not verify_data.get("success"):
context["message"] = "Captcha check failed. Please try again."
return render(request, "static/login.html", context)
# End Captcha
username = request.POST.get("username", "").strip() username = request.POST.get("username", "").strip()
email = request.POST.get("email", "").strip() email = request.POST.get("email", "").strip()
password = request.POST.get("password", "").strip() password = request.POST.get("password", "").strip()
@ -349,6 +388,9 @@ def get_base_context(request, for_user=None) -> Dict[str, Any]:
"debug": s.DEBUG, "debug": s.DEBUG,
"external_databases": ExternalDatabase.get_databases(), "external_databases": ExternalDatabase.get_databases(),
"site_id": s.MATOMO_SITE_ID, "site_id": s.MATOMO_SITE_ID,
# EDIT START
"secret_groups": Group.objects.filter(secret=True),
# EDIT END
}, },
} }
@ -517,7 +559,7 @@ def packages(request):
# Context for paginated template # Context for paginated template
context["entity_type"] = "package" context["entity_type"] = "package"
context["api_endpoint"] = "/api/v1/packages/" context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/packages/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "packages" context["list_title"] = "packages"
@ -548,10 +590,38 @@ def packages(request):
"package-description", s.DEFAULT_VALUES["description"] "package-description", s.DEFAULT_VALUES["description"]
) )
# EDIT START
data_pool = None
package_classification = request.POST.get("package-classification")
classification = Package.Classification(int(package_classification))
# For SECRET we'll need a data pool which will be an additional perm check later
if classification == Package.Classification.SECRET:
package_data_pool = request.POST.get("package-data-pool")
if package_data_pool is None:
return error(request, "Invalid data pool.", "Data Pool is required!")
data_pool = GroupManager.get_group_by_url(current_user, package_data_pool)
if data_pool is None:
return error(request, "Invalid data pool.", "Data Pool does not exist or no access!")
if not data_pool.secret:
return error(request, "Invalid data pool.", "Data Pool is not a secret group!")
created_package = PackageManager.create_package( created_package = PackageManager.create_package(
current_user, package_name, package_description current_user, package_name, package_description
) )
created_package.classification_level = classification
# Set previously determined data pool
if classification == Package.Classification.SECRET:
created_package.data_pool = data_pool
created_package.save()
# EDIT END
return redirect(created_package.url) return redirect(created_package.url)
elif request.method == "OPTIONS": elif request.method == "OPTIONS":
@ -575,7 +645,7 @@ def compounds(request):
# Context for paginated template # Context for paginated template
context["entity_type"] = "compound" context["entity_type"] = "compound"
context["api_endpoint"] = "/api/v1/compounds/" context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/compounds/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_mode"] = "tabbed" context["list_mode"] = "tabbed"
context["list_title"] = "compounds" context["list_title"] = "compounds"
@ -604,7 +674,7 @@ def rules(request):
# Context for paginated template # Context for paginated template
context["entity_type"] = "rule" context["entity_type"] = "rule"
context["api_endpoint"] = "/api/v1/rules/" context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/rules/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "rules" context["list_title"] = "rules"
@ -632,7 +702,7 @@ def reactions(request):
# Context for paginated template # Context for paginated template
context["entity_type"] = "reaction" context["entity_type"] = "reaction"
context["api_endpoint"] = "/api/v1/reactions/" context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/reactions/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "reactions" context["list_title"] = "reactions"
@ -660,7 +730,7 @@ def pathways(request):
# Context for paginated template # Context for paginated template
context["entity_type"] = "pathway" context["entity_type"] = "pathway"
context["api_endpoint"] = "/api/v1/pathways/" context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/pathways/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "pathways" context["list_title"] = "pathways"
@ -690,7 +760,7 @@ def scenarios(request):
# Context for paginated template # Context for paginated template
context["entity_type"] = "scenario" context["entity_type"] = "scenario"
context["api_endpoint"] = "/api/v1/scenarios/" context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/scenarios/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "scenarios" context["list_title"] = "scenarios"
@ -736,21 +806,21 @@ def models(request):
if s.FLAGS.get("PLUGINS", False): if s.FLAGS.get("PLUGINS", False):
for k, v in s.CLASSIFIER_PLUGINS.items(): for k, v in s.CLASSIFIER_PLUGINS.items():
context["model_types"][v().display()] = { context["model_types"][v.display()] = {
"type": k, "type": k,
"requires_rule_packages": True, "requires_rule_packages": v.requires_rule_packages(),
"requires_data_packages": True, "requires_data_packages": v.requires_data_packages(),
} }
for k, v in s.PROPERTY_PLUGINS.items(): for k, v in s.PROPERTY_PLUGINS.items():
context["model_types"][v().display()] = { context["model_types"][v.display()] = {
"type": k, "type": k,
"requires_rule_packages": v().requires_rule_packages, "requires_rule_packages": v.requires_rule_packages(),
"requires_data_packages": v().requires_data_packages, "requires_data_packages": v.requires_data_packages(),
} }
# Context for paginated template # Context for paginated template
context["entity_type"] = "model" context["entity_type"] = "model"
context["api_endpoint"] = "/api/v1/models/" context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/models/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "models" context["list_title"] = "models"
@ -832,7 +902,7 @@ def package_models(request, package_uuid):
context["object_type"] = "model" context["object_type"] = "model"
context["breadcrumbs"] = breadcrumbs(current_package, "model") context["breadcrumbs"] = breadcrumbs(current_package, "model")
context["entity_type"] = "model" context["entity_type"] = "model"
context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/model/" context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/package/{current_package.uuid}/model/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "models" context["list_title"] = "models"
@ -867,25 +937,30 @@ def package_models(request, package_uuid):
"requires_rule_packages": True, "requires_rule_packages": True,
"requires_data_packages": True, "requires_data_packages": True,
}, },
"EnviFormer": { }
if s.ENVIFORMER_PRESENT:
context["model_types"]["EnviFormer"] = {
"type": "enviformer", "type": "enviformer",
"requires_rule_packages": False, "requires_rule_packages": False,
"requires_data_packages": True, "requires_data_packages": True,
}, },
}
if s.FLAGS.get("PLUGINS", False): if s.FLAGS.get("PLUGINS", False):
for k, v in s.CLASSIFIER_PLUGINS.items(): for k, v in s.CLASSIFIER_PLUGINS.items():
context["model_types"][v().display()] = { context["model_types"][v.display()] = {
"type": k, "type": k,
"requires_rule_packages": True, "requires_rule_packages": v.requires_rule_packages(),
"requires_data_packages": True, "requires_data_packages": v.requires_data_packages(),
"additional_parameters": v.Config.__name__.lower()
if v.Config.__name__ != ""
else None,
} }
for k, v in s.PROPERTY_PLUGINS.items(): for k, v in s.PROPERTY_PLUGINS.items():
context["model_types"][v().display()] = { context["model_types"][v.display()] = {
"type": k, "type": k,
"requires_rule_packages": v().requires_rule_packages, "requires_rule_packages": v.requires_rule_packages(),
"requires_data_packages": v().requires_data_packages, "requires_data_packages": v.requires_data_packages(),
} }
return render(request, "collections/models_paginated.html", context) return render(request, "collections/models_paginated.html", context)
@ -917,7 +992,7 @@ def package_models(request, package_uuid):
params["threshold"] = threshold params["threshold"] = threshold
mod = EnviFormer.create(**params) mod = EnviFormer.create(**params)
elif model_type == "mlrr": elif model_type == "ml-relative-reasoning":
# ML Specific # ML Specific
threshold = float(request.POST.get("model-threshold", 0.5)) threshold = float(request.POST.get("model-threshold", 0.5))
# TODO handle additional fingerprinter # TODO handle additional fingerprinter
@ -941,27 +1016,41 @@ def package_models(request, package_uuid):
params["app_domain_local_compatibility_threshold"] = local_compatibility_threshold params["app_domain_local_compatibility_threshold"] = local_compatibility_threshold
mod = MLRelativeReasoning.create(**params) mod = MLRelativeReasoning.create(**params)
elif model_type == "rbrr": elif model_type == "rule-based-relative-reasoning":
params["rule_packages"] = [ params["rule_packages"] = [
PackageManager.get_package_by_url(current_user, p) for p in rule_packages PackageManager.get_package_by_url(current_user, p) for p in rule_packages
] ]
mod = RuleBasedRelativeReasoning.create(**params) mod = RuleBasedRelativeReasoning.create(**params)
elif s.FLAGS.get("PLUGINS", False) and model_type in s.CLASSIFIER_PLUGINS: elif s.FLAGS.get("PLUGINS", False) and model_type in s.CLASSIFIER_PLUGINS:
pass
elif s.FLAGS.get("PLUGINS", False) and model_type in s.PROPERTY_PLUGINS:
params["plugin_identifier"] = model_type params["plugin_identifier"] = model_type
impl = s.PROPERTY_PLUGINS[model_type] impl = s.CLASSIFIER_PLUGINS[model_type]
inst = impl()
if inst.requires_rule_packages(): if impl.requires_rule_packages():
params["rule_packages"] = [ params["rule_packages"] = [
PackageManager.get_package_by_url(current_user, p) for p in rule_packages PackageManager.get_package_by_url(current_user, p) for p in rule_packages
] ]
else: else:
params["rule_packages"] = [] params["rule_packages"] = []
if not inst.requires_data_packages(): if not impl.requires_data_packages():
params["data_packages"] = []
params["config"] = impl.parse_config(request.POST.dict())
mod = ClassifierPluginModel.create(**params)
elif s.FLAGS.get("PLUGINS", False) and model_type in s.PROPERTY_PLUGINS:
params["plugin_identifier"] = model_type
impl = s.PROPERTY_PLUGINS[model_type]
if impl.requires_rule_packages():
params["rule_packages"] = [
PackageManager.get_package_by_url(current_user, p) for p in rule_packages
]
else:
params["rule_packages"] = []
if not impl.requires_data_packages():
del params["data_packages"] del params["data_packages"]
mod = PropertyPluginModel.create(**params) mod = PropertyPluginModel.create(**params)
@ -1285,7 +1374,7 @@ def package_compounds(request, package_uuid):
context["object_type"] = "compound" context["object_type"] = "compound"
context["breadcrumbs"] = breadcrumbs(current_package, "compound") context["breadcrumbs"] = breadcrumbs(current_package, "compound")
context["entity_type"] = "compound" context["entity_type"] = "compound"
context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/compound/" context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/package/{current_package.uuid}/compound/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_mode"] = "tabbed" context["list_mode"] = "tabbed"
context["list_title"] = "compounds" context["list_title"] = "compounds"
@ -1438,7 +1527,7 @@ def package_compound_structures(request, package_uuid, compound_uuid):
context["entity_type"] = "structure" context["entity_type"] = "structure"
context["page_title"] = f"{current_compound.get_name()} - Structures" context["page_title"] = f"{current_compound.get_name()} - Structures"
context["api_endpoint"] = ( context["api_endpoint"] = (
f"/api/v1/package/{current_package.uuid}/compound/{current_compound.uuid}/structure/" f"{s.SERVER_PATH}/api/v1/package/{current_package.uuid}/compound/{current_compound.uuid}/structure/"
) )
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["compound"] = current_compound context["compound"] = current_compound
@ -1601,7 +1690,7 @@ def package_rules(request, package_uuid):
context["object_type"] = "rule" context["object_type"] = "rule"
context["breadcrumbs"] = breadcrumbs(current_package, "rule") context["breadcrumbs"] = breadcrumbs(current_package, "rule")
context["entity_type"] = "rule" context["entity_type"] = "rule"
context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/rule/" context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/package/{current_package.uuid}/rule/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "rules" context["list_title"] = "rules"
@ -1809,7 +1898,7 @@ def package_reactions(request, package_uuid):
context["object_type"] = "reaction" context["object_type"] = "reaction"
context["breadcrumbs"] = breadcrumbs(current_package, "reaction") context["breadcrumbs"] = breadcrumbs(current_package, "reaction")
context["entity_type"] = "reaction" context["entity_type"] = "reaction"
context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/reaction/" context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/package/{current_package.uuid}/reaction/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "reactions" context["list_title"] = "reactions"
@ -1959,7 +2048,7 @@ def package_pathways(request, package_uuid):
context["object_type"] = "pathway" context["object_type"] = "pathway"
context["breadcrumbs"] = breadcrumbs(current_package, "pathway") context["breadcrumbs"] = breadcrumbs(current_package, "pathway")
context["entity_type"] = "pathway" context["entity_type"] = "pathway"
context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/pathway/" context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/package/{current_package.uuid}/pathway/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "pathways" context["list_title"] = "pathways"
@ -2450,6 +2539,9 @@ def package_pathway_edges(request, package_uuid, pathway_uuid):
substrate_nodes, product_nodes, name=edge_name, description=edge_description substrate_nodes, product_nodes, name=edge_name, description=edge_description
) )
# Update depths as sideeffect of above operation
current_pathway.update_depths()
return redirect(current_pathway.url) return redirect(current_pathway.url)
else: else:
@ -2535,7 +2627,7 @@ def package_scenarios(request, package_uuid):
context["object_type"] = "scenario" context["object_type"] = "scenario"
context["breadcrumbs"] = breadcrumbs(current_package, "scenario") context["breadcrumbs"] = breadcrumbs(current_package, "scenario")
context["entity_type"] = "scenario" context["entity_type"] = "scenario"
context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/scenario/" context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/package/{current_package.uuid}/scenario/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "scenarios" context["list_title"] = "scenarios"
@ -2790,9 +2882,15 @@ def groups(request):
{"Group": s.SERVER_URL + "/group"}, {"Group": s.SERVER_URL + "/group"},
] ]
context["objects"] = Group.objects.all() # Context for paginated template
context["entity_type"] = "group"
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/groups/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "groups"
context["list_mode"] = "combined"
return render(request, "collections/groups_paginated.html", context)
return render(request, "collections/objects_list.html", context)
elif request.method == "POST": elif request.method == "POST":
group_name = request.POST.get("group-name") group_name = request.POST.get("group-name")
group_description = request.POST.get("group-description", s.DEFAULT_VALUES["description"]) group_description = request.POST.get("group-description", s.DEFAULT_VALUES["description"])
@ -2884,7 +2982,7 @@ def settings(request):
# Context for paginated template # Context for paginated template
context["entity_type"] = "setting" context["entity_type"] = "setting"
context["api_endpoint"] = "/api/v1/settings/" context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/settings/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "settings" context["list_title"] = "settings"
context["list_mode"] = "combined" context["list_mode"] = "combined"

0
epiuclid/__init__.py Normal file
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

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