Compare commits

..
Author SHA1 Message Date
Tim Lorsbach 421d33dddc adjusted migration
CI / test (pull_request) Failing after 17s
API CI / api-tests (pull_request) Failing after 24s
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

Wip

minor

PW interactions

API PES

wip

Make Select Widget reflect required

make required generallay available

Update UI if pathway mode is set to build

Added ais

circle adjustments

Initial Zoom, fix AD Creation

wip

auth log, bb4g fix

missing import

Added viz hint if PES is part of reaction

Add Edge check for pes

flip boolean

...

pes

Added extra

...

In / Out Edges Viz, Submitting Button Text

...

Make PES Link clickable

Return proper http response instead of error

Fixed error return, removed unused options

Fix PES Link HTML for other entities

Fixed molfile assignment, adjusted Export

Package Export/Import cycle

highlight Description links

implemented non persistent

Harmonised proposed field in Json output

Added pesLink field to PW Api output

PES Fields in API Output

removed debug

Fix Classification import, Fix PES Deserialization

underline pes link in templates

Fix alter name/desc for node, make /node /edge funcitonal

provide setting link and copy button

Implemented Compound Names / Reaction Names View Option

Unconnected Nodes

Make links thicker, reduce timeout trigger time

Show proposed info in popover

Pathway Build no stereo removal

Include probs in reaction name option viz

Detect clicks outside nodes/edges
2026-07-15 22:17:40 +02:00
22568 changed files with 2192158 additions and 3359 deletions
-62
View File
@@ -1,62 +0,0 @@
name: Build Docker Image
# Trigger when a PR to main/develop is completed.
on:
pull_request:
branches:
- main
- develop
types:
- closed
jobs:
build-and-push:
if: ${{ github.event.pull_request.merged == true }}
runs-on: ubuntu-latest
steps:
# Fetch the repository content for the Docker build context.
- name: Checkout repository
uses: actions/checkout@v4
# Enable Buildx for BuildKit features (incl. SSH mount support).
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Authenticate against the container registry before pushing images.
- name: Log in to container registry
uses: docker/login-action@v3
with:
registry: git.envipath.com
username: ${{ secrets.CI_REGISTRY_USER }}
password: ${{ secrets.CI_REGISTRY_PASSWORD }}
# Generate image tags/labels:
# - PRs targeting main get "latest" and "main-sha"
# - PRs targeting develop get "dev" and "dev-sha"
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: git.envipath.com/envipath/envipy
tags: |
type=raw,value=latest,enable=${{ github.event.pull_request.base.ref == 'main' }}
type=sha,prefix=main-,enable=${{ github.event.pull_request.base.ref == 'main' }}
type=raw,value=dev,enable=${{ github.event.pull_request.base.ref == 'develop' }}
type=sha,prefix=dev-,enable=${{ github.event.pull_request.base.ref == 'develop' }}
# Load SSH key so Docker can pull private git+ssh dependencies during build.
- name: Setup SSH for private git dependencies
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.ENVIPY_CI_PRIVATE_KEY }}
# Build and push the production image; forward SSH agent without registry cache reuse.
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile
push: true
ssh: default
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+2 -2
View File
@@ -7,10 +7,10 @@ repos:
- id: trailing-whitespace
exclude: epiuclid/schemas/
- id: end-of-file-fixer
exclude: ^epiuclid/schemas/|^static/js/ketcher3/
exclude: epiuclid/schemas/
- id: check-yaml
- id: check-added-large-files
exclude: ^static/images/|^epiuclid/schemas/|^fixtures/|^static/js/ketcher3/
exclude: ^static/images/|^epiuclid/schemas/|^fixtures/
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.13.3
+1 -7
View File
@@ -60,10 +60,6 @@ COPY tests tests
COPY utilities utilities
COPY manage.py .
# Used to run migrations etc
COPY entrypoint.sh entrypoint.sh
RUN chmod +x entrypoint.sh
# Install frontend deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
@@ -85,7 +81,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libxext6 \
libfontconfig1 \
nano \
openjdk-21-jre-headless \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -ms /bin/bash django
@@ -107,5 +102,4 @@ USER django
EXPOSE 8000
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["gunicorn", "envipath.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "8"]
CMD ["gunicorn", "envipath.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]
@@ -1,18 +0,0 @@
# Generated by Django 6.0.3 on 2026-09-08 08:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bayer', '0003_pescompound_pesstructure_package_data_pool'),
]
operations = [
migrations.AddField(
model_name='package',
name='shareable',
field=models.BooleanField(default=True, verbose_name='Shareable'),
),
]
-1
View File
@@ -23,7 +23,6 @@ class Package(EnviPathModel):
license = models.ForeignKey(
"epdb.License", on_delete=models.SET_NULL, blank=True, null=True, verbose_name="License"
)
shareable = models.BooleanField(verbose_name="Shareable", default=True)
class Classification(models.IntegerChoices):
INTERNAL = 0, "Internal"
@@ -5,11 +5,11 @@
class="modal"
x-data="{
isSubmitting: false,
packageClassification: '',
packageClassification: null,
reset() {
this.isSubmitting = false;
this.packageClassification = '';
this.packageClassification = null;
},
setFormData(data) {
@@ -114,7 +114,7 @@
x-model="packageClassification"
required
>
<option value="" disabled>Select Classification</option>
<option value="null" disabled selected>Select Classification</option>
<option value="0">Internal</option>
<option value="10">Restricted</option>
<option value="20">Secret</option>
@@ -131,9 +131,8 @@
id="package-data-pool"
name="package-data-pool"
class="select select-bordered w-full"
:required="isSecret"
>
<option value="" disabled>Select Data Pool</option>
<option value="" disabled selected>Select Data Pool</option>
{% for obj in meta.secret_groups %}
<option value="{{ obj.url }}">{{ obj.name|safe }}</option>
{% endfor %}
-1
View File
@@ -4,7 +4,6 @@
{% block action_modals %}
{% include "modals/objects/edit_package_modal.html" %}
{% include "modals/objects/view_package_permissions_modal.html" %}
{% include "modals/objects/edit_package_permissions_modal.html" %}
{% include "modals/objects/publish_package_modal.html" %}
{% include "modals/objects/set_license_modal.html" %}
+57 -139
View File
@@ -1,40 +1,19 @@
import base64
import logging
import requests
from django.conf import settings as s
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed
from django.http import HttpResponse, HttpResponseBadRequest
from django.shortcuts import redirect
from bayer.models import PESCompound
from epdb.logic import PackageManager
from epdb.models import Pathway, Node, Group
from epdb.models import Pathway, Node
from epdb.views import _anonymous_or_real, error
from utilities.decorators import package_permission_required
Package = s.GET_PACKAGE_MODEL()
logger = logging.getLogger(__name__)
def has_secret_group(user):
"""
Determines if the specified user belongs to any secret group.
This function checks whether the given user is a member of any group
that is marked as secret.
Args:
user: The user for whom the check is performed.
Returns:
bool: True if the user belongs to at least one secret group,
False otherwise.
"""
return Group.objects.filter(secret=True, user_member=user).exists()
@package_permission_required()
def create_pes(request, package_uuid):
current_user = _anonymous_or_real(request)
@@ -55,43 +34,29 @@ def create_pes(request, package_uuid):
if pes_link:
try:
pes_data = fetch_pes(request, pes_link, current_user)
pes_data = fetch_pes(request, pes_link)
except ValueError as e:
return error(
request,
"Could not fetch PES",
f"Could not fetch PES data for {pes_link}"
)
return HttpResponseBadRequest(f"Could not fetch PES data for {pes_link}")
classification = pes_data.get("classificationLevel", "")
if "secret" == classification.lower():
if current_package.classification_level != Package.Classification.SECRET:
return error(
request,
"Classification Mismatch!",
"Cannot create secret PESs in non-secret packages."
)
return HttpResponseBadRequest("Cannot create PESs for non-secret packages.")
if not current_package.data_pool or not current_package.data_pool.secret:
logger.info(f"The current package does not have a secret data pool.")
return error(
request,
"The current package does not have a secret data pool.",
"Cannot create secret PESs in package without a secret data pool."
)
data_pools = pes_data.get("dataPools")
if data_pools:
if s.DATA_POOL_MAPPING[current_package.data_pool.name] not in data_pools:
return HttpResponseBadRequest(
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 error(
request,
"No PES link received",
"Please provide a PES link."
)
return HttpResponseBadRequest("Please provide a PES link.")
else:
return HttpResponseNotAllowed(["POST"])
pass
@package_permission_required()
@@ -115,39 +80,25 @@ def create_pes_node(request, package_uuid, pathway_uuid):
if pes_link:
try:
pes_data = fetch_pes(request, pes_link, current_user)
pes_data = fetch_pes(request, pes_link)
except ValueError as e:
return error(
request,
"Failed to fetch this PES",
"Either the PES-ID is incorrect, or you're missing sufficient permissions to access this (potentially secret) item. In case you're missing permissions you can request the entitlement cs.u.enviPath_secret_data_user_group on go/idnow to gain access."
)
return HttpResponseBadRequest(f"Could not fetch PES data for {pes_link}")
classification = pes_data.get("classificationLevel", "")
if "secret" == classification.lower():
if current_package.classification_level != Package.Classification.SECRET:
return error(
request,
"Classification Mismatch!",
"Cannot create secret PESs in non-secret packages."
)
return HttpResponseBadRequest("Cannot create PESs for non-secret packages.")
if not current_package.data_pool or not current_package.data_pool.secret:
logger.info(f"The current package does not have a secret data pool.")
return error(
request,
"The current package does not have a secret data pool.",
"Cannot create secret PESs in package without a secret data pool."
)
data_pools = pes_data.get("dataPools")
if data_pools:
if s.DATA_POOL_MAPPING[current_package.data_pool.name] not in data_pools:
return HttpResponseBadRequest(
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)
node_qs = Node.objects.filter(
pathway=current_pathway,
default_node_label=pes.default_structure
)
node_qs = Node.objects.filter(pathway=current_pathway, default_node_label=pes.default_structure)
if node_qs.exists():
return redirect(current_pathway.url)
@@ -165,91 +116,58 @@ def create_pes_node(request, package_uuid, pathway_uuid):
return redirect(current_pathway.url)
else:
return error(
request,
"No PES link received",
"Please provide a PES link."
)
return HttpResponseBadRequest("Please provide a PES link.")
else:
return HttpResponseNotAllowed(["POST"])
pass
def get_application_token(prod: bool) -> str:
scope = f"{s.PROD_PES_SCOPE if prod else s.NON_PROD_PES_SCOPE}/.default"
def fetch_pes(request, pes_url) -> dict:
from epauth.views import get_access_token_from_request
token = get_access_token_from_request(request)
url = f"https://login.microsoftonline.com/{s.MS_ENTRA_TENANT_ID}/oauth2/v2.0/token"
data = {
"grant_type": "client_credentials",
"client_id": s.MS_ENTRA_CLIENT_ID,
"client_secret": s.MS_ENTRA_CLIENT_SECRET,
"scope": scope,
}
if token is None:
token = pes_url.split('/')[-1] == 'dummy'
try:
response = requests.post(url, data=data)
response.raise_for_status()
return response.json()["access_token"]
except requests.exceptions.HTTPError as e:
logger.error(f"Could not fetch application token: {e}")
raise ValueError(f"Could not fetch application token!")
if token:
for k, v in s.PES_API_MAPPING.items():
if pes_url.startswith(k):
pes_id = pes_url.split('/')[-1]
def fetch_pes(request, pes_url, user) -> dict:
for k, v in s.PES_API_MAPPING.items():
if pes_url.startswith(k):
prod = "cropkey-np" not in pes_url
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 = {
"accept": "*/*",
"authorization": "Bearer " + get_application_token(prod),
}
# Restrict request if user is not part of any secret group
if not has_secret_group(user):
headers["app-classification-level-restriction"] = "restrict-pes-secret-structure-access"
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()
# Handle missing response
if "detail" in pes_data and "The following PES Reg Entities Corporate Ids could not be found" in pes_data["detail"]:
raise ValueError(f"PES with id {pes_id} not found")
# Ensure we have a entity
if len(pes_data) == 0:
raise ValueError(f"PES with id {pes_id} not found")
res_data = pes_data[0]
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}
except requests.exceptions.HTTPError as e:
raise ValueError(f"Error fetching PES with id {pes_id}: {e}")
res = requests.get(v, headers=headers, params=params, proxies=s.PROXIES or None)
raise ValueError(f"Unknown URL {pes_url}")
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, request.user)
pes_data = fetch_pes(request, pes_link)
representations = pes_data.get('representations')
-13
View File
@@ -1,5 +1,4 @@
import enum
from typing import Any, Dict
from abc import ABC, abstractmethod
from envipy_additional_information import EnviPyModel
@@ -70,12 +69,6 @@ class Plugin(ABC):
class Property(Plugin):
def parameters(self) -> Dict[str, Any]:
"""
Returns the parameters of the PropertyPlugin.
"""
return {}
@classmethod
@abstractmethod
def requires_rule_packages(cls) -> bool:
@@ -307,12 +300,6 @@ class Classifier(Plugin):
"""
pass
def parameters(self) -> Dict[str, Any]:
"""
Returns the parameters of the ClassifierPlugin.
"""
return {}
@abstractmethod
def build(self, eP: EnviPyDTO, *args, **kwargs) -> BuildResult | None:
"""
-9
View File
@@ -1,9 +0,0 @@
#!/bin/bash
set -e
if [ "${SKIP_DJANGO_SETUP:-false}" != "true" ]; then
python manage.py migrate --no-input
python manage.py collectstatic --no-input
fi
exec "$@"
-3
View File
@@ -357,7 +357,6 @@ DEFAULT_MODEL_PARAMS = {
DEFAULT_MAX_NUMBER_OF_NODES = 9999
DEFAULT_MAX_DEPTH = 8
DEFAULT_MODEL_THRESHOLD = 0.25
BATCH_PREDICT_MAX_COMPOUNDS = 150
# Loading Plugins
PLUGINS_ENABLED = os.environ.get("PLUGINS_ENABLED", "False") == "True"
@@ -445,8 +444,6 @@ if MS_ENTRA_ENABLED:
MS_ENTRA_AUTHORITY = f"https://login.microsoftonline.com/{MS_ENTRA_TENANT_ID}"
MS_ENTRA_REDIRECT_URI = os.environ["MS_REDIRECT_URI"]
MS_ENTRA_SCOPES = os.environ.get("MS_SCOPES", "").split(",")
NON_PROD_PES_SCOPE = os.environ.get("NON_PROD_PES_SCOPE")
PROD_PES_SCOPE = os.environ.get("PROD_PES_SCOPE")
# Site ID 10 -> beta.envipath.org
MATOMO_SITE_ID = os.environ.get("MATOMO_SITE_ID", "10")
+2 -57
View File
@@ -9,8 +9,8 @@ from envipy_additional_information import registry
from envipy_additional_information.groups import GroupEnum
from epapi.utils.schema_transformers import build_rjsf_output
from epapi.utils.validation_errors import handle_validation_error
from epdb.models import AdditionalInformation, Scenario, Node
from ..dal import get_scenario_for_read, get_scenario_for_write, get_package_for_write
from epdb.models import AdditionalInformation
from ..dal import get_scenario_for_read, get_scenario_for_write
logger = logging.getLogger(__name__)
@@ -58,61 +58,6 @@ def list_scenario_info(request, scenario_uuid: UUID):
return result
@router.post("/information/{model_name}/")
def add_object_info(request, model_name: str, payload: Dict[str, Any] = Body(...)):
from epdb.views import EPDBURLParser
cls = registry.get_model(model_name.lower())
if not cls:
raise HttpError(404, f"Unknown model: {model_name}")
try:
instance = cls(**payload) # Pydantic validates
except ValidationError as e:
handle_validation_error(e)
if "attach_obj_url" in payload:
url_parser = EPDBURLParser(payload["attach_obj_url"])
if url_parser.contains_package_url():
package = get_package_for_write(request.user, url_parser.get_objects()[0].uuid)
attach_obj = url_parser.get_object()
if "scenario_uuid" in payload:
scenario = get_scenario_for_read(request.user, payload["scenario_uuid"])
else:
scenario = Scenario.create(
package,
name=f"Scenario {Scenario.objects.filter(package=package).count() + 1}",
description="no description",
scenario_date=None,
scenario_type=None,
additional_information=[],
)
if isinstance(attach_obj, Node):
ai = add_info_to_node(package, instance, scenario, attach_obj)
else:
raise HttpError(404, f"Bad request - Not implemented for {type(attach_obj)}!")
return {"status": "created", "uuid": ai.uuid}
raise HttpError(404, "Bad request!")
def add_info_to_node(package, add_inf, scenario, node):
ai = AdditionalInformation.create(
package,
add_inf,
scenario=scenario,
content_object=node,
)
node.pathway.scenarios.add(scenario)
return ai
@router.post("/scenario/{uuid:scenario_uuid}/information/{model_name}/")
def add_scenario_info(
request, scenario_uuid: UUID, model_name: str, payload: Dict[str, Any] = Body(...)
+1 -1
View File
@@ -56,7 +56,7 @@ def get_pathway_for_iuclid_export(user, pathway_uuid: UUID) -> PathwayExportDTO:
ai_for_node = []
scenario_entries: list[PathwayScenarioDTO] = []
for scenario in sorted(node.get_scenarios(), key=lambda item: item.pk):
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(
+5 -58
View File
@@ -1,5 +1,3 @@
import logging
import msal
from django.conf import settings as s
from django.contrib.auth import get_user_model
@@ -9,9 +7,6 @@ from django.shortcuts import redirect
from epdb.logic import UserManager, GroupManager
from epdb.models import Group
from epdb.views import get_remote_address, error
auth_log = logging.getLogger("auth")
def get_msal_app_with_cache(request):
@@ -35,9 +30,6 @@ def get_msal_app_with_cache(request):
def entra_login(request):
auth_log.info(f"Login request from {get_remote_address(request)}")
msal_app = msal.ConfidentialClientApplication(
client_id=s.MS_ENTRA_CLIENT_ID,
client_credential=s.MS_ENTRA_CLIENT_SECRET,
@@ -62,28 +54,14 @@ def entra_callback(request):
# Acquire token using the flow and callback request
result = msal_app.acquire_token_by_auth_code_flow(flow, request.GET)
if "error" in result:
auth_log.error(f"Login attempt by {get_remote_address(request)} failed due to {result['error']}")
return redirect("/")
# Save the token cache to session
if cache.has_state_changed:
request.session["msal_token_cache"] = cache.serialize()
claims = result["id_token_claims"]
if claims.get("roles") is None or claims.get("roles") == [] or "envipath_registered_user" not in claims.get("roles"):
auth_log.error(f"Login attempt by {get_remote_address(request)} failed due to missing role")
return error(
request,
"Login Failed",
"Request access to role 1230/MON/APPS/Envipath/Envipath_Prod on go/idnow",
403,
)
user_name = claims.get("name")
# preferred_username is a fallback for 2nd CWID
user_email = claims.get("emailaddress", claims.get("email", claims.get("preferred_username")))
user_email = claims.get("emailaddress", claims.get("email"))
user_oid = claims.get("oid")
if not all([user_name, user_email, user_oid]):
@@ -92,7 +70,6 @@ def entra_callback(request):
# Get implementing class
User = get_user_model()
registered = False
if User.objects.filter(uuid=user_oid).exists():
u = User.objects.get(uuid=user_oid)
@@ -101,11 +78,8 @@ def entra_callback(request):
u.save()
else:
auth_log.info(f"Registering {user_name} with OID {user_oid}")
u = UserManager.create_user(user_name, user_email, None, uuid=user_oid, is_active=True)
registered = True
auth_log.info(f"User {user_name} {'(admin) ' if u.is_superuser else ''}with OID {user_oid} successfully logged in as {u.username} from {get_remote_address(request)}")
login(request, u)
# EDIT START
@@ -128,37 +102,10 @@ def entra_callback(request):
else:
g = Group.objects.get(uuid=id)
sync_groups = list(s.ENTRA_GROUPS.keys()) + list(s.ENTRA_SECRET_GROUPS.keys())
user_groups = claims.get("groups", [])
for uuid in sync_groups:
if uuid in user_groups:
g = Group.objects.get(uuid=uuid)
if not g.user_member.contains(u):
g.user_member.add(u)
auth_log.info(f"Login Group Sync: Adding {u.username} to Group {g.name} ({ uuid })")
else:
g = Group.objects.get(uuid=uuid)
# Do not remove users from All enviPath Users
if g.user_member.contains(u) and g.name != 'All enviPath Users':
g.user_member.remove(u)
auth_log.info(f"Login Group Sync: Removing {u.username} from Group {g.name} ({ uuid })")
# Ensure people are part of All enviPath Users -> they have to as, envipath_registered_user is granted
all_envipath_users = Group.objects.get(name="All enviPath Users")
if not all_envipath_users.user_member.contains(u):
all_envipath_users.user_member.add(u)
if registered:
# #72 make package secret if user is part of a secret group
for id, name in s.ENTRA_SECRET_GROUPS.items():
group = Group.objects.get(uuid=id)
if group.user_member.contains(u):
# User is eligible for secrete
pack = u.default_package
pack.data_pool = group
pack.classification_level = pack.Classification.SECRET
pack.save()
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
-6
View File
@@ -11,7 +11,6 @@ from .models import (
CompoundStructure,
Edge,
EnviFormer,
EnzymeLink,
ExternalDatabase,
ExternalIdentifier,
Group,
@@ -213,10 +212,6 @@ class CompoundStructureAdmin(EPAdmin):
pass
class EnzymeLinkAdmin(EPAdmin):
pass
class SimpleAmbitRuleAdmin(EPAdmin):
pass
@@ -271,7 +266,6 @@ admin.site.register(License, LicenseAdmin)
admin.site.register(ClassifierPluginModel, ClassifierPluginModelAdmin)
admin.site.register(Compound, CompoundAdmin)
admin.site.register(CompoundStructure, CompoundStructureAdmin)
admin.site.register(EnzymeLink, EnzymeLinkAdmin)
admin.site.register(SimpleAmbitRule, SimpleAmbitRuleAdmin)
admin.site.register(ParallelRule, ParallelRuleAdmin)
admin.site.register(Reaction, ReactionAdmin)
+23 -74
View File
@@ -1,4 +1,3 @@
import logging
from collections import defaultdict
from typing import Any, Dict, List, Optional
@@ -10,6 +9,7 @@ from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.http import HttpResponse, JsonResponse
from django.shortcuts import redirect
from jwt import InvalidIssuerError
from ninja import Field, Form, Query, Router, Schema
from ninja.security import HttpBearer
@@ -45,12 +45,9 @@ from .models import (
User,
UserPackagePermission,
)
from .views import delete_with_log, get_remote_address
Package = s.GET_PACKAGE_MODEL()
auth_log = logging.getLogger("auth")
def get_cached_jwks(tenant_id: str, force=False) -> Dict:
"""Get JWKS using Django cache"""
@@ -72,10 +69,6 @@ def get_cached_jwks(tenant_id: str, force=False) -> Dict:
return jwks
def get_package_for_read(user, package_uuid):
return PackageManager.get_package_by_id(user, package_uuid)
def get_package_for_write(user, package_uuid):
p = PackageManager.get_package_by_id(user, package_uuid)
if not PackageManager.writable(user, p):
@@ -122,22 +115,15 @@ def validate_token(token: str) -> dict:
class MSBearerTokenAuth(HttpBearer):
def authenticate(self, request, token):
auth_log.info(f"Authentication request by {get_remote_address(request)}")
if token is None:
return None
claims = validate_token(token)
if not User.objects.filter(uuid=claims['oid']).exists():
auth_log.info(f"Authentication request by {get_remote_address(request)} failed!")
return None
user = User.objects.get(uuid=claims['oid'])
request.user = user
auth_log.info(
f"User {user.username} {'(admin) ' if user.is_superuser else ''}with OID {user.uuid} successfully logged in as {user.username} from {get_remote_address(request)}")
request.user = User.objects.get(uuid=claims['oid'])
return request.user
@@ -571,10 +557,7 @@ def update_package(request, package_uuid, pack: Form[UpdatePackage]):
if pack.hiddenMethod:
if pack.hiddenMethod == "DELETE":
if PackageManager.administrable(request.user, p):
delete_with_log(request, p)
else:
raise ValueError("You do not have the rights to delete this Package!")
p.delete()
elif pack.packageDescription is not None:
description = nh3.clean(pack.packageDescription, tags=s.ALLOWED_HTML_TAGS).strip()
@@ -611,7 +594,7 @@ def delete_package(request, package_uuid):
p = PackageManager.get_package_by_id(request.user, package_uuid)
if PackageManager.administrable(request.user, p):
delete_with_log(request, p)
p.delete()
return redirect(f"{s.SERVER_URL}/package")
else:
raise ValueError("You do not have the rights to delete this Package!")
@@ -889,7 +872,7 @@ def create_package_compound(
from bayer.models import PESCompound
try:
pes_data = fetch_pes(request, c.pesLink, request.user)
pes_data = fetch_pes(request, c.pesLink)
except ValueError as e:
return 400, {"message": f"Could not fetch PES data for {c.pesLink}"}
@@ -897,11 +880,12 @@ def create_package_compound(
if "secret" == classification.lower():
if p.classification_level != Package.Classification.SECRET:
return 400, {"message": "Cannot create secret PESs in non-secret packages."}
if not p.data_pool or not p.data_pool.secret:
return 400, {"message": "Cannot create secret PESs in package without a secret data pool."}
return 400, {"Cannot create PESs for non-secret packages."}
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:
@@ -1720,7 +1704,7 @@ class PathwayNode(Schema):
image: str = Field(None, alias="image")
imageSize: int = Field(None, alias="image_size")
name: str = Field(None, alias="name")
proposed: List[Dict[str, Any]] = []
proposed: List[Dict[str, str]] = []
smiles: str = Field(None, alias="smiles")
pseudo: bool = Field(False, alias="pseudo")
pesLink: str | None = Field(None, alias="pes_link")
@@ -1892,7 +1876,7 @@ def delete_pathway(request, package_uuid, pathway_uuid):
p = get_package_for_write(request.user, package_uuid)
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
delete_with_log(request, pw)
pw.delete()
return redirect(f"{p.url}/pathway")
except ValueError:
@@ -1990,7 +1974,7 @@ def get_package_pathway_node(request, package_uuid, pathway_uuid, node_uuid):
class CreateNode(Schema):
nodeAsSmiles: str | None = None
nodeAsSmiles: str
nodeAsMolFile: str | None = None
nodeName: str | None = None
nodeReason: str | None = None
@@ -2014,7 +1998,7 @@ def add_pathway_node(request, package_uuid, pathway_uuid, n: Form[CreateNode]):
from bayer.models import PESCompound
try:
pes_data = fetch_pes(request, n.pesLink, request.user)
pes_data = fetch_pes(request, n.pesLink)
except ValueError as e:
return 400, {"message": f"Could not fetch PES data for {n.pesLink}"}
@@ -2022,10 +2006,14 @@ def add_pathway_node(request, package_uuid, pathway_uuid, n: Form[CreateNode]):
if "secret" == classification.lower():
if p.classification_level != Package.Classification.SECRET:
return 400, {"message": "Cannot create secret PESs in non-secret packages."}
return 400, "Cannot create PESs for non-secret packages."
if not p.data_pool or not p.data_pool.secret:
return 400, {"message": "Cannot create secret PESs in package without a secret data pool."}
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, n.nodeName, n.nodeReason)
@@ -2070,7 +2058,7 @@ def delete_node(request, package_uuid, pathway_uuid, node_uuid):
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
n = Node.objects.get(pathway=pw, uuid=node_uuid)
delete_with_log(request, n)
n.delete()
return redirect(f"{pw.url}/node")
except ValueError:
@@ -2230,7 +2218,7 @@ def delete_edge(request, package_uuid, pathway_uuid, edge_uuid):
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
e = Edge.objects.get(pathway=pw, uuid=edge_uuid)
delete_with_log(request, e)
e.delete()
return redirect(f"{pw.url}/edge")
except ValueError:
@@ -2434,42 +2422,3 @@ def predict(request, np: Form[NonPersistent]):
return 403, {
"message": f"Getting Setting with id {np.setting_url} failed due to insufficient rights!"
}
##########
# Export #
##########
class PackageExportInSchema(Schema):
package_uuid: str
additional_information_types: List[str] | None = None
@router.get("/export", response={200: Any, 403: Error})
def export(request, q: Query[PackageExportInSchema]):
try:
p = get_package_for_read(request.user, q.package_uuid)
from envipy_additional_information import registry
from utilities.misc import PathwayExporter
ai_types = []
if q.additional_information_types is not None:
for ai_type in q.additional_information_types:
if registry.get_model(ai_type) is None:
return 400, {
"message": f"Exporting Package with id {q.package_uuid} failed as {ai_type} is not a valid additional information type!"
}
ai_types.append(ai_type)
exporter = PathwayExporter(p, add_infs_to_export=ai_types)
res = exporter.do_export()
filename = f"{p.get_name().replace(' ', '_')}_{p.uuid}.tsv"
response = HttpResponse(res, content_type="text/csv")
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
except ValueError:
return 403, {
"message": f"Exporting Package with id {q.package_uuid} failed due to insufficient rights!"
}
+10 -85
View File
@@ -35,7 +35,6 @@ from utilities.chem import FormatConverter
from utilities.misc import PackageExporter, PackageImporter
logger = logging.getLogger(__name__)
auth_log = logging.getLogger("auth")
Package = s.GET_PACKAGE_MODEL()
@@ -46,7 +45,7 @@ class EPDBURLParser:
MODEL_PATTERNS = {
"epdb.User": re.compile(rf"^.*/user/{UUID_PATTERN}"),
"epdb.Group": re.compile(rf"^.*/group/{UUID_PATTERN}"),
s.EPDB_PACKAGE_MODEL: re.compile(rf"^.*/package/{UUID_PATTERN}"),
"epdb.Package": re.compile(rf"^.*/package/{UUID_PATTERN}"),
"epdb.Compound": re.compile(rf"^.*/package/{UUID_PATTERN}/compound/{UUID_PATTERN}"),
"epdb.CompoundStructure": re.compile(
rf"^.*/package/{UUID_PATTERN}/compound/{UUID_PATTERN}/structure/{UUID_PATTERN}"
@@ -96,7 +95,7 @@ class EPDBURLParser:
def contains_package_url(self):
return (
bool(self.MODEL_PATTERNS[s.EPDB_PACKAGE_MODEL].findall(self.url))
bool(self.MODEL_PATTERNS["epdb.Package"].findall(self.url))
and not self.is_package_url()
)
@@ -124,7 +123,7 @@ class EPDBURLParser:
"epdb.EPModel",
"epdb.Pathway",
# 1st level
s.EPDB_PACKAGE_MODEL,
"epdb.Package",
"epdb.Setting",
"epdb.Group",
"epdb.User",
@@ -146,7 +145,7 @@ class EPDBURLParser:
hierarchy_order = [
# 1st level
s.EPDB_PACKAGE_MODEL,
"epdb.Package",
"epdb.Setting",
"epdb.Group",
"epdb.User",
@@ -211,7 +210,7 @@ class UserManager(object):
# Create package
package_name = f"{u.username}{'' if u.username[-1] in 'sxzß' else 's'} Package"
package_description = "This package was generated during registration."
p = PackageManager.create_package(u, package_name, package_description, shareable=False)
p = PackageManager.create_package(u, package_name, package_description)
u.default_package = p
u.save()
@@ -317,19 +316,13 @@ class GroupManager(object):
if isinstance(member, Group):
if add_or_remove == "add":
group.group_member.add(member)
auth_log.info(f"{caller.username} ({caller.url}) adds {member.name} ({member.url}) to {group.name} ({group.url})")
else:
group.group_member.remove(member)
auth_log.info(
f"{caller.username} ({caller.url}) removes {member.name} ({member.url}) from {group.name} ({group.url})")
else:
if add_or_remove == "add":
group.user_member.add(member)
auth_log.info(f"{caller.username} ({caller.url}) adds {member.username} ({member.url}) to {group.name} ({group.url})")
else:
group.user_member.remove(member)
auth_log.info(
f"{caller.username} ({caller.url}) removes {member.username} ({member.url}) from {group.name} ({group.url})")
group.save()
@@ -547,7 +540,7 @@ class PackageManager(object):
@staticmethod
@transaction.atomic
def create_package(current_user, name: str, description: str = None, *args, **kwargs):
def create_package(current_user, name: str, description: str = None):
p = Package()
# Clean for potential XSS
@@ -556,9 +549,6 @@ class PackageManager(object):
if description is not None and description.strip() != "":
p.description = nh3.clean(description.strip(), tags=s.ALLOWED_HTML_TAGS).strip()
if "shareable" in kwargs:
p.shareable = kwargs["shareable"]
p.save()
up = UserPackagePermission()
@@ -588,11 +578,9 @@ class PackageManager(object):
if isinstance(grantee, User):
perm_cls = UserPackagePermission
data["user"] = grantee
grantee_name = grantee.username
else:
perm_cls = GroupPackagePermission
data["group"] = grantee
grantee_name = grantee.name
if new_perm is None:
qs = perm_cls.objects.filter(**data)
@@ -601,23 +589,11 @@ class PackageManager(object):
if qs.count() != 0:
logger.info(f"Deleting Perm {qs.first()}")
qs.delete()
auth_log.info(f"{caller.username} ({caller.url}) revokes {grantee_name} ({grantee.url}) all Permissions on {package.name} ({package.url})")
else:
logger.debug(f"No Permission object for {perm_cls} with filter {data} found!")
else:
old_perm = None
old_perms_qs = perm_cls.objects.filter(**data)
if old_perms_qs.exists():
old_perm = old_perms_qs.first().permission
_ = perm_cls.objects.update_or_create(defaults={"permission": new_perm}, **data)
if old_perm is None:
auth_log.info(f"{caller.username} ({caller.url}) grants {grantee_name} ({grantee.url}) '{new_perm}' Permissions on {package.name} ({package.url})")
else:
auth_log.info(f"{caller.username} ({caller.url}) set {grantee_name} ({grantee.url}) Permissions from '{old_perm}' to '{new_perm}' on {package.name} ({package.url})")
@staticmethod
def grant_read(caller: User, package: Package, grantee: Union[User, Group]):
PackageManager.update_permissions(caller, package, grantee, Permission.READ[0])
@@ -626,10 +602,6 @@ class PackageManager(object):
def grant_write(caller: User, package: Package, grantee: Union[User, Group]):
PackageManager.update_permissions(caller, package, grantee, Permission.WRITE[0])
@staticmethod
def grant_owner(caller: User, package: Package, grantee: Union[User, Group]):
PackageManager.update_permissions(caller, package, grantee, Permission.ALL[0])
@staticmethod
@transaction.atomic
def import_legacy_package(
@@ -672,11 +644,11 @@ class PackageManager(object):
# EDIT START
if data.get("classification"):
if data["classification"] == "INTERNAL":
pack.classification_level = Package.Classification.RESTRICTED
pack.classification = Package.Classification.RESTRICTED
elif data["classification"] == "RESTRICTED":
pack.classification_level = Package.Classification.RESTRICTED
pack.classification = Package.Classification.RESTRICTED
elif data["classification"] == "SECRET":
pack.classification_level = Package.Classification.SECRET
pack.classification = Package.Classification.SECRET
if not "datapool" in data:
raise ValueError("Missing datapool in package")
@@ -828,14 +800,7 @@ class PackageManager(object):
r.name = rule["name"]
r.description = rule["description"]
r.aliases = rule.get("aliases", [])
if rule.get("smirks") is not None:
r.smirks = rule["smirks"]
elif rule.get("reactionSmarts") is not None:
r.smirks = rule["reactionSmarts"]
else:
raise ValueError(f"No SMIRKS or reactionSmarts found for rule {rule['id']}")
r.smirks = rule["smirks"]
r.reactant_filter_smarts = rule.get("reactantFilterSmarts", None)
r.product_filter_smarts = rule.get("productFilterSmarts", None)
r.save()
@@ -1910,51 +1875,12 @@ class SPathway(object):
logger.info("Update done!")
def compute_bayes_probabilities(self) -> Dict[SEdge, float]:
"""
Computes Bayes-adjusted probabilities for all edges in the pathway
by iterating level by level from depth 0 upwards, keyed on educt depth.
Returns:
A dict mapping each SEdge to its Bayes-adjusted probability.
"""
bayes_probs: Dict[SEdge, float] = {}
# Group edges by their educt depth
edges_by_depth: Dict[int, List[SEdge]] = {}
for edge in self.edges:
d = edge.educts[0].depth
edges_by_depth.setdefault(d, []).append(edge)
for depth in sorted(edges_by_depth.keys()):
for edge in edges_by_depth[depth]:
if depth == 0:
bayes_probs[edge] = edge.probability
else:
predecessor_edges = [e for e in self.edges if edge.educts[0] in e.products]
if not predecessor_edges or not all(
e in bayes_probs for e in predecessor_edges
):
# Predecessor not computed yet (e.g. same-depth product),
# fall back to raw probability
bayes_probs[edge] = edge.probability
else:
predecessor_avg = sum(bayes_probs[e] for e in predecessor_edges) / len(
predecessor_edges
)
bayes_probs[edge] = predecessor_avg * edge.probability
return bayes_probs
def to_json(self):
nodes = []
edges = []
idx_lookup = {}
bayes_probs = self.compute_bayes_probabilities()
for i, smiles in enumerate(self.smiles_to_node):
n = self.smiles_to_node[smiles]
idx_lookup[smiles] = i
@@ -1975,7 +1901,6 @@ class SPathway(object):
if edge.probability:
e["probability"] = edge.probability
e["multiGenProbability"] = bayes_probs[edge]
edges.append(e)
+12 -17
View File
@@ -44,25 +44,20 @@ class Command(BaseCommand):
"EPModel",
"ApplicabilityDomain",
"EnzymeLink",
"AdditionalInformation",
]
for model in MODELS:
obj_cls = apps.get_model("epdb", model)
update_fields = {"url": Replace(F("url"), Value(options["old"]), Value(options["new"]))}
if hasattr(obj_cls, "description"):
update_fields["description"] = Replace(
F("description"), Value(options["old"]), Value(options["new"])
)
obj_cls.objects.update(
url=Replace(F("url"), Value(options["old"]), Value(options["new"]))
)
if issubclass(obj_cls, EnviPathModel):
update_fields["kv"] = Cast(
Replace(
Cast(F("kv"), output_field=TextField()),
Value(options["old"]),
Value(options["new"]),
),
output_field=JSONField(),
obj_cls.objects.update(
kv=Cast(
Replace(
Cast(F("kv"), output_field=TextField()),
Value(options["old"]),
Value(options["new"]),
),
output_field=JSONField(),
)
)
obj_cls.objects.update(**update_fields)
@@ -1,97 +0,0 @@
import logging
from django.core.management.base import BaseCommand
from django.db import transaction
from uuid import uuid4
from epdb.models import Package, ReactionExplanation
from utilities.chem import FormatConverter
from django.utils import timezone
logger = logging.getLogger(__name__)
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument(
"--rule-package",
action="append",
default=["32de3cf4-e3e6-4168-956e-32fa5ddb0ce1"],
type=str,
help="UUID to process. Can be specified multiple times.",
)
parser.add_argument(
"--reaction-package",
action="append",
default=[
"32de3cf4-e3e6-4168-956e-32fa5ddb0ce1", # BBD
"f05e38d8-e9b4-4c3e-b0d8-9ab29966eccf", # Sediment
"521c547a-fd2a-491c-ad5b-7eaa1577fb65", # Sludge
"5882df9c-dae1-4d80-a40e-db4724271456", # Soil
"87a49584-d937-482c-9c33-25928dcb02a8", # PFAS
],
type=str,
help="UUID to process. Can be specified multiple times.",
)
parser.add_argument(
"--dry-run",
default=False,
action="store_true",
help="Perform dry run",
)
@transaction.atomic
def handle(self, *args, **options):
RUN_UUID = uuid4()
RUN_START = timezone.now()
rule_packages = Package.objects.filter(uuid__in=options["rule_package"])
reaction_packages = Package.objects.filter(uuid__in=options["reaction_package"])
rules = []
for rule_package in rule_packages:
rules.extend(rule_package.get_applicable_rules())
reactions = []
for reaction_package in reaction_packages:
reactions.extend(reaction_package.reactions)
logger.debug(f"Collected {len(rules)} rules and {len(reactions)} reactions.")
for i, reaction in enumerate(reactions):
logger.debug(f"Reaction {i} / {len(reactions)}")
for j, rule in enumerate(rules):
reactants, products = reaction.smirks().split(">>")
if len(reactants.split(".")) > 1:
logger.debug(f"Skipping reaction {reaction.uuid} as it has multiple reactants.")
break
products = products.split(".")
# Run reaction with rule
rule_products = rule.apply(reactants)
# Check if products match (in both directions if extras are not allowed)
for product_set in rule_products:
covered, exact = FormatConverter.smiles_covered_by(
products,
product_set.product_set,
standardize=True,
canonicalize_tautomers=True,
return_exact_match=True,
)
if covered and not options["dry-run"]:
logger.debug(f"Reaction {reaction.uuid} explained by rule {rule.uuid}")
re = ReactionExplanation()
re.run_uuid = RUN_UUID
re.run_start = RUN_START
re.reaction = reaction
re.rule = rule
re.exact = exact
re.save()
# Its explained, if there are more sets skip them
break
-113
View File
@@ -1,113 +0,0 @@
# Generated by Django 6.0.3 on 2026-08-12 09:02
from django.conf import settings as s
from django.db import migrations
from envipy_additional_information import Likelihood, RuleLikelihood
NEW_RULE = {
"parent": "bt0005",
"name": "bt0005-3667",
"description": "vic-unsubstituted Aromatic > vic-Dihydroxyaromatic",
"smirks": "[#8:7]([H])-[#6:1]([H])-1-[#6:2]=[#6:3]-[#6:4]=[#6:5]-[#6:6]([H])-1-[#8:8]([H])>>[#8:7]([H])-[#6:1]=1-[#6:2]=[#6:3]-[#6:4]=[#6:5]-[#6:6]=1-[#8:8]([H])",
"scenario_name": "bt0005-3667 aerobic likelihood",
"scenario_aerobic_likelihood": RuleLikelihood(likelihood=Likelihood.LIKELY),
}
RULE_FIXES = {
"bt0005-4282": "[c:1]([H])1:[c:2]([H]):[#6,#7;a:3]:[c:4]:[c:5]:[c:6]1>>[c:1]([#8])1:[c:2]([#8]):[#6,#7;a:3]:[c:4]:[c:5]:[c:6]1",
"bt0014-4215": "[c:1]([H])1[c:8][#6,#7;a:7][c:6][c:5][c:4]1[#8;!$([OH]c:[#6,#7;a:7]([OH])):9]([H])>>[#8:9]([H])[c:4]1:[c:5]:[c:6]:[#6,#7;a:7]:[c:8]:[c:1]1[#8]([H])",
# "bt0063-3938": "[#1,#6:6][#7;X3;!$(NC1CC1)!$([N][C]=O)!$([!#8]CNC=O):1]([#1,#6:7])[#6;A;X4:2][H:3]>>[#1,#6:6][#7;X3:1]([H:3])(=[#1,#6:7]).[#6;A:2]=O",
# CN1C=NC2=C1C(=O)N(C)C(=O)N2 not working anymore with bt0063-3938 if change above is applied
"bt0063-3938": "[#1,#6:6][#7;X3;!$(NC1CC1)!$([N][C]=O)!$([!#8]CNC=O):1]([#1,#6:7])[#6;A;X4:2][H:3]>>[#1,#6:6][#7;X3:1]([#1,#6:7])[H:3].[#6;A:2]=O",
"bt0068-3564": "[#7:4]!@-[#6:2](!@-[#7:1])=[O:5]>>[#7:4]-[#6:2](-[O+0H1])=[O:5].[#7H1:1]",
"bt0180-2844": "[H][C:2]([#6:5]([H])([H])([H]))([#1,#6:4])!@-[#6:1]([H])([H])-[#6:3](-[#8-:8])=[O:6]>>[#6:5]([H])([H])([H])\\[#6:2](-[#1,#6:4])=[#6H:1]\\[#6:3](-[#8-:8])=[O:6]",
"bt0181-1278": "[#8-:1]-[#6:2](=[O:11])-[#6:7]=[#6:8]-[#6:3](-[H])=[#6:5](-Cl)-[#6:6](-[#8-:10])=[O:9]>>[O+0H1:10]-[#6:6](=[O:9])-[#6:5]=[#6:3]-1-[O+0:1]-[#6:2](=[O:11])-[#6:7]=[#6:8]-1",
"bt0298-3335": "[#6:1][N+:2]#[C:3]>>[#6:1]-[#7H2:2]-[#6:3]=O",
"bt0322-3393": "[H:10]\\[#6:6](=[#6:9](/[#6:1]([H])([H])([H]))-[#6:11]-[#6:12]-[#6:13]=[#6:14])-[#6:5](-[#16:7])=[O:8]>>[H:10]\\[#6:6](-[#6:5](-[#16:7])=[O:8])=[#6:9](\\[#6:11]-[#6:12]-[#6:13]=[#6:14])-[#6:1]-[#6](-[#8-])=O",
"bt0343-2675": "[#8-]-[#6](=O)-[c:1]1[c:6][cH:7][c:8](-[#7H2,#8H1:9])[cH:10][c:11]1>>[#8H][c:1]1[c:6][c:7][c:8]([*:9])[c:10][c:11]1",
"bt0350-3319": "[#6:6][#7:3][#6;!R:2]=[#7;!R:1][#6:5]>>[#6:5][#7:1][#6:2]=O.[#6:6][#7:3]", # Trig before 5 -> all of them shouldn't
"bt0374-4081": "[cH:4]1[c:16][c:15][c:14][c:13][c:3]1[#7,#8:2][c:1]1[c:8][c:9][c:10][c:11][c:12]1>>[#7,#8:2]-[c:1]1[c:12][c:11][c:10][c:9][c:8]1[c:13]1[c:14][c:15][c:16][c:4](-[#8])[c:3]1-[#8]",
"bt0378-3188": "[#8-:7][c:1]1[c:6]([#7+]([#8-])=O)[c:5][c:4]([#7+:9]([#8-])=O)[c:3][c:2]1([#7+:8]([#8-])=O)>>[#8+0:7]=[#6:1]1-[#6:6]-[#6:5]-[#6:4]([#7+:9]([#8-])=O)-[#6:3]-[#6:2]1([#7+:8]([#8-])=O)",
"bt0379-3190": "[#9,#17,#35,#53]-[#6:1](-[H])-1-[#6:5]-,=[#6:6]-[#6:7]-,=[#6:8]-[#6:2](-[H])-1-[#9,#17,#35,#53]>>[#6:6]~1-[#6:7]~[#6:8]-[#6:2]=[#6:1]-[#6:5]~1",
"bt0393-3367": "[#6:5]-[#6:1](-[#7:2](-[H])(-[H]))=[S+:3]-[#8-:6]>>[#6:5]-[#6:1](=[#7H1:2])-[S+0:3](=[O])-[#8+0H1:6]",
}
def forward_func(apps, schema_editor):
ContentType = apps.get_model("contenttypes", "ContentType")
pkg_class = s.EPDB_PACKAGE_MODEL
if len(pkg_class.split(".")) != 2:
raise ValueError(
f"EPDB_PACKAGE_MODEL must be of the form 'app_label.model_name', got {pkg_class}"
)
app_label, model_name = pkg_class.split(".")
Package = apps.get_model(app_label, model_name)
SimpleAmbitRule = apps.get_model("epdb", "SimpleAmbitRule")
ParallelRule = apps.get_model("epdb", "ParallelRule")
Scenario = apps.get_model("epdb", "Scenario")
AdditionalInformation = apps.get_model("epdb", "AdditionalInformation")
simple_ambit_rule_ct = ContentType.objects.get_for_model(SimpleAmbitRule)
if Package.objects.filter(name="EAWAG-BBD").exists():
p = Package.objects.get(name="EAWAG-BBD")
if not SimpleAmbitRule.objects.filter(package=p, name=NEW_RULE["name"]).exists():
# Create Missing Rule
new_sr = SimpleAmbitRule()
new_sr.polymorphic_ctype = simple_ambit_rule_ct
new_sr.package = p
new_sr.name = NEW_RULE["name"]
new_sr.description = NEW_RULE["description"]
new_sr.smirks = NEW_RULE["smirks"]
new_sr.save()
new_sr.url = "{}/simple-ambit-rule/{}".format(new_sr.package.url, new_sr.uuid)
new_sr.save()
# Add likelihood
new_scen = Scenario()
new_scen.package = p
new_scen.name = NEW_RULE["scenario_name"]
new_scen.save()
new_scen.url = "{}/scenario/{}".format(new_scen.package.url, new_scen.uuid)
new_scen.save()
ai = NEW_RULE["scenario_aerobic_likelihood"]
new_add_inf = AdditionalInformation()
new_add_inf.package = p
new_add_inf.type = ai.__class__.__name__
new_add_inf.data = ai.model_dump(mode="json")
new_add_inf.scenario = new_scen
new_add_inf.save()
new_add_inf.url = "{}/additional-information/{}".format(
new_add_inf.scenario.url, new_add_inf.uuid
)
new_add_inf.save()
# Link Scenario
new_sr.scenarios.add(new_scen)
# Link to bt0005
pr = ParallelRule.objects.get(package=p, name="bt0005")
pr.simple_rules.add(new_sr)
# Update others
for rule_name, smirks in RULE_FIXES.items():
sr = SimpleAmbitRule.objects.get(package=p, name=rule_name)
sr.smirks = smirks
sr.save()
class Migration(migrations.Migration):
dependencies = [
("epdb", "0027_alter_compound_aliases_and_more"),
]
operations = [
migrations.RunPython(forward_func, reverse_code=migrations.RunPython.noop),
]
@@ -1,63 +0,0 @@
# Generated by Django 6.0.3 on 2026-08-13 09:58
import django.db.models.deletion
import django.utils.timezone
import model_utils.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("epdb", "0028_auto_20260812_0902"),
]
operations = [
migrations.CreateModel(
name="ReactionExplanation",
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"
),
),
("run_uuid", models.UUIDField()),
("run_start", models.DateTimeField()),
("exact", models.BooleanField(default=False)),
(
"reaction",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="epdb.reaction"
),
),
(
"rule",
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="epdb.rule"),
),
],
options={
"abstract": False,
},
),
migrations.AddField(
model_name="reaction",
name="explained_by",
field=models.ManyToManyField(
related_name="explained_reactions",
through="epdb.ReactionExplanation",
to="epdb.rule",
),
),
]
@@ -1,37 +0,0 @@
# Generated by Django 6.0.3 on 2026-08-14 07:41
from django.db import migrations
def forward_func(apps, schema_editor):
ContentType = apps.get_model("contenttypes", "ContentType")
AdditionalInformation = apps.get_model("epdb", "AdditionalInformation")
models = {}
for c in ContentType.objects.all():
try:
models[(c.app_label, c.model)] = apps.get_model(c.app_label, c.model)
except Exception:
pass
for ai in AdditionalInformation.objects.all():
if ai.url is None:
if ai.content_type is None:
ai.url = "{}/additional-information/{}".format(ai.scenario.url, ai.uuid)
else:
model = models[(ai.content_type.app_label, ai.content_type.model)]
obj = model.objects.get(pk=ai.object_id)
ai.url = "{}/additional-information/{}".format(obj.url, ai.uuid)
ai.save()
class Migration(migrations.Migration):
dependencies = [
("epdb", "0029_reactionexplanation_reaction_explained_by"),
]
operations = [
migrations.RunPython(forward_func, reverse_code=migrations.RunPython.noop),
]
+35 -148
View File
@@ -859,15 +859,9 @@ class Compound(
@property
def related_reactions(self):
return (
(
Reaction.objects.filter(package=self.package, educts__in=[self.default_structure])
| Reaction.objects.filter(
package=self.package, products__in=[self.default_structure]
)
)
.distinct()
.order_by("name")
)
Reaction.objects.filter(package=self.package, educts__in=[self.default_structure])
| Reaction.objects.filter(package=self.package, products__in=[self.default_structure])
).order_by("name")
@property
def related_nodes(self):
@@ -937,10 +931,8 @@ class Compound(
found_structure = qs.first()
found_compound = found_structure.compound
# We've only found the standardized one, create the very structure
_ = found_compound.add_structure(
smiles, molfile=molfile, name=name, description=description
)
# We've only found the normalized one, create the very structure
new_structure = found_compound.add_structure(smiles, molfile=molfile, name=name, description=description)
if name:
found_compound.add_alias(name)
@@ -1274,15 +1266,14 @@ class CompoundStructure(
if name is not None:
cs.name = name
# We have a default here only set the value if it carries some payload
if description is not None and description.strip() != "":
if description is not None:
cs.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
cs.compound = compound
cs.smiles = smiles
cs.compound = compound
# If molfile is not None, it hase to be a valid Molfile as we've survived the parsing check
if molfile is not None:
# Check if molfile is present and valid
if molfile is not None and molfile.strip() != "":
cs.molfile = molfile
if "normalized_structure" in kwargs:
@@ -1658,9 +1649,6 @@ class ParallelRule(Rule):
f"Simple rule {sr.uuid} does not belong to package {package.uuid}!"
)
if name is not None:
name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
# Deduplication check
query = ParallelRule.objects.annotate(
srs_count=Count("simple_rules", filter=Q(simple_rules__in=simple_rules), distinct=True)
@@ -1672,19 +1660,15 @@ class ParallelRule(Rule):
if existing_rule_qs.exists():
if existing_rule_qs.count() > 1:
logger.error(
f"Found more than one ParallelRule for given input! {existing_rule_qs}"
)
found_rule = existing_rule_qs.first()
if name:
found_rule.add_alias(name)
return found_rule
logger.error(f"Found more than one reaction for given input! {existing_rule_qs}")
return existing_rule_qs.first()
r = ParallelRule()
r.package = package
if name is not None:
name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
if name is None or name == "":
name = f"Rule {Rule.objects.filter(package=package).count() + 1}"
@@ -1741,14 +1725,6 @@ class SequentialRuleOrdering(models.Model):
order_index = models.IntegerField(null=False, blank=False)
class ReactionExplanation(TimeStampedModel):
run_uuid = models.UUIDField(null=False, blank=False)
run_start = models.DateTimeField(null=False, blank=False)
reaction = models.ForeignKey("epdb.Reaction", on_delete=models.CASCADE)
rule = models.ForeignKey("epdb.Rule", on_delete=models.CASCADE)
exact = models.BooleanField(default=False)
class Reaction(
EnviPathModel, AliasMixin, ScenarioMixin, ReactionIdentifierMixin, AdditionalInformationMixin
):
@@ -1774,12 +1750,6 @@ class Reaction(
external_identifiers = GenericRelation("ExternalIdentifier")
explained_by = models.ManyToManyField(
"epdb.Rule",
through="ReactionExplanation",
related_name="explained_reactions",
)
def _url(self):
return "{}/reaction/{}".format(self.package.url, self.uuid)
@@ -1794,6 +1764,7 @@ class Reaction(
rules: Union[Rule | List[Rule]] = None,
multi_step: bool = False,
):
# Clean for potential XSS
if name is not None and name.strip() != "":
name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
@@ -1863,10 +1834,8 @@ class Reaction(
r = Reaction()
r.package = package
if name is None or name == "":
name = f"Reaction {Reaction.objects.filter(package=package).count() + 1}"
r.name = name
if name is not None:
r.name = name
if description is not None and description.strip() != "":
r.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
@@ -2193,7 +2162,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
row += [cs.smiles, cs.get_name(), n.depth]
edges = self.edges.filter(end_nodes=n)
edges = self.edges.filter(end_nodes__in=[n])
if len(edges):
for e in edges:
_row = row.copy()
@@ -2486,7 +2455,7 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
"name": self.get_name(),
"plain_name": self.get_name(include_suffix=False),
"smiles": self.default_node_label.smiles,
"scenarios": [{"name": s.get_name(), "url": s.url} for s in self.get_scenarios()],
"scenarios": [{"name": s.get_name(), "url": s.url} for s in self.scenarios.all()],
"app_domain": {
"inside_app_domain": app_domain_data["assessment"]["inside_app_domain"]
if app_domain_data
@@ -2495,7 +2464,7 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
},
"predicted_properties": predicted_properties,
"is_engineered_intermediate": self.kv.get("is_engineered_intermediate", False),
"proposed": self.get_proposed_info(),
"proposed": self.is_proposed_intermediate(),
"timeseries": self.get_timeseries_data(),
**structure_data,
}
@@ -2584,15 +2553,7 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
return data
def simple_json(self, include_description=False):
res = super().simple_json()
name = res.get("name", None)
if name == "no name":
res["name"] = self.default_node_label.get_name()
return res
def get_proposed_info(self):
def is_proposed_intermediate(self):
collected = defaultdict(dict)
for ai in self.additional_information.filter(
type__in=["ProposedIntermediate", "TransformationProductImportance", "Confidence"],
@@ -2605,7 +2566,7 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
collected[str(ai.scenario.uuid)]["proposed"] = True
if ai.type == "Confidence":
collected[str(ai.scenario.uuid)]["Confidence"] = ai.get().level.value
collected[str(ai.scenario.uuid)]["Confidence"] = ai.get().level
if ai.type == "TransformationProductImportance":
collected[str(ai.scenario.uuid)]["Transformation product importance"] = (
@@ -2614,14 +2575,13 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
return list(collected.values())
def get_scenarios(self):
qs = self.scenarios.all()
qs |= Scenario.objects.filter(
id__in=self.additional_information.filter(scenario__isnull=False)
.values_list("scenario", flat=True)
.distinct()
)
return qs.distinct()
def simple_json(self, include_description=False):
res = super().simple_json()
name = res.get("name", None)
if name == "no name":
res["name"] = self.default_node_label.get_name()
return res
class Edge(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin):
@@ -2878,58 +2838,6 @@ class PackageBasedModel(EPModel):
return res
def parameters(self):
params = {
"Model Evaluation Threshold": f"{self.threshold:.2f}",
"Multi Gen Evaluation": "Yes" if self.multigen_eval else "No",
}
if self.app_domain:
params["Applicability Domain Num Neighbors"] = f"{self.app_domain.num_neighbours:.2f}"
params["Applicability Domain Reliability Threshold"] = (
f"{self.app_domain.reliability_threshold:.2f}"
)
params["Applicability Domain Local Compatibility Threshold"] = (
f"{self.app_domain.local_compatibilty_threshold:.2f}"
)
return params
def statistics(self):
from sklearn.metrics import auc
recall = list(self.eval_results["average_recall_per_threshold"].values())
precision = list(self.eval_results["average_precision_per_threshold"].values())
mg_recall = list(
self.eval_results.get("multigen_average_recall_per_threshold", {}).values()
)
mg_precision = list(
self.eval_results.get("multigen_average_precision_per_threshold", {}).values()
)
return {
"accuracy": [
self.eval_results["average_accuracy"],
self.eval_results.get("multigen_average_accuracy"),
],
"precision": [
self.eval_results["average_precision_per_threshold"][f"{self.threshold:.2f}"],
self.eval_results.get("multigen_average_precision_per_threshold", {}).get(
f"{self.threshold:.2f}"
),
],
"recall": [
self.eval_results["average_recall_per_threshold"][f"{self.threshold:.2f}"],
self.eval_results.get("multigen_average_recall_per_threshold", {}).get(
f"{self.threshold:.2f}"
),
],
"Area under PR Curve": [
auc(recall, precision),
auc(mg_recall, mg_precision) if self.multigen_eval else None,
],
}
@cached_property
def applicable_rules(self) -> List["Rule"]:
"""
@@ -3086,14 +2994,7 @@ class PackageBasedModel(EPModel):
prec, rec = dict(), dict()
thresholds = list(np.arange(0, 1.05, 0.05))
# Add specific threshold set during object creation if not already present
if np.float64(threshold) not in thresholds:
thresholds.append(np.float64(threshold))
thresholds.sort()
for t in thresholds:
for t in np.arange(0, 1.05, 0.05):
temp_thresholded = (y_pred_filtered >= t).astype(int)
prec[f"{t:.2f}"] = precision_score(
y_test_filtered, temp_thresholded, zero_division=0
@@ -3103,12 +3004,7 @@ class PackageBasedModel(EPModel):
return acc, prec, rec
def evaluate_mg(model, pathways: Union[QuerySet["Pathway"] | List["Pathway"]], threshold):
thresholds = list(np.arange(0, 1.05, 0.05))
# Add specific threshold set during object creation if not already present
if np.float64(threshold) not in thresholds:
thresholds.append(np.float64(threshold))
thresholds.sort()
thresholds = np.arange(0.1, 1.1, 0.1)
precision = {f"{t:.2f}": [] for t in thresholds}
recall = {f"{t:.2f}": [] for t in thresholds}
@@ -3134,7 +3030,7 @@ class PackageBasedModel(EPModel):
s = Setting()
s.model = mod
s.model_threshold = 0.0
s.model_threshold = thresholds.min()
s.max_depth = 10
s.max_nodes = 50
@@ -3158,17 +3054,14 @@ class PackageBasedModel(EPModel):
for t in thresholds:
for true, pred in zip(pathways, pred_pathways):
acc, pre, rec = multigen_eval(true, pred, t)
if f"{t:.2f}" == f"{threshold:.2f}":
mg_acc += acc
if abs(t - threshold) < 0.01:
mg_acc = acc
precision[f"{t:.2f}"].append(pre)
recall[f"{t:.2f}"].append(rec)
avg_mg_acc = mg_acc / len(root_compounds)
precision = {k: sum(v) / len(v) if len(v) > 0 else 0 for k, v in precision.items()}
recall = {k: sum(v) / len(v) if len(v) > 0 else 0 for k, v in recall.items()}
return avg_mg_acc, precision, recall
return mg_acc, precision, recall
# If there are eval packages perform single generation evaluation on them instead of random splits
if self.eval_packages.count() > 0:
@@ -4300,9 +4193,6 @@ class ClassifierPluginModel(PackageBasedModel):
instance = impl(conf)
return instance
def parameters(self):
return self.instance().parameters()
def build_dataset(self):
"""
Required by general model contract but actual implementation resides in plugin.
@@ -4523,9 +4413,6 @@ class PropertyPluginModel(PackageBasedModel):
instance = impl()
return instance
def parameters(self):
return self.instance().parameters()
def build_dataset(self):
"""
Required by general model contract but actual implementation resides in plugin.
+2 -1
View File
@@ -477,7 +477,8 @@ def batch_predict(
limit=None,
setting_overrides={
"max_nodes": num_tps,
"model_threshold": 0.0,
"max_depth": num_tps,
"model_threshold": 0.001,
},
)
+20 -75
View File
@@ -61,7 +61,6 @@ from .models import (
)
logger = logging.getLogger(__name__)
auth_log = logging.getLogger("auth")
Package = s.GET_PACKAGE_MODEL()
@@ -72,18 +71,6 @@ def log_post_params(request):
logger.debug(f"{k}\t{v}")
def get_remote_address(request):
remote_address = ""
if request is not None:
remote_address = request.META.get("HTTP_X_FORWARDED_FOR")
if not remote_address:
remote_address = request.META.get("REMOTE_ADDR", "")
return remote_address
def get_error_handler_context(request, for_user=None) -> Dict[str, Any]:
current_user = _anonymous_or_real(request)
@@ -160,20 +147,6 @@ def handler500(request):
return render(request, "errors/error.html", context, status=500)
def delete_with_log(request, obj):
caller = request.user
obj_type = obj.__class__.__name__
try:
obj.delete()
auth_log.info(f"{caller.username} ({caller.url}) deleted {obj_type}: {obj.name} ({obj.url})")
except Exception as e:
logger.info(f"Tried to delete {obj_type}: {obj.name} ({obj.url}) but deletion failed! Exception {e}")
auth_log.info(
f"{caller.username} ({caller.url}) tried to delete {obj_type}: {obj.name} ({obj.url}) but deletion failed!")
raise e
def login(request):
context = get_base_context(request)
@@ -232,18 +205,11 @@ def login(request):
if user is not None:
login(request, user)
if user.is_superuser:
auth_log.error(f"admin ({user.username}) login attempt by {get_remote_address(request)} successful")
if next := request.POST.get("next"):
return redirect(next)
return redirect(reverse("index"))
else:
if _user := User.objects.get(email=email):
if _user.is_superuser:
auth_log.error(f"admin ({_user.username}) login attempt by {get_remote_address(request)} failed")
context["message"] = "Login failed!"
return render(request, "static/login.html", context)
else:
@@ -424,7 +390,7 @@ def get_base_context(request, for_user=None) -> Dict[str, Any]:
"external_databases": ExternalDatabase.get_databases(),
"site_id": s.MATOMO_SITE_ID,
# EDIT START
"secret_groups": Group.objects.filter(secret=True, user_member=current_user),
"secret_groups": Group.objects.filter(secret=True),
# EDIT END
},
}
@@ -560,7 +526,6 @@ def batch_predict_pathway(request):
context = get_base_context(request)
context["title"] = "enviPath - Batch Predict Pathway"
context["meta"]["current_package"] = context["meta"]["user"].default_package
context["batch_predict_max_compounds"] = s.BATCH_PREDICT_MAX_COMPOUNDS
return render(request, "batch_predict_pathway.html", context)
@@ -1145,23 +1110,19 @@ def package_model(request, package_uuid, model_uuid):
for pr in pred_res:
if len(pr) > 0:
products = []
for prod_set in pr.product_sets:
logger.debug(f"Checking {prod_set}")
products.append(tuple([x for x in prod_set]))
products = list(set(products))
for prod in products:
res["pred"].append(
{
"products": list(prod),
"probability": pr.probability,
"btrule": {k: getattr(pr.rule, k) for k in ["url", "name"]}
if pr.rule is not None
else None,
}
)
res["pred"].append(
{
"products": list(set(products)),
"probability": pr.probability,
"btrule": {k: getattr(pr.rule, k) for k in ["url", "name"]}
if pr.rule is not None
else None,
}
)
# Sort data by prob desc
res["pred"] = sorted(
@@ -1320,8 +1281,7 @@ def package(request, package_uuid):
"You cannot delete the default package. If you want to delete this package you have to set another default package first.",
)
delete_with_log(request, current_package)
logger.debug(current_package.delete())
return redirect(s.SERVER_URL + "/package")
elif hidden == "publish-package":
for g in Group.objects.filter(public=True):
@@ -1988,24 +1948,9 @@ def package_reactions(request, package_uuid):
elif request.method == "POST":
reaction_name = request.POST.get("reaction-name")
reaction_description = request.POST.get("reaction-description")
reaction_smiles = request.POST.get("reaction-smiles")
if reaction_smiles is None or reaction_smiles.strip() == "":
return error(
request,
"Reaction SMILES is empty / missing",
"No reaction SMILES provided. Please provide a SMILES for the reaction.",
)
if not FormatConverter.is_valid_smirks(reaction_smiles):
return error(
request,
"Reaction SMILES is invalid",
f"The provided reactions SMILES {reaction_smiles} is invalid",
)
educts = reaction_smiles.split(">>")[0].split(".")
products = reaction_smiles.split(">>")[1].split(".")
reactions_smirks = request.POST.get("reaction-smirks")
educts = reactions_smirks.split(">>")[0].split(".")
products = reactions_smirks.split(">>")[1].split(".")
r = Reaction.create(
current_package,
@@ -2312,7 +2257,7 @@ def package_pathway(request, package_uuid, pathway_uuid):
elif request.method == "POST":
if hidden := request.POST.get("hidden", None):
if hidden == "delete":
delete_with_log(request, current_pathway)
current_pathway.delete()
return redirect(current_package.url + "/pathway")
else:
return HttpResponseBadRequest()
@@ -2434,8 +2379,8 @@ def package_pathway_nodes(request, package_uuid, pathway_uuid):
node_name = request.POST.get("node-name")
node_description = request.POST.get("node-description")
node_smiles = request.POST.get("node-smiles")
node_molfile = request.POST.get("node-molfile")
node_smiles = request.POST.get("node-smiles").strip()
node_molfile = request.POST.get("node-molfile").strip()
try:
current_pathway.add_node(
@@ -2530,7 +2475,7 @@ def package_pathway_node(request, package_uuid, pathway_uuid, node_uuid):
if hidden := request.POST.get("hidden", None):
if hidden == "delete":
# pre_delete signal will take care of edge deletion
delete_with_log(request, current_node)
current_node.delete()
return redirect(current_pathway.url)
else:
@@ -2684,7 +2629,7 @@ def package_pathway_edge(request, package_uuid, pathway_uuid, edge_uuid):
if hidden := request.POST.get("hidden", None):
if hidden == "delete":
delete_with_log(request, current_edge)
current_edge.delete()
return redirect(current_pathway.url)
if "selected-scenarios" in request.POST:
@@ -3046,7 +2991,7 @@ def group(request, group_uuid):
if hidden := request.POST.get("hidden", None):
if hidden == "delete":
delete_with_log(request, current_group)
current_group.delete()
return redirect(s.SERVER_URL + "/group")
else:
return HttpResponseBadRequest()
+8 -12
View File
@@ -120,6 +120,13 @@ class PathwayMapper:
)
bundle.reference_substances.append(ref_sub)
sub = IUCLIDSubstanceData(
uuid=sub_uuid,
name=compound.name,
reference_substance_uuid=ref_sub_uuid,
)
bundle.substances.append(sub)
if not export.compounds:
return bundle
@@ -138,16 +145,6 @@ class PathwayMapper:
if not root_compound_pks:
return bundle
for root_pk in root_compound_pks:
root_sub_uuid, root_ref_uuid = seen_compounds[root_pk]
bundle.substances.append(
IUCLIDSubstanceData(
uuid=root_sub_uuid,
name=compound_names[root_pk],
reference_substance_uuid=root_ref_uuid,
)
)
edge_templates: list[tuple[UUID, frozenset[int], tuple[int, ...], tuple[UUID, ...]]] = []
for edge in sorted(export.edges, key=lambda item: str(item.edge_uuid)):
parent_compound_pks = sorted(
@@ -351,8 +348,7 @@ class PathwayMapper:
props = SoilPropertiesData()
for ai_obj in ai_list:
ai = ai_obj.get()
for ai in ai_list:
if isinstance(ai, SoilTexture1) and props.soil_type is None:
props.soil_type = ai.type.value
elif isinstance(ai, SoilTexture2):
+2 -1
View File
@@ -70,7 +70,8 @@ class IUCLIDExportAPITest(TestCase):
names = zf.namelist()
self.assertIn("manifest.xml", names)
i6d_files = [n for n in names if n.endswith(".i6d")]
self.assertEqual(len(i6d_files), 4)
# 2 substances + 2 ref substances + 1 ESR = 5 i6d files
self.assertEqual(len(i6d_files), 5)
def test_anonymous_returns_401(self):
self.client.logout()
-46
View File
@@ -7,11 +7,6 @@ from uuid import uuid4
from django.test import SimpleTestCase, tag
from epapi.v1.interfaces.iuclid.dto import (
PathwayCompoundDTO,
PathwayEdgeDTO,
PathwayExportDTO,
)
from epiuclid.serializers.i6z import I6ZSerializer
from epiuclid.serializers.pathway_mapper import (
IUCLIDDocumentBundle,
@@ -19,24 +14,9 @@ from epiuclid.serializers.pathway_mapper import (
IUCLIDReferenceSubstanceData,
IUCLIDSubstanceData,
IUCLIDTransformationProductEntry,
PathwayMapper,
)
def _unlinked_documents(manifest_xml: str) -> list[tuple[str | None, str]]:
ns = "http://iuclid6.echa.europa.eu/namespaces/manifest/v1"
root = ET.fromstring(manifest_xml)
base = root.findtext(f"{{{ns}}}base-document-uuid")
linked_targets: set[str | None] = {base}
docs: dict[str, str | None] = {}
for doc in root.findall(f".//{{{ns}}}document"):
uuid = doc.findtext(f"{{{ns}}}uuid")
docs[uuid] = doc.findtext(f"{{{ns}}}type")
for link in doc.findall(f"{{{ns}}}links/{{{ns}}}link"):
linked_targets.add(link.findtext(f"{{{ns}}}ref-uuid"))
return [(doc_type, uuid) for uuid, doc_type in docs.items() if uuid not in linked_targets]
def _make_bundle() -> IUCLIDDocumentBundle:
ref_uuid = uuid4()
sub_uuid = uuid4()
@@ -217,29 +197,3 @@ class I6ZSerializerTest(SimpleTestCase):
}
self.assertIn(parent_ref_key, reference_links)
self.assertIn(product_ref_key, reference_links)
def test_multi_compound_pathway_has_no_unlinked_documents(self):
compounds = [
PathwayCompoundDTO(pk=1, name="Root", smiles="c1ccccc1"),
PathwayCompoundDTO(pk=2, name="P1", smiles="CCO"),
PathwayCompoundDTO(pk=3, name="P2", smiles="CCN"),
PathwayCompoundDTO(pk=4, name="P3", smiles="CCC"),
]
export = PathwayExportDTO(
pathway_uuid=uuid4(),
pathway_name="Regression Pathway",
compounds=compounds,
edges=[
PathwayEdgeDTO(edge_uuid=uuid4(), start_compound_pks=[1], end_compound_pks=[2]),
PathwayEdgeDTO(edge_uuid=uuid4(), start_compound_pks=[1], end_compound_pks=[3]),
PathwayEdgeDTO(edge_uuid=uuid4(), start_compound_pks=[2], end_compound_pks=[4]),
],
root_compound_pks=[1],
)
bundle = PathwayMapper().map(export)
data = I6ZSerializer().serialize(bundle)
with zipfile.ZipFile(io.BytesIO(data)) as zf:
manifest_xml = zf.read("manifest.xml").decode("utf-8")
self.assertEqual(_unlinked_documents(manifest_xml), [])
+3 -2
View File
@@ -31,7 +31,7 @@ class PathwayMapperTest(SimpleTestCase):
)
bundle = PathwayMapper().map(export)
self.assertEqual(len(bundle.substances), 1)
self.assertEqual(len(bundle.substances), 2)
self.assertEqual(len(bundle.reference_substances), 2)
self.assertEqual(len(bundle.endpoint_study_records), 1)
@@ -49,7 +49,8 @@ class PathwayMapperTest(SimpleTestCase):
)
bundle = PathwayMapper().map(export)
self.assertEqual(len(bundle.substances), 1)
# 2 unique compounds -> 2 substances, 2 ref substances
self.assertEqual(len(bundle.substances), 2)
self.assertEqual(len(bundle.reference_substances), 2)
# One endpoint study record per pathway
self.assertEqual(len(bundle.endpoint_study_records), 1)
-34
View File
@@ -186,40 +186,6 @@ window.AdditionalInformationApi = {
return this._handleResponse(response, "createItem");
},
/**
* Create new additional information and attach it to an object.
* @param {string} modelName - Name/type of the additional information model
* @param {Object} data - Data for the new item
* @param {string} attachObjectUrl - UUID of the object this data should be attached to
* @param {string} scenarioUuid - UUID of the scenario
* @returns {Promise<{status: string, uuid: string}>}
*/
async createItemOnNonScenarioObject(modelName, data, attachObjectUrl, scenarioUuid) {
const sanitizedData = this.sanitizePayload(data);
this._log("createItemOnNonScenarioObject", { modelName, data: sanitizedData, attachObjectUrl, scenarioUuid });
sanitizedData.attach_obj_url = attachObjectUrl;
if (scenarioUuid) {
sanitizedData.scenario_uuid = scenarioUuid;
}
// Normalize model name to lowercase
const normalizedName = modelName.toLowerCase();
const response = await fetch(
`/api/v1/information/${normalizedName}/`,
{
method: "POST",
headers: this._buildHeaders(),
body: JSON.stringify(sanitizedData),
},
);
return this._handleResponse(response, "createItemOnNonScenarioObject");
},
/**
* Delete additional information from a scenario
* @param {string} scenarioUuid - UUID of the scenario
+57
View File
@@ -0,0 +1,57 @@
## Prerequisites
Stable [Node.js](https://nodejs.org) version
## Build instructions
npm install
npm start
For production build:
npm run build
You could also build only the style with command
npm run style
## Indigo Service
Ketcher uses Indigo Service for server operations.
You can use `--api-path` parameter to start with it:
npm start -- --api-path=<server-url>
For production build:
npm run build -- --api-path=<server-url>
You can find the instruction for service installation
[here](http://lifescience.opensource.epam.com/indigo/service/index.html).
## Tests instructions
You can start tests for input/output `.mol`-files and render.
npm test
Tests are started for all structures in `test/fixtures` directory.
To start the tests separately:
npm run test-io
npm run test-render
#### Parameters
You can use following parameters to start the tests:
- `--fixtures` - for the choice of a specific directory with molecules
- `--headless` - for start of the browser in headless mode
```
npm run test-render -- --fixtures=fixtures/super --headless
```
If you have added new structures for testing to the `test/fixtures` directory
you have to generate `svg` from them for correct render-test with:
npm run generate-svg
+184
View File
@@ -0,0 +1,184 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2017 EPAM Systems
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+5
View File
@@ -0,0 +1,5 @@
Ketcher version 1 was released under GNU Affero General Public License v3.0
Ketcher version 2 was re-licensed under Apache License, Version 2.
Current version is distributed by the terms of the Apache License, Version 2.
which is included in the file LICENSE, found at the root of the Ketcher source tree.
+19
View File
@@ -0,0 +1,19 @@
Ketcher
Copyright (C) 2017 EPAM Systems
This product includes software developed at EPAM Systems, Inc.
In addition, this product contains dependencies on files licensed under:
The FreeBSD Documentation License https://www.freebsd.org/copyright/freebsd-doc-license.html
The MIT License https://opensource.org/licenses/MIT
X11 License http://www.xfree86.org/3.3.6/COPYRIGHT2.html
Academic Free License https://opensource.org/licenses/AFL-3.0
Apache License, Version 1.0 http://www.apache.org/licenses/LICENSE-1.0
Apache License, Version 2.0 http://www.apache.org/licenses/LICENSE-2.0
The 2-Clause BSD License https://opensource.org/licenses/BSD-2-Clause
The 3-Clause BSD License https://opensource.org/licenses/BSD-3-Clause
ISC License (ISC) https://opensource.org/licenses/ISC
GNU Lesser General Public License version 2.1 https://opensource.org/licenses/LGPL-2.1
The Mozilla Public License https://opensource.org/licenses/MPL-1.0
Public Domain https://wiki.creativecommons.org/wiki/Public_domain
Unlicense http://unlicense.org/
+35
View File
@@ -0,0 +1,35 @@
# EPAM Ketcher projects
Copyright (c) 2017 EPAM Systems, Inc
Ketcher is an open-source web-based chemical structure editor incorporating high performance, good portability, light weight, and ability to easily integrate into a custom web-application. Ketcher is designed for chemists, laboratory scientists and technicians who draw structures and reactions.
## KEY FEATURES
* Fast 2D structure representation that satisfies common chemical drawing standards
* 3D structure visualization
* Draw and edit structures using major tools: Atom Tool, Bond Tool, and Template Tool
* Template library (including custom and user's templates)
* Add atom and bond basic properties and query features, add aliases and Generic groups
* Select, modify, and erase connected and unconnected atoms and bonds using Selection Tool, or using Shift key
* Simple Structure Clean up Tool (checks bonds length, angles and spatial arrangement of atoms) and Advanced Structure Clean up Tool (+ stereochemistry checking and structure layout)
* Aromatize/De-aromatize Tool
* Calculate CIP Descriptors Tool
* Structure Check Tool
* MW and Structure Parameters Calculate Tool
* Stereochemistry support during editing, loading, and saving chemical structures
* Storing history of actions, with the ability to rollback to previous state
* Ability to load and save structures and reactions in MDL Molfile or RXN file format, InChI String, ChemAxon Extended SMILES, ChemAxon Extended CML file formats
* Easy to use R-Group and S-Group tools (Generic, Multiple group, SRU polymer, peratom, Data S-Group)
* Reaction Tool (reaction generating, manual and automatic atom-to-atom mapping)
* Flip/Rotate Tool
* Zoom in/out, hotkeys, cut/copy/paste
* OCR - ability to recognize structures at pictures (image files) and reproduce them
* Copy and paste between different chemical editors
* Settings support (Rendering, Displaying, Debugging)
* Use of SVG to achieve best quality in-browser chemical structure rendering
* Languages: JavaScript with third-party libraries
## Build instructions
Please read [DEVNOTES.md](DEVNOTES.md) for details.
## License
Please read [LICENSE](LICENSE) and [NOTICE](NOTICE) for details.
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1014 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 630 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+570
View File
@@ -0,0 +1,570 @@
**Ketcher** is a tool to draw molecular structures and chemical
reactions.
# Ketcher Overview
**Ketcher** is a tool to draw molecular structures and chemical
reactions. Ketcher operates in two modes, the Server mode with most
functions available and the client mode with limited functions
available.
**Ketcher** consists of the following elements:
![](main.png "Main window")
**Note** : Depending on the screen size, some tools on the _Tool
palette_ can be displayed in expanded or collapsed forms.
Using the _Tool palette_, you can
* draw and edit a molecule or reaction by clicking on and dragging
atoms, bonds, and other elements provided with the buttons on the
_Atoms_ toolbar and _Tool palette_;
* delete any element of the drawing (atom or bond) by clicking on it
with the Erase tool;
* delete the entire molecule or its fragment by a lasso,
rectangular, or fragment selection with the Erase tool;
* draw special structures (see the following sections);
* select the entire molecule or its fragment in one of the following
ways (click on the button to see the list of available options):
* in the expanded form
![](expanded.png "Expanded tool")
* in the collapsed form
![](collapsed.png "Collapsed tool")
To select one atom or bond, click Lasso or Rectangle Selection tool,
and then click the atom or bond.
To select the entire structure:
* Select the Fragment Selection tool and then click the object.
* Select the Lasso or Rectangle Selection tool, and then drag the
mouse to select the object.
* `Ctrl-click` with the Lasso or Rectangle Selection tool.
To select multiple atoms, bonds, structures, or other objects, do one
of the following:
* `Shift-click` with the Lasso or Rectangle Selection tool selects
some (connected or not) atoms/bonds.
* With the Lasso or Rectangle Selection tool click and drag the
mouse around the atoms, bonds, or structures that you want to
select.
**Note** : `Ctrl+Shift-click` with the Lasso or Rectangle Selection tool
selects several structures.
You can use the buttons of the _Main_ toolbar:
![](toolbar.png "Tolbar")
* **Clear Canvas** (1) button to start drawing a new molecule; this
command clears the drawing area;
* **Open…** (2) and **Save As…** (3) buttons to import a molecule
from a molecular file or save it to a supported molecular file
format;
* **Undo** / **Redo** (4), **Cut** (5), **Copy** (6), **Paste** (7),
**Zoom In** / **Out** (8), and **Scaling** (9) buttons to perform
the corresponding actions;
* **Layout** button (10) to change the position of the structure to
work with it with the most convenience;
* **Clean Up** button (11) to improve the appearance of the
structure by assigning them uniform bond lengths and angles.
* **Aromatize** / **Dearomatize** buttons (12) to mark aromatic
structures (to convert a structure to the Aromatic or Kekule
presentation);
* **Calculate CIP** button (13) to determine R/S and E/Z
configurations;
* **Check Structure** button (14) to check the following properties
of the structure:
![](check.png "Structure Ckeck")
* **Calculated Values** button (15) to display some properties of
the structure:
![](analyse.png "Calculated Values")
* **Recognize Molecule** button (16) to recognize a structure in the
image file and load it to the canvas;
* **3D Viewer** button (17) to open the structure in the
three-dimensional Viewer;
* **Settings** button (18) to make some settings for molecular
files:
![](settings.png "Settings")
* **Help** button (19) to view Help;
* **About** button (20) to display version and copyright information
of the program.
**Note** : **Layout,** **Clean Up,** **Aromatize** / **Dearomatize,**
**Calculate CIP,** **Check Structure,** **Calculated Values,**
**Recognize Molecule** and **3D View** buttons are active only in the
Server mode.
# 3D Viewer
The structure appears in a modal window after clicking on the **3D
Viewer** button:
![](miew.png "3D Viewer")
You can perform the following actions:
* Rotate the structure holding the left mouse button;
* Zoom In/Out the structure;
Ketcher Settings allow to change the appearance of the structure and background coloring.
"Lines" drawing method, "Bright" atom name coloring
method and "Light" background coloring are default.
# Drawing Atoms
To draw/edit atoms you can:
* select an atom in the Atoms toolbar and click inside the drawing
area;
* if the desired atom is absent in the toolbar, click on
the ![](periodic-table.png) button to invoke the Periodic Table and
click on the desired atom (available options: _Single_ selection
of a single atom, _List_ choose an atom from the list of selected
options (To allow one atom from a list of atoms of your choice at
that position), _Not List_ - exclude any atom on your list at that
position).
![](periodic-dialog.png "Periodic Table")
* add an atom to the existing molecule by selecting an atom in the
_Atoms_ toolbar, clicking on an atom in the molecule, and dragging
the cursor; the atom will be added with a single bond; vacant
valences will be filled with the corresponding number of hydrogen
atoms;
* change an atom by selecting an atom in the _Atoms_ toolbar and
clicking on the atom to be changed; in the case a wrong valence thus
appears the atom will be underlined in red;
* change an atom by clicking on an existing atom with the
_Selection_ tool and waiting for a couple of seconds for the text
box to appear; type another atom symbol in the text box:
![](inline-edit.png "Change Atom")
* change the charge of an atom by selecting the Charge Plus or
Charge Minus tool and clicking consecutively on an atom to
increase/decrease its charge
![](charge.png "Ions")
* change an atom or its properties by double-clicking on the atom to
invoke the Atom Properties dialog (the dialog also provides atom
query features):
![](atom-dialog.png "Atom Properties")
* click on the Periodic Table button, open the Extended table and
select a corresponding Generic group or Special Node:
![](periodic-dialog-ext.png "Generic Groups")
# Drawing Bonds
To draw/edit bonds you can:
* Click an arrow on the Bond tool ![](bond.png) in the Tools palette
to open the drop-down list with the following bond types:
![](bonds.png)
For the full screen format, the Bond tool from the Tools palette
splits into three: _Single Bond,__Single Up Bond,_ and _Any
Bond_,which include the corresponding bond types:
![](bond-types.png)
* select a bond type from the drop down list and click inside the
drawing area; a bond of the selected type will be drawn;
* click on an atom in the molecule; a bond of the selected type will
be added to the atom at the angle of 120 degrees;
* add a bond to the existing molecule by clicking on an atom in the
molecule and dragging the cursor; in this case you can set the angle
manually;
* change the bond type by clicking on it;
* use the Chain Tool ![](chain.png) to draw consecutive single
bonds;
* change a bond or its properties by double-clicking on the bond to
invoke the Bond Properties dialog:
![](bond-dialog.png "Bond Properties")
* clicking on a drawn stereo bond changes its direction.
* clicking with the Single Bond tool or Chain tool switches the bond type
cyclically: Single-Double-Triple-Single.
# Drawing R-Groups
Use the _R-Group_ toolbox ![](rgroup.png) to draw R-groups in Markush
structures:
![](rgroup-types.png)
Selecting the _R-Group_ _Label_ Tool and clicking on an atom in the
structure invokes the dialog to select the R-Group label for a current
atom position in the structure:
![](rgroup-dialog.png)
Selecting the R-Group label and clicking **OK** converts the structure
into a Markush structure with the selected R-Group label:
![](rgroup-example1.png)
**Note** : You can choose several R-Group labels simultaneously:
![](rgroup-example2.png)
Particular chemical fragments that may be substituted for a given
R-Group form a set of R-Group members. R-Group members can be any
structural fragment, including functional groups and single atoms or
atom lists.
To create a set of R-Group members:
1. Draw a structure to become an R-Group member.
2. Select the structure using the _R-Group Fragment Tool_ to invoke
the R-Group dialog; in this dialog select the label of the
R-Group to assign the fragment to.
3. Click on **OK** to convert the structure into an R-Group member.
An R-Group attachment point is the atom in an R-Group member fragment
that attaches the fragment to the initial Markush structure.
Selecting the _Attachment Point Tool_ and clicking on an atom in the
R-Group fragment converts this atom into an attachment point. If the
R-Group contains more than one attachment point, you can specify one
of them as primary and the other as secondary. You can select between
either the primary or secondary attachment point using the dialog that
appears after clicking on the atom:
![](attpoints-dialog.png)
If there are two attachment points on an R-Group member, there must be
two corresponding attachments (bonds) to the R-Group atom that has the
same R-Group label. Clicking on **OK** in the above dialog creates the
attachment point.
Schematically, the entire process of the R-Group member creation can
be presented as:
![](rgroup-example3.png)
![](rgroup-example4.png)
# R-Group Logic
**Ketcher** enables one to add logic when using R-Groups. To access
the R-Group logic:
1. Create an R-Group member fragment as described above.
2. Move the cursor over the entire fragment for the green frame to
appear, then click inside the fragment. The following dialog
appears:
![](rlogic-dialog.png)
3. Specify **Occurrence** to define how many of an R-Group
occurs. If an R-Group atom appears several times in the initial
structure, you will specify **Occurrence**"&gt;n", n
being the number of occurrences; if it appears once, you see
"R1 > 0".
4. Specify H at **unoccupied** R-Group sites ( **RestH** ): check or
clear the checkbox.
5. Specify the logical **Condition**. Use the R-Group condition **If
R(i) Then** to specify whether the presence of an R-Group is
dependent on the presence of another R-Group.
# Marking S-Groups
To mark S-Groups, use the _S-Group tool_ ![](sgroup.png) and the
following dialog that appears after selecting a fragment with this
tool:
![](sgroup-dialog.png "S-Group Dialog")
Available S-Group types:
_Generic_
Generic is a pair of brackets without any labels.
_Multiple group_
A Multiple group indicates a number of replications of a fragment or a part of a
structure in contracted form.
_SRU Polymer_
The Structural Repeating Unit (SRU) brackets enclose the structural
repeating of a polymer. You have three available patterns:
head-to-tail (the default), head-to-head, and either/unknown.
_Superatom_
An abbreviated structure (abbreviation) is all or part of a structure
(molecule or reaction component) that has been abbreviated to a text
label. Structures that you abbreviate keep their chemical
significance, but their underlying structure is hidden. The current
version can&#39;t display contracted structures but correctly
saves/reads them into/from files.
# Data S-Groups
The _Data S-Groups Tool_ ![](sdata.png) is a separate tool for
comfortable use with the accustomed set of descriptors (like Attached
Data in **Marvin** Editor).
You can attach data to an atom, a fragment, a single bond, or a
group. The defined set of _Names_ and _Values_ is introduced for each
type of selected elements:
![](sdata-dialog.png)
* Select the appropriate S-Group Field Name.
* Select or type the appropriate Field Value.
* Labels can be specified as Absolute, Relative or Attached.
# Changing Structure Display
Use the _Flip/Rotate_ tool ![](transform.png) to change the structure
display:
![](transform-types.png)
For the full screen format, the _Flip/Rotate_ tool is split into
separate buttons:
![](rotate.png)
_Rotate Tool_
This tool allows rotating objects.
* If some objects are selected, the tool rotates the selected objects.
* If no objects are selected, or all objects are selected, the tool rotates the whole canvas
* The default rotation step is 15 degrees.
* Press and hold the Ctrl key for more gradual continuous rotation with 1 degree rotation step
Select any bond on the structure and click Alt+H to rotate the structure so that the selected bond is placed horizontally.
Select any bond on the structure and click Alt+V to rotate the structure so that the selected bond is placed vertically.
_Flip Tool_
This tool flips the objects horizontally or vertically.
* If some objects are selected, the Horizontal Flip tool (or Alt+H) flips the selected objects horizontally
* If no objects are selected, or all objects are selected, the Horizontal Flip tool (or Alt+H) flips each structure horizontally
* If some objects are selected, the Vertical Flip tool (or Alt+V) flips the selected objects vertically
* If no objects are selected, or all objects are selected, the Vertical Flip tool (or Alt+V) flips each structure vertically
# Drawing Reactions
To draw/edit reactions you can
* draw reagents and products as described above;
* use options of the _Reaction Arrow Tool_ ![](reaction.png) to draw an
arrow and pluses in the reaction equation and map same atoms in
reagents and products.
![](reaction-types.png)
**Note** : Reaction Auto-Mapping Tool is available only in the Server
mode.
# Templates toolbar
You can add templates (rings or other predefined structures) to the
structure using the _Templates_ toolbar together with the _Custom
Templates_ button located at the bottom:
![](template.png)
To add a ring to the molecule, select a ring from the toolbar and
click inside the drawing area, or click on an atom or a bond in the
molecule.
Rules of using templates:
* Selecting a template and clicking on an atom in the existing
structure adds the template to the structure connected with a single
bond:
![](template-example1.png)
* Selecting a template and dragging the cursor from an atom in the
existing structure adds the template directly to this atom resulting
in the fused structure:
![](template-example2.png)
* Dragging the cursor from an atom in the existing structure results
in the single bond attachment if the cursor is dragged to more than
the bond length; otherwise the fused structure is drawn.
* Selecting a template and clicking on a bond in the existing
structure created a bond-to-bond fused structure:
![](template-example3.png)
* The bond in the initial structure is replaced with the bond in the
template.
* This procedure doesn&#39;t change the length of the bond in the
initial structure.
* Dragging the cursor relative to the initial bond applies the
template at the corresponding side of the bond.
**Note** : The added template will be fused by the default attachment
atom or bond preset in the program.
**Note** : User is able to define the attachment atom and bond by clicking
the Edit button for template structure.
The _Custom Templates_ button ![](template-lib.png)invokes the scrolling
list of templates available in the program; both built-in and created
by user:
![](template-dialog.png)
To create a user template:
* draw a structure.
* click the Save as button.
* click the Save to Templates button.
* enter a name and define the attachment atom and bond.
# Working with Files
Ketcher supports the following molecular formats that can be entered
either manually or from files:
* MDL Molfile or RXN file;
* Daylight SMILES (Server mode only);
* Daylight SMARTS (Server mode only);
* InChi string (Server mode only);
* CML file (Server mode only).
You can use the **Open…** and **Save As…** buttons of the _Main_
toolbar to import a molecule from a molecular file or save it to a
supported molecular file format. The _Open Structure_ dialog enables
one to either browse for a file (Server mode) or manually input, e.g.,
the Molfile ctable for the molecule to be imported:
![](open.png)
The _Save Structure_ dialog enables one to save the molecular file:
![](save.png)
**Note** : In the standalone version only mol/rxn are supported for
Open and mol/rxn/SMILES for Save.
# Hotkeys
You can use keyboard hotkeys (including Numeric keypad) for some
features/commands of the Editor. To display the hotkeys just place the
cursor over a toolbar button. If a hotkey is available for the button,
it will appear in brackets after the description of the button.
| Key | Action |
| --- | --- |
| `Esc` | Switching between the Lasso/Rectangle/Fragment Selection tools |
| `Del` | Delete the selected objects |
| `0` | Draw Any bond. |
| `1` | Single / Single Up / Single Down / Single Up/Down bond. Consecutive pressing switches between these types. |
| `2` | Double / Double Cis/Trans bond |
| `3` | Draw a triple bond. |
| `4` | Draw an aromatic bond. |
| `5` | Charge Plus/Charge Minus |
| `A` | Draw any atom |
| `H` | Draw a hydrogen |
| `C` | Draw a carbon |
| `N` | Draw a nitrogen |
| `O` | Draw an oxygen |
| `S` | Draw a sulfur |
| `F` | Draw a fluorine |
| `P` | Draw a phosphorus |
| `I` | Draw an iodine |
| `T` | Basic templates. Consecutive pressing switches between different templates |
| `Shift+t` | Open template library |
| `Alt+r` | Rotate tool |
| `Alt+v` | Flip vertically |
| `Alt+h` | Flip horizontally |
| `Ctrl+g` | S-Group tool / Data S-Group tool |
| `Ctrl+d` | Align and select all S-Group data
| `Ctrl+r` | Switching between the R-Group Label Tool/R-Group Fragment Tool/Attachment Point Tool |
| `Ctrl+Shift+r` | R-Group Fragment Tool |
| `Ctrl+Del` | Clear canvas |
| `Ctrl+o` | Open |
| `Ctrl+s` | Save As |
| `Ctrl+z` | Undo |
| `Ctrl+Shift+z` | Redo |
| `Ctrl+x` | Cut selected objects |
| `Ctrl+c` | Copy selected objects |
| `Ctrl+v` | Paste selected objects |
| `+` | Zoom In |
| `-` | Zoom Out |
| `Ctrl+l` | Layout |
| `Ctrl+Shift+l` | Clean Up |
| `Ctrl+p` | Calculate CIP |
| `?` | Help |
**Note** : Please, use `Ctrl+V` to paste the selected object in
Google Chrome and Mozilla Firefox browsers.
**Note 2** : Probably, you have forbidden access to the local storage.
If you are using IE10 or IE11 and didn't forbid access to local storage
intentionally, you can pay attention here: https://stackoverflow.com/a/20848924
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 887 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 757 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 995 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 817 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 604 B

+310
View File
@@ -0,0 +1,310 @@
/****************************************************************************
* Copyright 2017 EPAM Systems
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
***************************************************************************/
var gulp = require('gulp');
var gutil = require('gulp-util');
var plugins = require('gulp-load-plugins')();
var browserify = require('browserify');
var source = require('vinyl-source-stream');
var buffer = require('vinyl-buffer');
var budo = require('budo');
var istanbul = require('browserify-babel-istanbul');
var fs = require('fs');
var cp = require('child_process');
var del = require('del');
var minimist = require('minimist');
var MarkdownIt = require('markdown-it');
var pkg = require('./package.json');
var options = minimist(process.argv.slice(2), {
string: ['dist', 'api-path', 'build-number', 'build-date',
'miew-path'],
boolean: ['sgroup-data-special', 'no-generics', 'no-reactions',
'no-sgroup', 'no-rgroup', 'rgroup-label-only'],
default: {
'dist': 'dist',
'api-path': '',
'miew-path': '',
'build-number': '',
'build-date': new Date() // TODO: format me
}
});
var distrib = ['LICENSE', 'demo.html', 'library.sdf', 'library.svg'];
var bundleConfig = {
entries: 'script',
extensions: ['.js', '.jsx', '.es'],
debug: true,
standalone: pkg.name,
transform: [
['exposify', {
expose: {'raphael': 'Raphael' }
}],
['browserify-replace', {
replace: [
{ from: '__VERSION__', to: pkg.version },
{ from: '__API_PATH__', to: options['api-path'] },
{ from: '__BUILD_NUMBER__', to: options['build-number'] },
{ from: '__BUILD_DATE__', to: options['build-date'] },
{ from: '__MIEW_PATH__', to: options['miew-path'] },
]
}],
['babelify', {
presets: [
["env", {
"targets": {
"browsers": ["last 2 versions", "safari > 8", "chrome > 52"]
},
"useBuiltIns": true
}],
"react"],
plugins: ['lodash', 'transform-class-properties', 'transform-object-rest-spread']
}]
]
};
var iconfont = null;
gulp.task('script', ['patch-version'], function() {
bundleConfig.transform.push(
['loose-envify', {
NODE_ENV: 'production',
global: true
}]
);
return browserify(bundleConfig).bundle()
// Don't transform, see: http://git.io/vcJlV
.pipe(source(`${pkg.name}.js`)).pipe(buffer())
.pipe(plugins.sourcemaps.init({ loadMaps: true }))
.pipe(plugins.uglify({
compress: {
global_defs: {
DEBUG: false
},
dead_code: true
}}))
.pipe(plugins.header(fs.readFileSync('script/banner.js', 'utf8')))
.pipe(plugins.sourcemaps.write('./'))
.pipe(gulp.dest(options.dist));
});
gulp.task('test-render', function() {
return browserify({
entries: 'test/render/render-test.js',
debug: true,
transform: [
istanbul,
['exposify', {
expose: {
raphael: 'Raphael',
resemblejs: 'resemble'
}
}]
]
}).bundle()
.pipe(source('render-test.js'))
.pipe(plugins.header(fs.readFileSync('script/banner.js', 'utf8')))
.pipe(gulp.dest('./test/dist'));
});
gulp.task('style', ['font'], function () {
return gulp.src('style/index.less')
.pipe(plugins.sourcemaps.init())
.pipe(plugins.rename(pkg.name))
.pipe(plugins.less({
paths: ['node_modules/normalize.css'],
modifyVars: iconfont
}))
// don't use less plugins due http://git.io/vqVDy bug
.pipe(plugins.autoprefixer({ browsers: ['> 0.5%'] }))
.pipe(plugins.cleanCss({compatibility: 'ie8'}))
.pipe(plugins.sourcemaps.write('./'))
.pipe(gulp.dest(options.dist));
});
gulp.task('html', ['patch-version'], function () {
var hbs = plugins.hb()
.partials('template/menu/*.hbs')
.partials('template/dialog/*.hbs')
.data(Object.assign({ pkg: pkg }, options));
return gulp.src('template/index.hbs')
.pipe(hbs)
.pipe(plugins.rename('ketcher.html'))
.pipe(gulp.dest(options.dist));
});
gulp.task('doc', function () {
return gulp.src('doc/*.{png, jpg, gif}')
.pipe(gulp.dest(options.dist + '/doc'));
});
gulp.task('help', ['doc'], function () {
return gulp.src('doc/help.md')
.pipe(plugins.tap(markdownify()))
.pipe(gulp.dest(options.dist + '/doc'));
});
gulp.task('font', function (cb) {
return iconfont ? cb() : gulp.src(['icons/*.svg'])
.pipe(plugins.iconfont({
fontName: pkg.name,
formats: ['ttf', 'svg', 'eot', 'woff'],
timestamp: options['build-date'],
normalize: true
}))
.on('glyphs', function(glyphs) {
iconfont = glyphReduce(glyphs);
})
.pipe(gulp.dest(options.dist));
});
gulp.task('images', function () {
return gulp.src('images/*')
.pipe(gulp.dest(options.dist + '/images'));
});
gulp.task('copy', ['images'], function () {
return gulp.src(['raphael'].map(require.resolve)
.concat(distrib))
.pipe(gulp.dest(options.dist));
});
gulp.task('patch-version', function (cb) {
if (pkg.rev)
return cb();
cp.exec('git rev-list ' + pkg.version + '..HEAD --count', function (err, stdout, stderr) {
if (err && stderr.toString().search('path not in') > 0) {
cb(new Error('Could not fetch revision. ' +
'Please git tag the package version.'));
}
else if (!err && stdout > 0) {
pkg.rev = stdout.toString().trim();
pkg.version += ('+r' + pkg.rev);
}
cb();
});
});
gulp.task('lint', function () {
return gulp.src('script/**')
.pipe(plugins.eslint())
.pipe(plugins.eslint.format())
.pipe(plugins.eslint.failAfterError());
});
gulp.task('check-epam-email', function(cb) {
// TODO: should be pre-push and check remote origin
try {
var email = cp.execSync('git config user.email').toString().trim();
if (/@epam.com$/.test(email))
cb();
else {
cb(new Error('Email ' + email + ' is not from EPAM domain.'));
gutil.log('To check git project\'s settings run `git config --list`');
gutil.log('Could not continue. Bye!');
}
} catch(e) {};
});
gulp.task('check-deps-exact', function (cb) {
var semver = require('semver'); // TODO: output corrupted packages
var allValid = ['dependencies', 'devDependencies'].every(d => {
var dep = pkg[d];
return Object.keys(dep).every(name => {
var ver = dep[name];
return (semver.valid(ver) && semver.clean(ver));
});
});
if (!allValid) {
cb(new gutil.PluginError('check-deps-exact',
'All top level dependencies should be installed' +
'using `npm install --save-exact` command'));
} else
cb();
});
gulp.task('clean', function () {
return del.sync([options.dist + '/**', pkg.name + '-*.zip']);
});
gulp.task('archive', ['clean', 'assets', 'code'], function () {
var an = pkg.name + '-' + pkg.version;
return gulp.src(['**', '!*.map'], { cwd: options.dist })
.pipe(plugins.rename(function (path) {
path.dirname = an + '/' + path.dirname;
return path;
}))
.pipe(plugins.zip(an + '.zip'))
.pipe(gulp.dest('.'));
});
gulp.task('serve', ['clean', 'style', 'html', 'assets'], function(cb) {
var server = budo(`${bundleConfig.entries}:${pkg.name}.js`, {
dir: options.dist,
browserify: bundleConfig,
stream: process.stdout,
host: '0.0.0.0',
live: true,
watchGlob: `${options.dist}/*.{html,css}`,
staticOptions: {
index: `ketcher.html`
}
}).on('exit', cb);
gulp.watch('style/**.less', ['style']);
gulp.watch('template/**', ['html']);
gulp.watch('doc/**', ['help']);
gulp.watch(['gulpfile.js', 'package.json'], function() {
server.close();
cp.spawn('gulp', process.argv.slice(2), {
stdio: 'inherit'
});
process.exit(0);
});
return server;
});
function markdownify (options) {
var header = '<!DOCTYPE html>';
var footer = '';
var md = MarkdownIt(Object.assign({
html: true,
linkify: true,
typographer: true
}, options));
return function process (file) {
var data = md.render(file.contents.toString());
file.contents = new Buffer(header + data + footer);
file.path = gutil.replaceExtension(file.path, '.html');
};
}
function glyphReduce(glyphs) {
return glyphs.reduce(function (res, glyph) {
res['icon-' + glyph.name] = "'" + glyph.unicode[0] + "'";
return res;
}, {});
}
gulp.task('pre-commit', ['lint', 'check-epam-email',
'check-deps-exact']);
gulp.task('assets', ['copy', 'help']);
gulp.task('code', ['style', 'script', 'html']);
gulp.task('build', ['clean', 'code', 'assets']);
+60
View File
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generated by IcoMoon.io -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
width="1000"
height="1000"
viewBox="0 0 1000 1000"
id="svg2"
inkscape:version="0.91 r13725"
sodipodi:docname="about.svg">
<metadata
id="metadata11">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs9" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1280"
inkscape:window-height="974"
id="namedview7"
showgrid="false"
inkscape:zoom="0.52678571"
inkscape:cx="708.89624"
inkscape:cy="375.86441"
inkscape:window-x="0"
inkscape:window-y="23"
inkscape:window-maximized="1"
inkscape:current-layer="svg2" />
<g
id="icomoon-ignore"
transform="translate(0,552)" />
<path
d="m 666.66667,812.5 0,-104.16667 c 0,-11.71875 -9.11459,-20.83333 -20.83334,-20.83333 l -62.5,0 0,-333.33333 c 0,-11.71875 -9.11458,-20.83334 -20.83333,-20.83334 l -208.33333,0 c -11.71875,0 -20.83334,9.11459 -20.83334,20.83334 l 0,104.16666 c 0,11.71875 9.11459,20.83334 20.83334,20.83334 l 62.5,0 0,208.33333 -62.5,0 c -11.71875,0 -20.83334,9.11458 -20.83334,20.83333 l 0,104.16667 c 0,11.71875 9.11459,20.83333 20.83334,20.83333 l 291.66666,0 c 11.71875,0 20.83334,-9.11458 20.83334,-20.83333 z m -83.33334,-583.33333 0,-104.16667 c 0,-11.71875 -9.11458,-20.83333 -20.83333,-20.83333 l -125,0 c -11.71875,0 -20.83333,9.11458 -20.83333,20.83333 l 0,104.16667 C 416.66667,240.88542 425.78125,250 437.5,250 l 125,0 c 11.71875,0 20.83333,-9.11458 20.83333,-20.83333 z M 1000,500 c 0,276.04167 -223.95833,500 -500,500 C 223.95833,1000 0,776.04167 0,500 0,223.95833 223.95833,0 500,0 c 276.04167,0 500,223.95833 500,500 z"
id="path5"
inkscape:connector-curvature="0" />
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="1000" viewBox="0 0 750 750"><path d="M585.781 276.055l-8.73 15.797H559.59s-24.945 0-31.598 34.921l-7.066 34.922h47.808l-8.73 17.461H518.43l-24.942 122.23c-10.394 52.384-47.812 52.384-47.812 52.384h-54.047l7.07-17.461h17.043s26.61 0 33.676-34.922l24.945-122.23H435.7l8.73-17.462h33.676l7.067-34.922c10.394-52.382 47.808-52.382 47.808-52.382zm0 186.668l-33.676-46.149-12.054 7.485-12.059 7.898 34.508 52.8-57.79 55.708 19.958 19.957 54.047-51.969 37 56.125 12.058-7.898 12.055-7.485-41.574-60.699 57.371-55.293-9.977-9.976-9.976-10.395zm166.297-23.282c-.367 91.606-55.082 174.254-139.273 210.364a113.081 113.081 0 0 1-14.137 38.25c-19.602 35.312-55.742 58.308-96.035 61.113H110.172a118.487 118.487 0 0 1-93.543-60.7 124.736 124.736 0 0 1-4.574-115.16l180.851-356.706a41.618 41.618 0 0 0 5.82-17.461V80.238c-15.167-4.71-25.246-19.058-24.53-34.922C173.78 16.215 196.23.832 225.331.832h162.14a41.57 41.57 0 0 1 48.641 44.066c.91 17.106-11.008 32.23-27.855 35.34v118.489c0 6.234 5.406 12.054 8.316 17.46l7.899 12.473c71.336-30.547 153.21-23.472 218.25 18.856 65.039 42.332 104.66 114.328 105.613 191.925M87.723 644.816c4.988 7.899 13.304 21.204 22.863 21.204h369.18c-111.414-17.891-193.543-113.74-194.149-226.58a228.229 228.229 0 0 1 69.012-163.386l-12.473-22.453a127.586 127.586 0 0 1-17.875-54.875V83.98h-41.574v114.747a133.024 133.024 0 0 1-14.555 56.957L86.472 614.469a34.522 34.522 0 0 0 0 29.933m430.298-16.629c103.734-1.148 187.007-85.964 186.246-189.703-.762-103.738-85.27-187.328-189.012-186.949-103.738.379-187.64 84.578-187.645 188.32.684 104.54 85.868 188.79 190.41 188.332"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+56
View File
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="1000px"
height="1000px"
viewBox="0 0 1000 1000"
version="1.1"
id="svg3017"
inkscape:version="0.48.4 r9939"
sodipodi:docname="arom.svg">
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1280"
inkscape:window-height="985"
id="namedview3024"
showgrid="false"
inkscape:zoom="0.788"
inkscape:cx="451.14213"
inkscape:cy="500.63452"
inkscape:window-x="-2"
inkscape:window-y="16"
inkscape:window-maximized="1"
inkscape:current-layer="svg3017" />
<metadata
id="metadata3028">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs3026" />
<path
id="path3022"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 461.48649,555.69054 -18.12703,65.26757 -59.56757,0 77.6946,-254.13783 75.43513,0 78.77297,254.13783 -61.87838,0 -19.56486,-65.26757 z m 64.44594,-43.03243 -15.86757,-53.91892 C 505.5973,443.7446 501.02703,424.8473 497.27838,409.75001 l -0.77027,0 c -3.8,14.99459 -7.54865,34.3027 -11.70811,48.98918 l -15.04595,53.91892 z M 261.5,86.96875 C 182.01696,224.66603 102.49955,362.34349 23,500.03125 c 79.52601,137.6516 159.01303,275.32583 238.5,413 159,0 318,0 477,0 79.48707,-137.67411 158.97388,-275.34846 238.5,-413 -79.49951,-137.68778 -159.017,-275.3652 -238.5,-413.0625 -159,0 -318,0 -477,0 z m 14.84375,25.65625 c 149.10417,0 298.20833,0 447.3125,0 C 798.20833,241.75 872.76042,370.875 947.3125,500 872.76042,629.125 798.20833,758.25 723.65625,887.375 c -149.10417,0 -298.20833,0 -447.3125,0 C 201.79167,758.25 127.23958,629.125 52.6875,500 127.23958,370.875 201.79167,241.75 276.34375,112.625 z M 500,242 C 374.04836,239.29512 257.52291,342.03121 244.04439,467.20792 225.70876,589.90115 309.65836,716.56 429.41412,748.25904 546.18673,783.97655 681.70279,722.69748 732.56859,611.82988 787.72264,502.74797 750.317,358.42131 649.3252,289.64929 606.10754,258.72845 553.13905,241.83584 500,242 z m 0,25.65625 c 115.57457,-2.72299 222.02818,93.36956 231.17737,208.59286 14.12026,112.83847 -67.3644,226.90051 -178.63888,250.18431 C 444.05716,753.87 322.67939,690.15798 283.7264,585.26728 240.65276,481.8684 285.98523,352.23297 384.27419,298.42521 419.26475,278.23386 459.61245,267.57542 500,267.65625 z"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccccccccccccccccccccccccccccccccccccccc" />
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

+57
View File
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
sodipodi:docname="bond_any.svg"
inkscape:version="0.48.4 r9939"
id="svg3079"
version="1.1"
viewBox="0 0 1000 1000"
height="1000px"
width="1000px">
<metadata
id="metadata3094">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs3092" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1280"
inkscape:window-height="985"
id="namedview3090"
showgrid="false"
inkscape:zoom="0.788"
inkscape:cx="405.21344"
inkscape:cy="500"
inkscape:window-x="-2"
inkscape:window-y="16"
inkscape:window-maximized="1"
inkscape:current-layer="svg3079"
inkscape:snap-object-midpoints="true" />
<path
inkscape:connector-curvature="0"
d="m 807.5889,373.70454 -17.41667,-30.746 167.41111,-82.27937 17.41667,30.69765 z m -257.39723,123.32238 -17.41667,-30.746 174.48334,-84.4548 17.41667,30.746 z m -252.7,118.97153 -17.41667,-30.69765 167.41112,-82.37607 17.41666,30.79434 z M 42.416668,739.32083 25.000001,708.52649 199.48334,624.07169 216.9,654.81768 z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path3088" />
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="1000px"
height="1000px"
viewBox="0 0 1000 1000"
version="1.1"
id="svg3199"
inkscape:version="0.48.4 r9939"
sodipodi:docname="bond_aromatic.svg">
<metadata
id="metadata3214">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs3212" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1280"
inkscape:window-height="985"
id="namedview3210"
showgrid="false"
inkscape:zoom="0.816"
inkscape:cx="500"
inkscape:cy="500.61275"
inkscape:window-x="-2"
inkscape:window-y="16"
inkscape:window-maximized="1"
inkscape:current-layer="svg3199" />
<path
sodipodi:nodetypes="cccccccccccccccccscc"
inkscape:connector-curvature="0"
d="m 354.55846,455.58994 -16.98973,-32.66037 206.56763,-108.1644 16.93696,32.66037 z M 667.12719,292.3936 650.19023,259.73323 856.75785,151.56883 873.69481,184.2292 z M 41.936961,618.78627 24.999999,586.1259 231.56762,477.9615 l 16.93696,32.66037 z m 100.896279,229.6449 -16.89739,-32.60761 832.16677,-431.21981 16.89739,32.60761 z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path3208" />
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+56
View File
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="1000px"
height="1000px"
viewBox="0 0 1000 1000"
version="1.1"
id="svg3263"
inkscape:version="0.48.4 r9939"
sodipodi:docname="bond_crossed.svg">
<metadata
id="metadata3274">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs3272" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1280"
inkscape:window-height="985"
id="namedview3270"
showgrid="false"
inkscape:zoom="0.236"
inkscape:cx="-36.016949"
inkscape:cy="502.11864"
inkscape:window-x="-2"
inkscape:window-y="16"
inkscape:window-maximized="1"
inkscape:current-layer="svg3263" />
<path
sodipodi:nodetypes="cccccccccc"
inkscape:connector-curvature="0"
d="M 214.92692,956.46459 C 189.8034,944.24469 170.00797,930.87122 197.28383,907.30639 382.73782,619.38274 568.19182,331.45908 753.64582,43.535417 778.76894,55.755786 798.55569,69.131287 771.28239,92.693699 585.83056,380.61733 400.37874,668.54096 214.92692,956.46459 z M 39.649459,756.73262 C 23.919163,733.64652 13.307802,712.17807 48.705005,705.24876 352.58686,547.29687 656.46869,389.34499 960.35054,231.39309 976.08086,254.47918 986.69218,275.94762 951.295,282.87693 647.41315,440.82883 343.53131,598.78072 39.649459,756.73262 z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path3268" />
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+56
View File
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="1000px"
height="1000px"
viewBox="0 0 1000 1000"
version="1.1"
id="svg3325"
inkscape:version="0.48.4 r9939"
sodipodi:docname="bond_double.svg">
<metadata
id="metadata3336">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs3334" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1280"
inkscape:window-height="985"
id="namedview3332"
showgrid="false"
inkscape:zoom="0.236"
inkscape:cx="-36.016949"
inkscape:cy="502.11864"
inkscape:window-x="-2"
inkscape:window-y="16"
inkscape:window-maximized="1"
inkscape:current-layer="svg3325" />
<path
sodipodi:nodetypes="cccccccccc"
inkscape:connector-curvature="0"
d="M 41.850878,615.30151 25.000005,582.7935 855.18403,152.59836 872.0349,185.11295 z M 145.33039,847.40164 128.44654,814.90682 958.11615,383.66304 975,416.15125 z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path3330" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="1000px"
height="1000px"
viewBox="0 0 1000 1000"
version="1.1"
id="svg3397"
inkscape:version="0.48.4 r9939"
sodipodi:docname="bond_doublearomatic.svg">
<metadata
id="metadata3420">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs3418" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1280"
inkscape:window-height="985"
id="namedview3416"
showgrid="false"
inkscape:zoom="0.236"
inkscape:cx="-36.016949"
inkscape:cy="502.11864"
inkscape:window-x="-2"
inkscape:window-y="16"
inkscape:window-maximized="1"
inkscape:current-layer="svg3397" />
<path
sodipodi:nodetypes="cccccccccccccccccccccccccccccccccccccccc"
inkscape:connector-curvature="0"
d="m 924.75556,388.79722 50.24444,0 0,50.61389 -50.24444,0 z m -110.04167,-238.81945 50.24444,0 0,50.66667 -50.24444,0 z m -458.79723,245.83889 50.24445,0 0,50.61389 -50.24445,0 z m 114.68612,238.71389 50.24444,0 0,50.66667 -50.24444,0 z m 147.51389,-30.34722 -17.25834,-33.30278 210.425,-110.09444 17.31112,33.25 z M 149.97777,850.02222 132.71944,816.71944 343.09166,706.57222 360.40278,739.875 z m 346.48612,-477.58611 -17.31111,-33.30278 210.425,-110.14722 17.25833,33.30278 z M 42.258329,613.57778 24.999996,580.275 235.425,470.12778 l 17.25833,33.30277 z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path3414" />
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+56
View File
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="1000px"
height="1000px"
viewBox="0 0 1000 1000"
version="1.1"
id="svg3483"
inkscape:version="0.48.4 r9939"
sodipodi:docname="bond_down.svg">
<metadata
id="metadata3508">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs3506" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1280"
inkscape:window-height="985"
id="namedview3504"
showgrid="false"
inkscape:zoom="0.236"
inkscape:cx="-36.016949"
inkscape:cy="502.11864"
inkscape:window-x="-2"
inkscape:window-y="16"
inkscape:window-maximized="1"
inkscape:current-layer="svg3483" />
<path
sodipodi:nodetypes="ccccccccccccccccccccccccccccccccccccccccccccc"
inkscape:connector-curvature="0"
d="M 935.1657,651.47986 599.14704,136.37837 638.98135,110.39833 975,625.49322 z M 823.74444,681.96584 526.55071,226.22657 566.29265,200.20694 863.48638,655.84066 z M 712.32317,712.35286 453.8884,316.03517 493.66993,290.08812 752.11131,686.40581 z M 600.86234,742.6607 381.14693,405.67226 420.94165,379.65263 640.65706,716.5883 z M 489.50045,773.00814 308.47142,495.3951 348.26614,469.3227 529.2424,746.98851 z M 378.13857,803.35557 235.8487,585.01239 l 39.74194,-25.96685 142.2371,218.2904 z M 266.7635,833.61724 163.18638,674.76161 l 39.74855,-25.91407 103.5771,158.85563 z m -111.50703,30.43319 -64.864339,-99.4868 39.794719,-26.01962 64.86434,99.43402 z M 51.16478,889.60166 25.000011,849.44408 64.801335,823.51682 90.966087,863.6678 z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path3502" />
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+56
View File
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="1000px"
height="1000px"
viewBox="0 0 1000 1000"
version="1.1"
id="svg3555"
inkscape:version="0.48.4 r9939"
sodipodi:docname="bond_single.svg">
<metadata
id="metadata3564">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs3562" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1280"
inkscape:window-height="985"
id="namedview3560"
showgrid="false"
inkscape:zoom="0.236"
inkscape:cx="-36.016949"
inkscape:cy="502.11864"
inkscape:window-x="-2"
inkscape:window-y="16"
inkscape:window-maximized="1"
inkscape:current-layer="svg3555" />
<path
sodipodi:nodetypes="ccccc"
inkscape:connector-curvature="0"
d="M 43.960687,760.20465 25.000012,723.7281 956.03271,239.79536 l 18.96728,36.47653 z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path3558" />
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="1000px"
height="1000px"
viewBox="0 0 1000 1000"
version="1.1"
id="svg3619"
inkscape:version="0.48.4 r9939"
sodipodi:docname="bond_singlearomatic.svg">
<metadata
id="metadata3636">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs3634" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1280"
inkscape:window-height="985"
id="namedview3632"
showgrid="false"
inkscape:zoom="0.236"
inkscape:cx="-36.016949"
inkscape:cy="502.11864"
inkscape:window-x="-2"
inkscape:window-y="16"
inkscape:window-maximized="1"
inkscape:current-layer="svg3619" />
<path
sodipodi:nodetypes="ccccccccccccccccccccccccc"
inkscape:connector-curvature="0"
d="M 144.81672,842.64301 127.97985,810.1234 958.16973,380.25911 975,412.77214 z M 798.40091,157.357 l 49.27733,0 0,49.59389 -49.27733,0 z m -449.35232,240.68865 49.22458,0 0,49.59389 -49.22458,0 z m 137.64943,-22.89761 -16.93579,-32.55258 206.07845,-107.8931 16.88303,32.55259 z M 41.935778,611.35214 24.999991,578.7468 231.02568,470.8537 l 16.93579,32.60535 z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path3630" />
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="1000px"
height="1000px"
viewBox="0 0 1000 1000"
version="1.1"
id="svg3700"
inkscape:version="0.48.4 r9939"
sodipodi:docname="bond_singledouble.svg">
<metadata
id="metadata3721">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs3719" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1280"
inkscape:window-height="985"
id="namedview3717"
showgrid="false"
inkscape:zoom="0.236"
inkscape:cx="-36.016949"
inkscape:cy="502.11864"
inkscape:window-x="-2"
inkscape:window-y="16"
inkscape:window-maximized="1"
inkscape:current-layer="svg3700" />
<path
sodipodi:nodetypes="ccccccccccccccccccccccccccccccccccc"
inkscape:connector-curvature="0"
d="m 407.50181,572.41513 -17.15373,-33.0935 209.11717,-109.4672 17.15373,33.04073 z M 765.83004,386.20479 748.67631,353.16406 957.84627,243.64409 975,276.7376 z M 42.100958,763.21739 25.000008,730.12389 234.11718,620.6567 251.27091,653.69742 z M 679.74471,691.11895 662.59098,658.02544 871.76093,548.55825 888.91466,581.59897 z M 326.06117,877.32929 308.90744,844.23578 518.02461,734.71581 535.17834,767.75654 z M 486.62009,265.2314 469.46636,232.1379 678.58353,122.67071 l 17.15373,33.04072 z m -358.38101,190.80227 -17.15374,-33.04073 209.11718,-109.51997 17.15373,33.04072 z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path3715" />
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+56
View File
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="1000px"
height="1000px"
viewBox="0 0 1000 1000"
version="1.1"
id="svg3772"
inkscape:version="0.48.4 r9939"
sodipodi:docname="bond_triple.svg">
<metadata
id="metadata3785">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs3783" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1280"
inkscape:window-height="985"
id="namedview3781"
showgrid="false"
inkscape:zoom="0.236"
inkscape:cx="-36.016949"
inkscape:cy="502.11864"
inkscape:window-x="-2"
inkscape:window-y="16"
inkscape:window-maximized="1"
inkscape:current-layer="svg3772" />
<path
sodipodi:nodetypes="ccccccccccccccc"
inkscape:connector-curvature="0"
d="M 226.3235,893.86915 211.12878,864.54149 959.81188,476.67372 975,505.99479 z m -95.68588,-183.37209 -15.24089,-29.30129 748.00383,-389.13399 15.2409,29.29469 z M 40.194716,523.32628 24.999992,494.00521 773.68311,106.13085 l 15.18812,29.32107 z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path3779" />
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+56
View File
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="1000px"
height="1000px"
viewBox="0 0 1000 1000"
version="1.1"
id="svg3832"
inkscape:version="0.48.4 r9939"
sodipodi:docname="bond_up.svg">
<metadata
id="metadata3841">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs3839" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1280"
inkscape:window-height="985"
id="namedview3837"
showgrid="false"
inkscape:zoom="0.236"
inkscape:cx="-36.016949"
inkscape:cy="502.11864"
inkscape:window-x="-2"
inkscape:window-y="16"
inkscape:window-maximized="1"
inkscape:current-layer="svg3832" />
<path
sodipodi:nodetypes="cccc"
inkscape:connector-curvature="0"
d="M 24.999996,889.02501 661.975,110.975 975,595.84445 z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path3835" />
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+56
View File
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="1000px"
height="1000px"
viewBox="0 0 1000 1000"
version="1.1"
id="svg3888"
inkscape:version="0.48.4 r9939"
sodipodi:docname="bond_updown.svg">
<metadata
id="metadata3897">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs3895" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1280"
inkscape:window-height="985"
id="namedview3893"
showgrid="false"
inkscape:zoom="1"
inkscape:cx="-36.016949"
inkscape:cy="502.11864"
inkscape:window-x="-2"
inkscape:window-y="16"
inkscape:window-maximized="1"
inkscape:current-layer="svg3888" />
<path
sodipodi:nodetypes="ccccccccccccccccccc"
inkscape:connector-curvature="0"
d="M 56.508328,883.35139 24.999995,866.04028 111.08055,708.55139 263.97777,765.12917 249.88611,513.5375 480.31389,685.85695 385.57777,318.52361 691.58333,602.78473 509.025,116.64861 975,587.84862 949.45556,613.12917 601.86111,261.73472 778.87778,732.9875 451.12778,428.45973 540.63889,775.89584 290.10277,588.53473 302.98055,817.90695 127.75833,753.09584 z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path3891" />
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

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