5 Commits

Author SHA1 Message Date
421d33dddc adjusted migration
Some checks failed
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
72a63b4876 [Feature] Sync Features from Client Repo (#425)
- New PackageImporter
- Fix persisting Molfile on Structure creation
- Add NonPersistent Prediction
- Pathway View Options
- Edit Node Fix

Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#425
2026-07-16 07:50:46 +12:00
2c2437e3f5 [Feature] Generate Compounds by Molfile (#423)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#423
2026-07-15 23:56:24 +12:00
9bc9f86ff1 [Fix] Pathway SVG Export has Edges again, Fix In/Out Edge coloring (#422)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#422
2026-07-03 07:24:02 +12:00
dba6514013 [Chore] Package Export, Blanks for fields, Styling (#421)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#421
2026-07-02 20:12:26 +12:00
33 changed files with 1828 additions and 1184 deletions

View File

@ -185,7 +185,7 @@ class PESStructure(CompoundStructure):
def create( def create(
compound: Compound, compound: Compound,
pes_link: str, pes_link: str,
mol_file: str, molfile: str,
smiles: str, smiles: str,
name: str = None, name: str = None,
description: str = None, description: str = None,
@ -204,7 +204,7 @@ class PESStructure(CompoundStructure):
cs.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() cs.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
cs.smiles = smiles cs.smiles = smiles
cs.mol_file = mol_file cs.molfile = molfile
cs.pes_link = pes_link cs.pes_link = pes_link
cs.compound = compound cs.compound = compound

View File

@ -3,7 +3,11 @@
<div class="collapse-arrow bg-base-200 collapse"> <div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked /> <input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Link to PES</div> <div class="collapse-title text-xl font-medium">Link to PES</div>
<div class="collapse-content">{{ compound_structure.pes_link }}</div> <div class="collapse-content">
<p>
<a href="{{ compound_structure.pes_link }}" class="hover:bg-base-200">{{ compound_structure.pes_link }}</a>
</p>
</div>
</div> </div>
<!-- Image Representation --> <!-- Image Representation -->

View File

@ -3,7 +3,11 @@
<div class="collapse-arrow bg-base-200 collapse"> <div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked /> <input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Link to PES</div> <div class="collapse-title text-xl font-medium">Link to PES</div>
<div class="collapse-content">{{ compound.default_structure.pes_link }}</div> <div class="collapse-content">
<p>
<a href="{{ compound.default_structure.pes_link }}" class="hover:bg-base-200">{{ compound.default_structure.pes_link }}</a>
</p>
</div>
</div> </div>
<!-- Image Representation --> <!-- Image Representation -->

View File

@ -3,7 +3,11 @@
<div class="collapse-arrow bg-base-200 collapse"> <div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked /> <input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Link to PES</div> <div class="collapse-title text-xl font-medium">Link to PES</div>
<div class="collapse-content">{{ node.default_node_label.pes_link }}</div> <div class="collapse-content">
<p>
<a href="{{ node.default_node_label.pes_link }}" class="hover:bg-base-200">{{ node.default_node_label.pes_link }}</a>
</p>
</div>
</div> </div>
<!-- Image Representation --> <!-- Image Representation -->

View File

@ -2,14 +2,13 @@ import base64
import requests import requests
from django.conf import settings as s from django.conf import settings as s
from django.core.exceptions import BadRequest from django.http import HttpResponse, HttpResponseBadRequest
from django.http import HttpResponse
from django.shortcuts import redirect from django.shortcuts import redirect
from bayer.models import PESCompound from bayer.models import PESCompound
from epdb.logic import PackageManager from epdb.logic import PackageManager
from epdb.models import Pathway, Node from epdb.models import Pathway, Node
from epdb.views import _anonymous_or_real from epdb.views import _anonymous_or_real, error
from utilities.decorators import package_permission_required from utilities.decorators import package_permission_required
Package = s.GET_PACKAGE_MODEL() Package = s.GET_PACKAGE_MODEL()
@ -23,7 +22,11 @@ def create_pes(request, package_uuid):
if request.method == "POST": if request.method == "POST":
if current_package.classification_level == Package.Classification.INTERNAL: if current_package.classification_level == Package.Classification.INTERNAL:
raise BadRequest("Cannot create PESs for internal packages.") return error(
request,
f'Creation of PESs for package {current_package.name} failed!',
"Creating PESs for internal packages is not allowed.",
)
compound_name = request.POST.get('compound-name') compound_name = request.POST.get('compound-name')
compound_description = request.POST.get('compound-description') compound_description = request.POST.get('compound-description')
@ -33,25 +36,25 @@ def create_pes(request, package_uuid):
try: try:
pes_data = fetch_pes(request, pes_link) pes_data = fetch_pes(request, pes_link)
except ValueError as e: except ValueError as e:
return BadRequest(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", "") classification = pes_data.get("classificationLevel", "")
if "secret" == classification.lower(): if "secret" == classification.lower():
if current_package.classification_level != Package.Classification.SECRET: if current_package.classification_level != Package.Classification.SECRET:
return BadRequest("Cannot create PESs for non-secret packages.") return HttpResponseBadRequest("Cannot create PESs for non-secret packages.")
data_pools = pes_data.get("dataPools") data_pools = pes_data.get("dataPools")
if data_pools: if data_pools:
if s.DATA_POOL_MAPPING[current_package.data_pool.name] not in data_pools: if s.DATA_POOL_MAPPING[current_package.data_pool.name] not in data_pools:
return BadRequest( return HttpResponseBadRequest(
f"PES data pool {s.DATA_POOL_MAPPING[current_package.data_pool.name]} not found in PES data") 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) pes = PESCompound.create(current_package, pes_data, compound_name, compound_description)
return redirect(pes.url) return redirect(pes.url)
else: else:
return BadRequest("Please provide a PES link.") return HttpResponseBadRequest("Please provide a PES link.")
else: else:
pass pass
@ -65,7 +68,11 @@ def create_pes_node(request, package_uuid, pathway_uuid):
if request.method == "POST": if request.method == "POST":
if current_package.classification_level == Package.Classification.INTERNAL: if current_package.classification_level == Package.Classification.INTERNAL:
raise BadRequest("Cannot create PESs for internal packages.") return error(
request,
f'Creation of PESs for package {current_package.name} failed!',
"Creating PESs for internal packages is not allowed.",
)
compound_name = request.POST.get('compound-name') compound_name = request.POST.get('compound-name')
compound_description = request.POST.get('compound-description') compound_description = request.POST.get('compound-description')
@ -75,18 +82,18 @@ def create_pes_node(request, package_uuid, pathway_uuid):
try: try:
pes_data = fetch_pes(request, pes_link) pes_data = fetch_pes(request, pes_link)
except ValueError as e: except ValueError as e:
return BadRequest(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", "") classification = pes_data.get("classificationLevel", "")
if "secret" == classification.lower(): if "secret" == classification.lower():
if current_package.classification_level != Package.Classification.SECRET: if current_package.classification_level != Package.Classification.SECRET:
return BadRequest("Cannot create PESs for non-secret packages.") return HttpResponseBadRequest("Cannot create PESs for non-secret packages.")
data_pools = pes_data.get("dataPools") data_pools = pes_data.get("dataPools")
if data_pools: if data_pools:
if s.DATA_POOL_MAPPING[current_package.data_pool.name] not in data_pools: if s.DATA_POOL_MAPPING[current_package.data_pool.name] not in data_pools:
return BadRequest( return HttpResponseBadRequest(
f"PES data pool {s.DATA_POOL_MAPPING[current_package.data_pool.name]} not found in PES data") 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) pes = PESCompound.create(current_package, pes_data, compound_name, compound_description)
@ -109,7 +116,7 @@ def create_pes_node(request, package_uuid, pathway_uuid):
return redirect(current_pathway.url) return redirect(current_pathway.url)
else: else:
return BadRequest("Please provide a PES link.") return HttpResponseBadRequest("Please provide a PES link.")
else: else:
pass pass

View File

@ -117,25 +117,28 @@ class APIPermissionTestBase(TestCase):
# Create test compounds in each package # Create test compounds in each package
cls.reviewed_compound = Compound.create( cls.reviewed_compound = Compound.create(
cls.reviewed_package, "C", "Reviewed Compound", "Test compound" cls.reviewed_package, "C", name="Reviewed Compound", description="Test compound"
) )
cls.owned_compound = Compound.create( cls.owned_compound = Compound.create(
cls.unreviewed_package_owned, "CC", "Owned Compound", "Test compound" cls.unreviewed_package_owned, "CC", name="Owned Compound", description="Test compound"
) )
cls.read_compound = Compound.create( cls.read_compound = Compound.create(
cls.unreviewed_package_read, "CCC", "Read Compound", "Test compound" cls.unreviewed_package_read, "CCC", name="Read Compound", description="Test compound"
) )
cls.write_compound = Compound.create( cls.write_compound = Compound.create(
cls.unreviewed_package_write, "CCCC", "Write Compound", "Test compound" cls.unreviewed_package_write, "CCCC", name="Write Compound", description="Test compound"
) )
cls.all_compound = Compound.create( cls.all_compound = Compound.create(
cls.unreviewed_package_all, "CCCCC", "All Compound", "Test compound" cls.unreviewed_package_all, "CCCCC", name="All Compound", description="Test compound"
) )
cls.no_access_compound = Compound.create( cls.no_access_compound = Compound.create(
cls.unreviewed_package_no_access, "CCCCCC", "No Access Compound", "Test compound" cls.unreviewed_package_no_access,
"CCCCCC",
name="No Access Compound",
description="Test compound",
) )
cls.group_compound = Compound.create( cls.group_compound = Compound.create(
cls.group_package, "CCCCCCC", "Group Compound", "Test compound" cls.group_package, "CCCCCCC", name="Group Compound", description="Test compound"
) )

View File

@ -294,8 +294,8 @@ class CompoundPaginationAPITest(BaseTestAPIGetPaginated, TestCase):
return Compound.create( return Compound.create(
package, package,
smiles, smiles,
f"Reviewed Compound {idx:03d}", name=f"Reviewed Compound {idx:03d}",
"Compound for pagination tests", description="Compound for pagination tests",
) )
@classmethod @classmethod
@ -305,8 +305,8 @@ class CompoundPaginationAPITest(BaseTestAPIGetPaginated, TestCase):
return Compound.create( return Compound.create(
package, package,
smiles, smiles,
f"Draft Compound {idx:03d}", name=f"Draft Compound {idx:03d}",
"Compound for pagination tests", description="Compound for pagination tests",
) )

View File

@ -1,2 +1,10 @@
class InvalidSMILESException(Exception): class InvalidSMILESException(Exception):
pass pass
class InvalidMolfileException(Exception):
pass
class PackageImportException(Exception):
pass

View File

@ -633,9 +633,14 @@ class CompoundSchema(Schema):
reviewStatus: str = Field(False, alias="review_status") reviewStatus: str = Field(False, alias="review_status")
scenarios: List["SimpleScenario"] = Field([], alias="scenarios") scenarios: List["SimpleScenario"] = Field([], alias="scenarios")
structures: List["CompoundStructureSchema"] = [] structures: List["CompoundStructureSchema"] = []
pesLink: str | None = Field(None, alias="pes_link")
@staticmethod @staticmethod
def resolve_review_status(obj: CompoundStructure): def resolve_pes_link(obj: Compound):
return getattr(obj.default_structure, "pes_link", None)
@staticmethod
def resolve_review_status(obj: Compound):
return "reviewed" if obj.package.reviewed else "unreviewed" return "reviewed" if obj.package.reviewed else "unreviewed"
@staticmethod @staticmethod
@ -709,6 +714,7 @@ class CompoundStructureSchema(Schema):
reviewStatus: str = Field(None, alias="review_status") reviewStatus: str = Field(None, alias="review_status")
scenarios: List["SimpleScenario"] = Field([], alias="scenarios") scenarios: List["SimpleScenario"] = Field([], alias="scenarios")
smiles: str = Field(None, alias="smiles") smiles: str = Field(None, alias="smiles")
pesLink: str | None = Field(None, alias="pes_link")
@staticmethod @staticmethod
def resolve_review_status(obj: CompoundStructure): def resolve_review_status(obj: CompoundStructure):
@ -844,6 +850,7 @@ def get_package_compound_structure(request, package_uuid, compound_uuid, structu
class CreateCompound(Schema): class CreateCompound(Schema):
compoundSmiles: str compoundSmiles: str
compoundMolFile: str | None = None
compoundName: str | None = None compoundName: str | None = None
compoundDescription: str | None = None compoundDescription: str | None = None
inchi: str | None = None inchi: str | None = None
@ -883,7 +890,12 @@ def create_package_compound(
c = PESCompound.create(p, pes_data, c.compoundName, c.compoundDescription) c = PESCompound.create(p, pes_data, c.compoundName, c.compoundDescription)
else: else:
c = Compound.create( c = Compound.create(
p, c.compoundSmiles, c.compoundName, c.compoundDescription, inchi=c.inchi p,
c.compoundSmiles,
molfile=c.compoundMolFile,
name=c.compoundName,
description=c.compoundDescription,
inchi=c.inchi
) )
return redirect(c.url) return redirect(c.url)
except ValueError as e: except ValueError as e:
@ -1687,14 +1699,15 @@ class PathwayNode(Schema):
dt50s: List[Dict[str, str]] = Field([], alias="dt50s") dt50s: List[Dict[str, str]] = Field([], alias="dt50s")
engineeredIntermediate: bool = Field(None, alias="engineered_intermediate") engineeredIntermediate: bool = Field(None, alias="engineered_intermediate")
id: str = Field(None, alias="url") id: str = Field(None, alias="url")
idcomp: str = Field(None, alias="default_node_label.url") idcomp: str = Field(None, alias="node_label_id")
idreact: str = Field(None, alias="default_node_label.url") idreact: str = Field(None, alias="node_label_id")
image: str = Field(None, alias="image") image: str = Field(None, alias="image")
imageSize: int = Field(None, alias="image_size") imageSize: int = Field(None, alias="image_size")
name: str = Field(None, alias="name") name: str = Field(None, alias="name")
proposed: List[Dict[str, str]] = Field([], alias="proposed_intermediate") proposed: List[Dict[str, str]] = []
smiles: str = Field(None, alias="default_node_label.smiles") smiles: str = Field(None, alias="smiles")
pseudo: bool = Field(False, alias="pseudo") pseudo: bool = Field(False, alias="pseudo")
pesLink: str | None = Field(None, alias="pes_link")
@staticmethod @staticmethod
def resolve_atom_count(obj: Node): def resolve_atom_count(obj: Node):
@ -1707,24 +1720,10 @@ class PathwayNode(Schema):
# TODO # TODO
return [] return []
@staticmethod
def resolve_engineered_intermediate(obj: Node):
# TODO
return False
@staticmethod
def resolve_image(obj: Node):
return f"{obj.default_node_label.url}?image=svg"
@staticmethod @staticmethod
def resolve_image_size(obj: Node): def resolve_image_size(obj: Node):
return 400 return 400
@staticmethod
def resolve_proposed_intermediate(obj: Node):
# TODO
return []
class PathwaySchema(Schema): class PathwaySchema(Schema):
aliases: List[str] = Field([], alias="aliases") aliases: List[str] = Field([], alias="aliases")
@ -1976,6 +1975,7 @@ def get_package_pathway_node(request, package_uuid, pathway_uuid, node_uuid):
class CreateNode(Schema): class CreateNode(Schema):
nodeAsSmiles: str nodeAsSmiles: str
nodeAsMolFile: str | None = None
nodeName: str | None = None nodeName: str | None = None
nodeReason: str | None = None nodeReason: str | None = None
nodeDepth: str | None = None nodeDepth: str | None = None
@ -2037,7 +2037,14 @@ def add_pathway_node(request, package_uuid, pathway_uuid, n: Form[CreateNode]):
else: else:
node_depth = -1 node_depth = -1
node = Node.create(pw, n.nodeAsSmiles, node_depth, n.nodeName, n.nodeReason) node = Node.create(
pw,
n.nodeAsSmiles,
node_depth,
molfile=n.nodeAsMolFile,
name=n.nodeName,
description=n.nodeReason,
)
return redirect(node.url) return redirect(node.url)
except ValueError: except ValueError:
@ -2392,3 +2399,26 @@ def get_setting(request, setting_uuid):
return 403, { return 403, {
"message": f"Getting Setting with id {setting_uuid} failed due to insufficient rights!" "message": f"Getting Setting with id {setting_uuid} failed due to insufficient rights!"
} }
########
# Util #
########
class NonPersistent(Schema):
smiles: str
setting_url: str = Field(..., alias="settingUri")
@router.post("/util", response={200: Any, 403: Error})
def predict(request, np: Form[NonPersistent]):
try:
from epdb.logic import SPathway
setting = SettingManager.get_setting_by_url(request.user, np.setting_url)
spw = SPathway(prediction_setting=setting, root_nodes=[np.smiles])
spw.predict()
return spw.to_json()
except ValueError:
return 403, {
"message": f"Getting Setting with id {np.setting_url} failed due to insufficient rights!"
}

View File

@ -1078,10 +1078,8 @@ class PackageManager(object):
data: Dict[str, Any], data: Dict[str, Any],
owner: User, owner: User,
preserve_uuids=False, preserve_uuids=False,
add_import_timestamp=True,
trust_reviewed=False,
) -> Package: ) -> Package:
importer = PackageImporter(data, preserve_uuids, add_import_timestamp, trust_reviewed) importer = PackageImporter(data, preserve_uuids)
imported_package = importer.do_import() imported_package = importer.do_import()
up = UserPackagePermission() up = UserPackagePermission()
@ -1898,6 +1896,12 @@ class SPathway(object):
"to": to_indices, "to": to_indices,
} }
if edge.rule:
e["rule"] = edge.rule.simple_json()
if edge.probability:
e["probability"] = edge.probability
edges.append(e) edges.append(e)
return { return {

View File

@ -99,11 +99,15 @@ class Command(BaseCommand):
new_license.image_link = f"https://licensebuttons.net/l/{cc_string}/4.0/88x31.png" new_license.image_link = f"https://licensebuttons.net/l/{cc_string}/4.0/88x31.png"
new_license.save() new_license.save()
def import_package(self, data, owner): def import_package(self, data, owner, all_envipath_user_group):
return PackageManager.import_legacy_package( p = PackageManager.import_legacy_package(
data, owner, keep_ids=True, add_import_timestamp=False, trust_reviewed=True data, owner, keep_ids=True, add_import_timestamp=False, trust_reviewed=True
) )
PackageManager.grant_read(owner, p, all_envipath_user_group)
return p
def create_default_setting(self, owner, packages): def create_default_setting(self, owner, packages):
s = SettingManager.create_setting( s = SettingManager.create_setting(
owner, owner,
@ -198,7 +202,7 @@ class Command(BaseCommand):
s.BASE_DIR / "fixtures" / "packages" / "2025-07-18" / p, encoding="utf-8" s.BASE_DIR / "fixtures" / "packages" / "2025-07-18" / p, encoding="utf-8"
).read() ).read()
) )
imported_package = self.import_package(package_data, admin) imported_package = self.import_package(package_data, admin, g)
mapping[p.replace(".json", "")] = imported_package mapping[p.replace(".json", "")] = imported_package
setting = self.create_default_setting(admin, [mapping["EAWAG-BBD"]]) setting = self.create_default_setting(admin, [mapping["EAWAG-BBD"]])

View File

@ -0,0 +1,150 @@
# Generated by Django 6.0.3 on 2026-07-01 20:59
import django.contrib.postgres.fields
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("epdb", "0026_auto_20260602_1718"),
]
operations = [
migrations.AlterField(
model_name="compound",
name="aliases",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), blank=True, default=list, verbose_name="Aliases"
),
),
migrations.AlterField(
model_name="compound",
name="default_structure",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="compound_default_structure",
to="epdb.compoundstructure",
verbose_name="Default Structure",
),
),
migrations.AlterField(
model_name="compound",
name="scenarios",
field=models.ManyToManyField(
blank=True, to="epdb.scenario", verbose_name="Attached Scenarios"
),
),
migrations.AlterField(
model_name="compoundstructure",
name="aliases",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), blank=True, default=list, verbose_name="Aliases"
),
),
migrations.AlterField(
model_name="compoundstructure",
name="scenarios",
field=models.ManyToManyField(
blank=True, to="epdb.scenario", verbose_name="Attached Scenarios"
),
),
migrations.AlterField(
model_name="edge",
name="aliases",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), blank=True, default=list, verbose_name="Aliases"
),
),
migrations.AlterField(
model_name="edge",
name="edge_label",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="epdb.reaction",
verbose_name="Edge label",
),
),
migrations.AlterField(
model_name="edge",
name="scenarios",
field=models.ManyToManyField(
blank=True, to="epdb.scenario", verbose_name="Attached Scenarios"
),
),
migrations.AlterField(
model_name="node",
name="aliases",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), blank=True, default=list, verbose_name="Aliases"
),
),
migrations.AlterField(
model_name="node",
name="scenarios",
field=models.ManyToManyField(
blank=True, to="epdb.scenario", verbose_name="Attached Scenarios"
),
),
migrations.AlterField(
model_name="pathway",
name="aliases",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), blank=True, default=list, verbose_name="Aliases"
),
),
migrations.AlterField(
model_name="pathway",
name="scenarios",
field=models.ManyToManyField(
blank=True, to="epdb.scenario", verbose_name="Attached Scenarios"
),
),
migrations.AlterField(
model_name="reaction",
name="aliases",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), blank=True, default=list, verbose_name="Aliases"
),
),
migrations.AlterField(
model_name="reaction",
name="medline_references",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(),
blank=True,
null=True,
verbose_name="Medline References",
),
),
migrations.AlterField(
model_name="reaction",
name="rules",
field=models.ManyToManyField(
blank=True, related_name="reaction_rule", to="epdb.rule", verbose_name="Rule"
),
),
migrations.AlterField(
model_name="reaction",
name="scenarios",
field=models.ManyToManyField(
blank=True, to="epdb.scenario", verbose_name="Attached Scenarios"
),
),
migrations.AlterField(
model_name="rule",
name="aliases",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), blank=True, default=list, verbose_name="Aliases"
),
),
migrations.AlterField(
model_name="rule",
name="scenarios",
field=models.ManyToManyField(
blank=True, to="epdb.scenario", verbose_name="Attached Scenarios"
),
),
]

View File

@ -31,7 +31,10 @@ from sklearn.model_selection import ShuffleSplit
from bridge.contracts import Property from bridge.contracts import Property
from bridge.dto import RunResult, PropertyPrediction from bridge.dto import RunResult, PropertyPrediction
from epdb.exceptions import InvalidSMILESException from epdb.exceptions import (
InvalidMolfileException,
InvalidSMILESException,
)
from utilities.chem import FormatConverter, IndigoUtils, PredictionResult, ProductSet from utilities.chem import FormatConverter, IndigoUtils, PredictionResult, ProductSet
from utilities.ml import ( from utilities.ml import (
ApplicabilityDomainPCA, ApplicabilityDomainPCA,
@ -637,7 +640,7 @@ class EnviPathModel(TimeStampedModel):
class AliasMixin(models.Model): class AliasMixin(models.Model):
aliases = ArrayField( aliases = ArrayField(
models.TextField(blank=False, null=False), verbose_name="Aliases", default=list models.TextField(blank=False, null=False), verbose_name="Aliases", default=list, blank=True
) )
@transaction.atomic @transaction.atomic
@ -660,7 +663,9 @@ class AliasMixin(models.Model):
class ScenarioMixin(models.Model): class ScenarioMixin(models.Model):
scenarios = models.ManyToManyField("epdb.Scenario", verbose_name="Attached Scenarios") scenarios = models.ManyToManyField(
"epdb.Scenario", verbose_name="Attached Scenarios", blank=True
)
@transaction.atomic @transaction.atomic
def set_scenarios(self, scenarios: List["Scenario"]): def set_scenarios(self, scenarios: List["Scenario"]):
@ -792,6 +797,13 @@ class Compound(
external_identifiers = GenericRelation("ExternalIdentifier") external_identifiers = GenericRelation("ExternalIdentifier")
def get_structure_by_smiles(self, smiles: str) -> "CompoundStructure":
for struct in self.structures.all():
if struct.smiles == smiles:
return struct
raise ValueError(f"No structure with SMILES {smiles} found for {self.get_name()}")
@property @property
def structures(self) -> QuerySet: def structures(self) -> QuerySet:
return CompoundStructure.objects.filter(compound=self) return CompoundStructure.objects.filter(compound=self)
@ -860,8 +872,24 @@ class Compound(
@staticmethod @staticmethod
@transaction.atomic @transaction.atomic
def create( def create(
package: "Package", smiles: str, name: str = None, description: str = None, molfile: str = None, *args, **kwargs package: "Package",
smiles: str,
molfile: str | None = None,
name: str | None = None,
description: str | None = None,
*args,
**kwargs,
) -> "Compound": ) -> "Compound":
# Molfile has precendence over SMILES
if molfile is not None and molfile.strip() != "":
mol = FormatConverter.from_molfile(molfile)
if mol is None:
raise InvalidMolfileException("Given molfile is invalid")
else:
# Overwrite SMILES from molfile
smiles = FormatConverter.to_smiles(mol)
if smiles is None or smiles.strip() == "": if smiles is None or smiles.strip() == "":
raise InvalidSMILESException("SMILES is required") raise InvalidSMILESException("SMILES is required")
@ -894,19 +922,19 @@ class Compound(
return found_compound return found_compound
qs = CompoundStructure.objects.filter(smiles=standardized_smiles, compound__package=package) qs = CompoundStructure.objects.filter(smiles=standardized_smiles, compound__package=package)
if subclasses: if subclasses:
qs = qs.not_instance_of(*subclasses) qs = qs.not_instance_of(*subclasses)
# Check if we can find the standardized one # Check if we can find the standardized one
if qs.exists(): if qs.exists():
# TODO should we add a structure?
found_structure = qs.first() found_structure = qs.first()
found_compound = found_structure.compound found_compound = found_structure.compound
# 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: if name:
found_structure.add_alias(name)
found_compound.add_alias(name) found_compound.add_alias(name)
return found_compound return found_compound
@ -938,7 +966,12 @@ class Compound(
) )
cs = CompoundStructure.create( cs = CompoundStructure.create(
c, smiles, name=name, description=description, normalized_structure=is_standardized c,
smiles,
molfile=molfile,
name=name,
description=description,
normalized_structure=is_standardized,
) )
c.default_structure = cs c.default_structure = cs
@ -951,11 +984,22 @@ class Compound(
self, self,
smiles: str, smiles: str,
name: str = None, name: str = None,
molfile: str = None,
description: str = None, description: str = None,
default_structure: bool = False, default_structure: bool = False,
*args, *args,
**kwargs, **kwargs,
) -> "CompoundStructure": ) -> "CompoundStructure":
# Molfile has precendence over SMILES
if molfile is not None and molfile.strip() != "":
mol = FormatConverter.from_molfile(molfile)
if mol is None:
raise InvalidMolfileException("Given molfile is invalid")
else:
# Overwrite SMILES from molfile
smiles = FormatConverter.to_smiles(mol)
if smiles is None or smiles == "": if smiles is None or smiles == "":
raise ValueError("SMILES is required") raise ValueError("SMILES is required")
@ -975,16 +1019,28 @@ class Compound(
) )
if is_standardized: if is_standardized:
CompoundStructure.objects.get(smiles__in=smiles, compound__package=self.package) CompoundStructure.objects.get(smiles=smiles, compound__package=self.package)
# Check if we find a direct match for a given SMILES and/or its standardized SMILES # Check if we find a direct match for a given SMILES and/or its standardized SMILES
if CompoundStructure.objects.filter( if CompoundStructure.objects.filter(smiles=smiles, compound__package=self.package).exists():
smiles__in=smiles, compound__package=self.package found_cs = CompoundStructure.objects.get(smiles=smiles, compound__package=self.package)
).exists():
return CompoundStructure.objects.get(smiles__in=smiles, compound__package=self.package) if found_cs.molfile is None and (molfile is not None and molfile.strip() != ""):
logger.info(
f"Setting molfile for found CompoundStructure(uuid={found_cs.uuid}) as it was empty"
)
found_cs.molfile = molfile
found_cs.save()
return found_cs
cs = CompoundStructure.create( cs = CompoundStructure.create(
self, smiles, name=name, description=description, normalized_structure=is_standardized self,
smiles,
name=name,
molfile=molfile,
description=description,
normalized_structure=is_standardized,
) )
if default_structure: if default_structure:
@ -1165,8 +1221,24 @@ class CompoundStructure(
@staticmethod @staticmethod
@transaction.atomic @transaction.atomic
def create( def create(
compound: Compound, smiles: str, name: str = None, description: str = None, molfile: str = None, *args, **kwargs compound: Compound,
smiles: str,
molfile: str = None,
name: str = None,
description: str = None,
*args,
**kwargs,
): ):
# Molfile has precendence over SMILES
if molfile is not None and molfile.strip() != "":
mol = FormatConverter.from_molfile(molfile)
if mol is None:
raise InvalidMolfileException("Given molfile is invalid")
else:
# Overwrite SMILES from molfile
smiles = FormatConverter.to_smiles(mol)
# Clean for potential XSS # Clean for potential XSS
if name is not None: if name is not None:
name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
@ -1177,6 +1249,13 @@ class CompoundStructure(
if name: if name:
found_cs.add_alias(name) found_cs.add_alias(name)
if found_cs.molfile is None and (molfile is not None and molfile.strip() != ""):
logger.info(
f"Setting molfile for found CompoundStructure(uuid={found_cs.uuid}) as it was empty"
)
found_cs.molfile = molfile
found_cs.save()
return found_cs return found_cs
if compound.pk is None: if compound.pk is None:
@ -1194,7 +1273,7 @@ class CompoundStructure(
cs.compound = compound cs.compound = compound
# Check if molfile is present and valid # Check if molfile is present and valid
if molfile is not None and FormatConverter.from_molfile(molfile) is not None: if molfile is not None and molfile.strip() != "":
cs.molfile = molfile cs.molfile = molfile
if "normalized_structure" in kwargs: if "normalized_structure" in kwargs:
@ -1658,10 +1737,15 @@ class Reaction(
products = models.ManyToManyField( products = models.ManyToManyField(
"epdb.CompoundStructure", verbose_name="Products", related_name="reaction_products" "epdb.CompoundStructure", verbose_name="Products", related_name="reaction_products"
) )
rules = models.ManyToManyField("epdb.Rule", verbose_name="Rule", related_name="reaction_rule") rules = models.ManyToManyField(
"epdb.Rule", verbose_name="Rule", related_name="reaction_rule", blank=True
)
multi_step = models.BooleanField(verbose_name="Multistep Reaction") multi_step = models.BooleanField(verbose_name="Multistep Reaction")
medline_references = ArrayField( medline_references = ArrayField(
models.TextField(blank=False, null=False), null=True, verbose_name="Medline References" models.TextField(blank=False, null=False),
null=True,
verbose_name="Medline References",
blank=True,
) )
external_identifiers = GenericRelation("ExternalIdentifier") external_identifiers = GenericRelation("ExternalIdentifier")
@ -1750,7 +1834,7 @@ class Reaction(
r = Reaction() r = Reaction()
r.package = package r.package = package
if r is not None: if name is not None:
r.name = name r.name = name
if description is not None and description.strip() != "": if description is not None and description.strip() != "":
@ -1987,6 +2071,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
# add links start -> pseudo # add links start -> pseudo
new_link = { new_link = {
"name": link["name"], "name": link["name"],
"plain_name": link["plain_name"],
"id": link["id"], "id": link["id"],
"url": link["url"], "url": link["url"],
"image": link["image"], "image": link["image"],
@ -1996,6 +2081,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
"source": node_url_to_idx[link["start_node_urls"][0]], "source": node_url_to_idx[link["start_node_urls"][0]],
"target": pseudo_idx, "target": pseudo_idx,
"app_domain": link.get("app_domain", None), "app_domain": link.get("app_domain", None),
"to_pseudo": True,
} }
adjusted_links.append(new_link) adjusted_links.append(new_link)
@ -2003,6 +2089,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
for target in link["end_node_urls"]: for target in link["end_node_urls"]:
new_link = { new_link = {
"name": link["name"], "name": link["name"],
"plain_name": link["plain_name"],
"id": link["id"], "id": link["id"],
"url": link["url"], "url": link["url"],
"image": link["image"], "image": link["image"],
@ -2013,6 +2100,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
"target": node_url_to_idx[target], "target": node_url_to_idx[target],
"app_domain": link.get("app_domain", None), "app_domain": link.get("app_domain", None),
"multi_step": link["multi_step"], "multi_step": link["multi_step"],
"from_pseudo": True,
} }
adjusted_links.append(new_link) adjusted_links.append(new_link)
@ -2222,11 +2310,12 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
def add_node( def add_node(
self, self,
smiles: str, smiles: str,
name: Optional[str] = None, molfile: str | None = None,
description: Optional[str] = None, name: str | None = None,
depth: Optional[int] = -1, description: str | None = None,
depth: int = -1,
): ):
return Node.create(self, smiles, depth, name=name, description=description) return Node.create(self, smiles, depth, molfile=molfile, name=name, description=description)
@transaction.atomic @transaction.atomic
def add_edge( def add_edge(
@ -2255,10 +2344,12 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
depth_map[0] = list() depth_map[0] = list()
processed = set() processed = set()
data_driven_root_nodes = self.node_set.all().annotate( data_driven_root_nodes = (
prod_cnt=Count('edge_products'), self.node_set.all()
educt_cnt=Count('edge_educts') .annotate(prod_cnt=Count("edge_products"), educt_cnt=Count("edge_educts"))
).filter(prod_cnt=0, educt_cnt__gt=0).distinct() .filter(prod_cnt=0, educt_cnt__gt=0)
.distinct()
)
# Eval QuerySet # Eval QuerySet
root_nodes_by_depth = list(self.root_nodes) root_nodes_by_depth = list(self.root_nodes)
@ -2320,17 +2411,19 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
def _url(self): def _url(self):
return "{}/node/{}".format(self.pathway.url, self.uuid) return "{}/node/{}".format(self.pathway.url, self.uuid)
def get_name(self): def get_name(self, include_suffix=True):
non_generic_name = True non_generic_name = True
if self.name == "no name": if self.name is None or self.name == "no name":
non_generic_name = False non_generic_name = False
return ( if non_generic_name:
self.name return self.name
if non_generic_name else:
else f"{self.default_node_label.name} (taken from underlying structure)" if include_suffix:
) return f"{self.default_node_label.name} (taken from underlying structure)"
else:
return self.default_node_label.name
def d3_json(self): def d3_json(self):
app_domain_data = self.get_app_domain_assessment_data() app_domain_data = self.get_app_domain_assessment_data()
@ -2351,11 +2444,16 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
"node_label_id": self.default_node_label.url, "node_label_id": self.default_node_label.url,
"image": f"{self.url}?image=svg", "image": f"{self.url}?image=svg",
"image_svg": IndigoUtils.mol_to_svg( "image_svg": IndigoUtils.mol_to_svg(
self.default_node_label.molfile if self.default_node_label.molfile is not None and self.default_node_label.molfile.strip() else self.default_node_label.smiles, self.default_node_label.molfile
width=40, height=40 if self.default_node_label.molfile is not None
and self.default_node_label.molfile.strip()
else self.default_node_label.smiles,
width=40,
height=40,
), ),
"image_type": "svg", "image_type": "svg",
"name": self.get_name(), "name": self.get_name(),
"plain_name": self.get_name(include_suffix=False),
"smiles": self.default_node_label.smiles, "smiles": self.default_node_label.smiles,
"scenarios": [{"name": s.get_name(), "url": s.url} for s in self.scenarios.all()], "scenarios": [{"name": s.get_name(), "url": s.url} for s in self.scenarios.all()],
"app_domain": { "app_domain": {
@ -2366,6 +2464,7 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
}, },
"predicted_properties": predicted_properties, "predicted_properties": predicted_properties,
"is_engineered_intermediate": self.kv.get("is_engineered_intermediate", False), "is_engineered_intermediate": self.kv.get("is_engineered_intermediate", False),
"proposed": self.is_proposed_intermediate(),
"timeseries": self.get_timeseries_data(), "timeseries": self.get_timeseries_data(),
**structure_data, **structure_data,
} }
@ -2378,35 +2477,53 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
pathway: "Pathway", pathway: "Pathway",
smiles: str, smiles: str,
depth: int, depth: int,
name: Optional[str] = None, molfile: str | None = None,
description: Optional[str] = None, name: str | None = None,
description: str | None = None,
): ):
# Molfile has precendence over SMILES
if molfile is not None and molfile.strip() != "":
mol = FormatConverter.from_molfile(molfile)
if mol is None:
raise InvalidMolfileException("Given molfile is invalid")
else:
# Overwrite SMILES from molfile
smiles = FormatConverter.to_smiles(mol)
stereo_removed = False stereo_removed = False
if pathway.predicted and FormatConverter.has_stereo(smiles): if pathway.predicted and FormatConverter.has_stereo(smiles):
smiles = FormatConverter.standardize(smiles, remove_stereo=True) smiles = FormatConverter.standardize(smiles, remove_stereo=True)
stereo_removed = True stereo_removed = True
c = Compound.create(pathway.package, smiles, name=name, description=description) c = Compound.create(
pathway.package, smiles, molfile=molfile, name=name, description=description
)
if Node.objects.filter(pathway=pathway, default_node_label=c.default_structure).exists(): structure = c.get_structure_by_smiles(smiles)
return Node.objects.get(pathway=pathway, default_node_label=c.default_structure)
if Node.objects.filter(pathway=pathway, default_node_label=structure).exists():
return Node.objects.get(pathway=pathway, default_node_label=structure)
n = Node() n = Node()
n.stereo_removed = stereo_removed n.stereo_removed = stereo_removed
n.pathway = pathway n.pathway = pathway
n.depth = depth n.depth = depth
n.default_node_label = c.default_structure n.default_node_label = structure
n.save() n.save()
n.node_labels.add(c.default_structure) n.node_labels.add(structure)
n.save() n.save()
return n return n
@property @property
def as_svg(self): def as_svg(self):
if self.default_node_label.molfile is not None and self.default_node_label.molfile.strip() != "": if (
self.default_node_label.molfile is not None
and self.default_node_label.molfile.strip() != ""
):
return IndigoUtils.mol_to_svg(self.default_node_label.molfile) return IndigoUtils.mol_to_svg(self.default_node_label.molfile)
return IndigoUtils.mol_to_svg(self.default_node_label.smiles) return IndigoUtils.mol_to_svg(self.default_node_label.smiles)
@ -2436,6 +2553,28 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
return data return data
def is_proposed_intermediate(self):
collected = defaultdict(dict)
for ai in self.additional_information.filter(
type__in=["ProposedIntermediate", "TransformationProductImportance", "Confidence"],
scenario__isnull=False,
):
collected[str(ai.scenario.uuid)]["scenarioId"] = ai.scenario.url
collected[str(ai.scenario.uuid)]["scenarioName"] = ai.scenario.name
if ai.type == "ProposedIntermediate":
collected[str(ai.scenario.uuid)]["proposed"] = True
if ai.type == "Confidence":
collected[str(ai.scenario.uuid)]["Confidence"] = ai.get().level
if ai.type == "TransformationProductImportance":
collected[str(ai.scenario.uuid)]["Transformation product importance"] = (
ai.get().importance.value
)
return list(collected.values())
def simple_json(self, include_description=False): def simple_json(self, include_description=False):
res = super().simple_json() res = super().simple_json()
name = res.get("name", None) name = res.get("name", None)
@ -2465,6 +2604,7 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
def d3_json(self): def d3_json(self):
edge_json = { edge_json = {
"name": self.get_name(), "name": self.get_name(),
"plain_name": self.get_name(include_suffix=False),
"id": self.url, "id": self.url,
"url": self.url, "url": self.url,
"image": self.url + "?image=svg", "image": self.url + "?image=svg",
@ -2579,17 +2719,19 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
return res return res
def get_name(self): def get_name(self, include_suffix=True):
non_generic_name = True non_generic_name = True
if self.name == "no name": if self.name == "no name":
non_generic_name = False non_generic_name = False
return ( if non_generic_name:
self.name return self.name
if non_generic_name else:
else f"{self.edge_label.name} (taken from underlying reaction)" if include_suffix:
) return f"{self.edge_label.name} (taken from underlying reaction)"
else:
return self.edge_label.name
class EPModel(PolymorphicModel, EnviPathModel, AdditionalInformationMixin): class EPModel(PolymorphicModel, EnviPathModel, AdditionalInformationMixin):

View File

@ -19,7 +19,7 @@ from sentry_sdk import capture_exception
from utilities.chem import FormatConverter, IndigoUtils from utilities.chem import FormatConverter, IndigoUtils
from utilities.decorators import package_permission_required from utilities.decorators import package_permission_required
from .exceptions import InvalidSMILESException from .exceptions import InvalidMolfileException, InvalidSMILESException
from .logic import ( from .logic import (
EPDBURLParser, EPDBURLParser,
@ -785,6 +785,7 @@ def models(request):
{"Home": s.SERVER_URL}, {"Home": s.SERVER_URL},
{"Model": s.SERVER_URL + "/model"}, {"Model": s.SERVER_URL + "/model"},
] ]
context["entity_type"] = "model" context["entity_type"] = "model"
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/models/" context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/models/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
@ -809,7 +810,7 @@ def models(request):
"type": "enviformer", "type": "enviformer",
"requires_rule_packages": False, "requires_rule_packages": False,
"requires_data_packages": True, "requires_data_packages": True,
}, }
if s.FLAGS.get("PLUGINS", False): if s.FLAGS.get("PLUGINS", False):
for k, v in s.CLASSIFIER_PLUGINS.items(): for k, v in s.CLASSIFIER_PLUGINS.items():
@ -942,13 +943,12 @@ def package_models(request, package_uuid):
"requires_data_packages": True, "requires_data_packages": True,
}, },
} }
if s.ENVIFORMER_PRESENT: if s.ENVIFORMER_PRESENT:
context["model_types"]["EnviFormer"] = { context["model_types"]["EnviFormer"] = {
"type": "enviformer", "type": "enviformer",
"requires_rule_packages": False, "requires_rule_packages": False,
"requires_data_packages": True, "requires_data_packages": True,
}, }
if s.FLAGS.get("PLUGINS", False): if s.FLAGS.get("PLUGINS", False):
for k, v in s.CLASSIFIER_PLUGINS.items(): for k, v in s.CLASSIFIER_PLUGINS.items():
@ -1124,8 +1124,10 @@ def package_model(request, package_uuid, model_uuid):
} }
) )
# Sort data by prob desc # Sort data by prob desc
res["pred"] = sorted(res["pred"], key=lambda x: x["probability"], reverse=True) res["pred"] = sorted(
res["pred"], key=lambda x: x["probability"], reverse=True
)
return JsonResponse(res, safe=False) return JsonResponse(res, safe=False)
@ -1230,9 +1232,7 @@ def package(request, package_uuid):
if request.method == "GET": if request.method == "GET":
if request.GET.get("export", False) == "true": if request.GET.get("export", False) == "true":
filename = f"{current_package.get_name().replace(' ', '_')}_{current_package.uuid}.json" filename = f"{current_package.get_name().replace(' ', '_')}_{current_package.uuid}.json"
pack_json = PackageManager.export_package( pack_json = PackageManager.export_package(current_package)
current_package, include_models=False, include_external_identifiers=False
)
response = JsonResponse(pack_json, content_type="application/json") response = JsonResponse(pack_json, content_type="application/json")
response["Content-Disposition"] = f'attachment; filename="{filename}"' response["Content-Disposition"] = f'attachment; filename="{filename}"'
@ -1415,12 +1415,18 @@ def package_compounds(request, package_uuid):
elif request.method == "POST": elif request.method == "POST":
compound_name = request.POST.get("compound-name") compound_name = request.POST.get("compound-name")
compound_smiles = request.POST.get("compound-smiles") compound_smiles = request.POST.get("compound-smiles")
compound_molfile = request.POST.get("compound-molfile")
compound_description = request.POST.get("compound-description") compound_description = request.POST.get("compound-description")
try: try:
c = Compound.create( c = Compound.create(
current_package, compound_smiles, compound_name, compound_description current_package,
compound_smiles,
molfile=compound_molfile,
name=compound_name,
description=compound_description,
) )
except ValueError as e: except (InvalidSMILESException, InvalidMolfileException) as e:
raise BadRequest(str(e)) raise BadRequest(str(e))
return redirect(c.url) return redirect(c.url)
@ -1546,11 +1552,15 @@ def package_compound_structures(request, package_uuid, compound_uuid):
elif request.method == "POST": elif request.method == "POST":
structure_name = request.POST.get("structure-name") structure_name = request.POST.get("structure-name")
structure_smiles = request.POST.get("structure-smiles") structure_smiles = request.POST.get("structure-smiles")
structure_molfile = request.POST.get("structure-molfile")
structure_description = request.POST.get("structure-description") structure_description = request.POST.get("structure-description")
try: try:
cs = current_compound.add_structure( cs = current_compound.add_structure(
structure_smiles, structure_name, structure_description structure_smiles,
molfile=structure_molfile,
name=structure_name,
description=structure_description,
) )
except ValueError: except ValueError:
return error( return error(
@ -2121,12 +2131,14 @@ def package_pathways(request, package_uuid):
else: else:
prediction_setting = current_user.prediction_settings() prediction_setting = current_user.prediction_settings()
is_predict_mode = pw_mode in {"predict", "incremental"}
pw = Pathway.create( pw = Pathway.create(
current_package, current_package,
stand_smiles, stand_smiles if is_predict_mode else smiles,
name=name, name=name,
description=description, description=description,
predicted=pw_mode in {"predict", "incremental"}, predicted=is_predict_mode,
) )
# set mode # set mode
@ -2368,10 +2380,16 @@ def package_pathway_nodes(request, package_uuid, pathway_uuid):
node_description = request.POST.get("node-description") node_description = request.POST.get("node-description")
node_smiles = request.POST.get("node-smiles").strip() node_smiles = request.POST.get("node-smiles").strip()
node_molfile = request.POST.get("node-molfile").strip()
try: try:
current_pathway.add_node(node_smiles, name=node_name, description=node_description) current_pathway.add_node(
except InvalidSMILESException as e: node_smiles, molfile=node_molfile, name=node_name, description=node_description
return error(request, "Node creation failed!", f"Given SMILES ({node_smiles}) is invalid") )
except InvalidSMILESException:
return error(
request, "Node creation failed!", f"Given SMILES ({node_smiles}) is invalid"
)
return redirect(current_pathway.url) return redirect(current_pathway.url)
@ -2479,7 +2497,26 @@ def package_pathway_node(request, package_uuid, pathway_uuid, node_uuid):
return JsonResponse({"success": current_node.url}) return JsonResponse({"success": current_node.url})
return HttpResponseBadRequest() new_node_name = request.POST.get("node-name")
new_node_description = request.POST.get("node-description")
if any([new_node_name, new_node_description]):
if new_node_name is not None and new_node_name.strip() != "":
new_node_name = nh3.clean(new_node_name.strip(), tags=s.ALLOWED_HTML_TAGS).strip()
current_node.name = new_node_name
if new_node_description is not None and new_node_description.strip() != "":
new_node_description = nh3.clean(
new_node_description.strip(), tags=s.ALLOWED_HTML_TAGS
).strip()
current_node.description = new_node_description
current_node.save()
return redirect(current_node.url)
return error(request, "Node update failed!", "No changes were made to the node")
else: else:
return HttpResponseNotAllowed(["GET", "POST"]) return HttpResponseNotAllowed(["GET", "POST"])

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -81,6 +81,7 @@ def migration(request):
"bt0416-4269", "bt0416-4269",
"bt0432-4254", "bt0432-4254",
"bt0337-4117", "bt0337-4117",
"bt0322-3393",
] ]
if request.method == "GET": if request.method == "GET":

View File

@ -39,3 +39,7 @@ select.select[multiple] {
display: block; display: block;
white-space: normal; white-space: normal;
} }
p a {
@apply underline;
}

View File

@ -5,6 +5,126 @@
*/ */
document.addEventListener('alpine:init', () => { document.addEventListener('alpine:init', () => {
const basePagination = (
items,
currentPage,
totalPages,
totalItems,
perPage,
isReviewed,
instanceId
) => ({
items,
currentPage,
totalPages,
totalItems,
perPage,
isReviewed,
instanceId,
get paginatedItems() {
return this.items;
},
get showingStart() {
if (this.totalItems === 0) return 0;
return (this.currentPage - 1) * this.perPage + 1;
},
get showingEnd() {
if (this.totalItems === 0) return 0;
return Math.min((this.currentPage - 1) * this.perPage + this.items.length, this.totalItems);
},
nextPage() {
if (this.currentPage < this.totalPages) {
this.fetchPage(this.currentPage + 1);
}
},
prevPage() {
if (this.currentPage > 1) {
this.fetchPage(this.currentPage - 1);
}
},
goToPage(page) {
if (page >= 1 && page <= this.totalPages) {
this.fetchPage(page);
}
},
get pageNumbers() {
const pages = [];
const total = this.totalPages;
const current = this.currentPage;
if (total === 0) {
return pages;
}
if (total <= 7) {
for (let i = 1; i <= total; i++) {
pages.push({ page: i, isEllipsis: false, key: `${this.instanceId}-page-${i}` });
}
} else {
pages.push({ page: 1, isEllipsis: false, key: `${this.instanceId}-page-1` });
let rangeStart;
let rangeEnd;
if (current <= 4) {
rangeStart = 2;
rangeEnd = 5;
} else if (current >= total - 3) {
rangeStart = total - 4;
rangeEnd = total - 1;
} else {
rangeStart = current - 1;
rangeEnd = current + 1;
}
if (rangeStart > 2) {
pages.push({ page: '...', isEllipsis: true, key: `${this.instanceId}-ellipsis-start` });
}
for (let i = rangeStart; i <= rangeEnd; i++) {
pages.push({ page: i, isEllipsis: false, key: `${this.instanceId}-page-${i}` });
}
if (rangeEnd < total - 1) {
pages.push({ page: '...', isEllipsis: true, key: `${this.instanceId}-ellipsis-end` });
}
pages.push({ page: total, isEllipsis: false, key: `${this.instanceId}-page-${total}` });
}
return pages;
}
});
Alpine.data('paginatedList',
(items, options = {}) => ({
...basePagination(items,1, 0, 0, options.perPage || 50, options.isReviewed || false, options.instanceId || Math.random().toString(36).substring(2, 9),),
init() {
this.fetchPage(1);
},
async fetchPage(page) {
this.totalItems = this.items.length;
this.totalPages = Math.ceil(this.items.length / this.perPage);
this.currentPage = page
const start = page * this.perPage - this.perPage;
const end = page * this.perPage;
return this.items.slice(start, end);
}
})
);
Alpine.data('remotePaginatedList', (options = {}) => ({ Alpine.data('remotePaginatedList', (options = {}) => ({
items: [], items: [],
currentPage: 1, currentPage: 1,

View File

@ -1,18 +1,20 @@
console.log("loaded pw.js") console.log("loaded pw.js")
function findPaths(source_idx, links) {
function findPaths(source_idx, target_idx, links) {
const resultLinks = new Set(); const resultLinks = new Set();
const visited = new Set(); const visited = new Set();
// Helper function for depth-first search // Helper function for depth-first search
function dfs(current) { function dfs(current) {
if (current === target_idx) {
if (visited.has(current)) {
return;
}
if (links.filter(link => link.target.id === current).length === 0) {
return; return;
} }
visited.add(current); visited.add(current);
links.forEach(link => { links.forEach(link => {
if ( if (
link.target.id === current && link.target.id === current &&
@ -23,18 +25,13 @@ function findPaths(source_idx, target_idx, links) {
dfs(link.source.id); dfs(link.source.id);
} }
}); });
visited.delete(current); visited.delete(current);
} }
// Start DFS // Start DFS
dfs(source_idx); dfs(source_idx);
return Array.from(resultLinks);
return Array.from(resultLinks); // Convert Set to Array for result
} }
function predictFromNode(url) { function predictFromNode(url) {
fetch("", { fetch("", {
method: "POST", method: "POST",
@ -70,6 +67,9 @@ function draw(pathway, elem) {
const horizontalSpacing = 75; // horizontal space between nodes const horizontalSpacing = 75; // horizontal space between nodes
const depthMap = new Map(); const depthMap = new Map();
// Avoid leaving unconnected Nodes leaving the viewport
nodes.forEach(node => {if (node.depth < 0) node.depth = 0;});
// Sort nodes by depth first to minimize crossings // Sort nodes by depth first to minimize crossings
const sortedNodes = [...nodes].sort((a, b) => a.depth - b.depth); const sortedNodes = [...nodes].sort((a, b) => a.depth - b.depth);
@ -157,6 +157,11 @@ function draw(pathway, elem) {
}); });
node.attr("transform", d => `translate(${d.x},${d.y})`); node.attr("transform", d => `translate(${d.x},${d.y})`);
linkText
.attr("x", d => (d.source.x + d.target.x) / 2)
.attr("y", d => (d.source.y + d.target.y) / 2);
} }
function dragstarted(event, d) { function dragstarted(event, d) {
@ -259,7 +264,7 @@ function draw(pathway, elem) {
} }
// Wait before showing popup (ms) // Wait before showing popup (ms)
var popupWaitBeforeShow = 1000; var popupWaitBeforeShow = 500;
// Custom popover element // Custom popover element
let popoverTimeout = null; let popoverTimeout = null;
@ -491,7 +496,7 @@ function draw(pathway, elem) {
if (n.stereo_removed) { if (n.stereo_removed) {
popupContent += "<span class='alert alert-warning alert-soft'>Removed stereochemistry for prediction</span>"; popupContent += "<span class='alert alert-warning alert-soft'>Removed stereochemistry for prediction</span>";
} }
popupContent += "<a href='" + n.url + "'>" + n.name + "</a><br>"; popupContent += "<a href='" + n.url + "'>" + n.plain_name + "</a><br>";
popupContent += "Depth " + n.depth + "<br>" popupContent += "Depth " + n.depth + "<br>"
if (appDomainViewEnabled) { if (appDomainViewEnabled) {
@ -531,6 +536,21 @@ function draw(pathway, elem) {
popupContent += '<b>Half-lives and related scenarios:</b><br>' popupContent += '<b>Half-lives and related scenarios:</b><br>'
for (var s of n.scenarios) { for (var s of n.scenarios) {
popupContent += "<a href='" + s.url + "'>" + s.name + "</a><br>"; popupContent += "<a href='" + s.url + "'>" + s.name + "</a><br>";
for (var prop in n.proposed) {
if (n.proposed[prop].scenarioId === s.url) {
if("proposed" in n.proposed[prop]){
popupContent += "This compound is a proposed intermediate" + "<br>";
}
if("Confidence" in n.proposed[prop]){
popupContent += "Confidence: " + n.proposed[prop]["Confidence"] + "<br>";
}
if("Transformation product importance" in n.proposed[prop]){
popupContent += "Transformation product importance: " + n.proposed[prop]["Transformation product importance"] + "<br>";
}
}
}
popupContent += "<br>";
} }
} }
@ -543,7 +563,7 @@ function draw(pathway, elem) {
} }
function edge_popup(e) { function edge_popup(e) {
popupContent = "<a href='" + e.url + "'>" + e.name + "</a><br><br>"; popupContent = "<a href='" + e.url + "'>" + e.plain_name + "</a><br><br>";
if (e.reaction.rules) { if (e.reaction.rules) {
for (var rule of e.reaction.rules) { for (var rule of e.reaction.rules) {
@ -594,7 +614,7 @@ function draw(pathway, elem) {
// Add background rectangle FIRST to enable pan/zoom on empty space // Add background rectangle FIRST to enable pan/zoom on empty space
// This must be inserted before zoomable group so it's behind everything // This must be inserted before zoomable group so it's behind everything
svg.insert("rect", "#zoomable") const rect = svg.insert("rect", "#zoomable")
.attr("x", 0) .attr("x", 0)
.attr("y", 0) .attr("y", 0)
.attr("width", width) .attr("width", width)
@ -603,6 +623,30 @@ function draw(pathway, elem) {
.attr("pointer-events", "all") .attr("pointer-events", "all")
.style("cursor", "grab"); .style("cursor", "grab");
// Click on empty SVG space (not on a node or link)
rect.on("click", function(event) {
// Only treat it as a background click if the direct target is the rect itself
// (clicks on nodes/links bubble up but originate from different elements)
const target = event.target;
const isNode = target.closest && (
target.closest("circle") !== null ||
target.closest("image") !== null ||
target.closest(".node") !== null
);
const isLink = target.closest && (
target.closest("path.link") !== null ||
target.closest("path.link_no_arrow") !== null
);
if (!isNode && !isLink) {
// console.log("Clicked outside a node or link");
// Clear all highlights
d3.selectAll("circle").classed("highlighted", false);
d3.selectAll("path").classed("highlighted", false);
d3.selectAll("path").classed("inedge", false);
d3.selectAll("path").classed("outedge", false);
}
});
// Zoom Funktion aktivieren // Zoom Funktion aktivieren
const zoom = d3.zoom() const zoom = d3.zoom()
.scaleExtent([0.5, 5]) .scaleExtent([0.5, 5])
@ -649,49 +693,83 @@ function draw(pathway, elem) {
.force("center", d3.forceCenter(width / 2, height / 4)) .force("center", d3.forceCenter(width / 2, height / 4))
.on("tick", ticked); .on("tick", ticked);
// Kanten zeichnen // Draw Edges
const link = zoomable.append("g") const linkGroup = zoomable.append("g")
.selectAll("path") .selectAll("g")
.data(links) .data(links)
.enter().append("path") .enter()
// Check if target is pseudo and draw marker only if not pseudo .append("g")
.attr("class", d => d.target.pseudo ? "link_no_arrow" : "link") .attr("class", "link-group");
.attr("marker-end", d => {
if (d.target.pseudo) return ''; const link = linkGroup
if (d.source.id === d.target.id) return 'url(#curve-arrow)'; // Use curve arrow for self-loops .append("path")
return d.multi_step ? 'url(#doublearrow)' : 'url(#arrow)'; .attr("class", d => d.target.pseudo ? "link_no_arrow" : "link")
}) .attr("marker-end", d => d.target.pseudo ? "" : "url(#arrow)")
.attr("fill", "none") .attr("fill", "none")
.on("click", function(event, d) {
// Check if target is pseudo and draw marker only if not pseudo
.attr("class", d => d.target.pseudo ? "link_no_arrow" : "link")
.attr("marker-end", d => {
if (d.target.pseudo) return '';
if (d.source.id === d.target.id) return 'url(#curve-arrow)'; // Use curve arrow for self-loops
return d.multi_step ? 'url(#doublearrow)' : 'url(#arrow)';
}
)
.attr("fill", "none")
.on("click", function(event, d) {
const wasHighlighted = d3.select(this).classed("highlighted"); const wasHighlighted = d3.select(this).classed("highlighted");
d3.selectAll("path").classed("highlighted", false);
d3.selectAll("path").classed("highlighted", false);
if (!wasHighlighted) { if (!wasHighlighted) {
const toHighlight = []; const toHighlight = [];
toHighlight.push(d.el); toHighlight.push(d.el);
if (d.source.pseudo || d.target.pseudo) { if (d.source.pseudo || d.target.pseudo) {
if (d.target.pseudo) { if (d.target.pseudo) {
d3.selectAll("path").each(e => { d3.selectAll("path").each(e => {
if (e !== undefined && e.source.id === d.target.id) { if (e !== undefined && e.source.id === d.target.id) {
toHighlight.push(e.el); toHighlight.push(e.el);
}
});
} else {
d3.selectAll("path").each(e => {
if (e !== undefined && (e.target.id === d.source.id || e.source.id === d.source.id)) {
toHighlight.push(e.el);
}
});
} }
});
} else {
d3.selectAll("path").each(e => {
if (e !== undefined && (e.target.id === d.source.id || e.source.id === d.source.id)) {
toHighlight.push(e.el);
}
});
} }
}
for (const e of toHighlight) { for (const e of toHighlight) {
d3.select(e).classed("highlighted", true); d3.select(e).classed("highlighted", true);
} }
} }
}) });
function reaction_name(d) {
const to_pseudo = d?.to_pseudo || false;
if (to_pseudo) {
return "";
}
var suffix = "";
if ("reaction_probability" in d && d["reaction_probability"] !== null) {
suffix = " (p= " + d["reaction_probability"].toFixed(2) + ")"
}
return d.plain_name + suffix;
}
const linkText = linkGroup
.append("text")
.attr("class", "link-label")
.attr("text-anchor", "middle")
.attr("dy", -6)
.style("font-size", "4px")
.style("pointer-events", "none")
.style("display", "none")
.text(d => reaction_name(d))
.attr("x", d => (d.source.x + d.target.x) / 2)
.attr("y", d => (d.source.y + d.target.y) / 2);
// add element to links array // add element to links array
link.each(function (d) { link.each(function (d) {
@ -700,7 +778,7 @@ function draw(pathway, elem) {
pop_add(link, "Reaction", edge_popup); pop_add(link, "Reaction", edge_popup);
// Knoten zeichnen // Draw Nodes
const node = zoomable.append("g") const node = zoomable.append("g")
.selectAll("g") .selectAll("g")
.data(nodes) .data(nodes)
@ -708,44 +786,41 @@ function draw(pathway, elem) {
.call(d3.drag() .call(d3.drag()
.on("start", dragstarted) .on("start", dragstarted)
.on("drag", dragged) .on("drag", dragged)
.on("end", dragended)) .on("end", dragended)
)
.on("click", function (event, d) { .on("click", function (event, d) {
const wasHighlighted = d3.select(this).select("circle").classed("highlighted"); const wasHighlighted = d3.select(this).select("circle").classed("highlighted");
d3.selectAll('circle.highlighted').classed('highlighted', false);
d3.selectAll("path").classed("inedge", false);
d3.selectAll("path").classed("outedge", false);
d3.selectAll('circle.highlighted').classed('highlighted', false); if (!wasHighlighted) {
d3.selectAll("path").classed("inedge", false); d3.select(this).select("circle").classed("highlighted", !d3.select(this).select("circle").classed("highlighted"));
d3.selectAll("path").classed("outedge", false); const inEdges = findPaths(d.id, links);
const outEdges = []
if (!wasHighlighted) { // Colorize out edges green
d3.select(this).select("circle").classed("highlighted", !d3.select(this).select("circle").classed("highlighted")); for (const l of links) {
if (l.source.id === d.id) {
const inEdges = findPaths(d.id, 3, links); outEdges.push(l);
const outEdges = [] if (l.target.pseudo) {
for (const l2 of links) {
// Colorize out edges green if (l.target.id === l2.source.id) {
for (const l of links) { outEdges.push(l2);
if (l.source.id === d.id) {
outEdges.push(l);
if (l.target.pseudo) {
for (const l2 of links) {
if (l.target.id === l2.source.id) {
outEdges.push(l2);
}
}
}
} }
}
} }
}
for (const e of inEdges) {
d3.select(e.el).classed("inedge", true);
}
for (const e of outEdges) {
d3.select(e.el).classed("outedge", true);
}
} }
for (const e of inEdges) {
d3.select(e.el).classed("inedge", true);
}
}) for (const e of outEdges) {
d3.select(e.el).classed("outedge", true);
}
}
});
// Kreise für die Knoten hinzufügen // Kreise für die Knoten hinzufügen
node.append("circle") node.append("circle")
@ -819,14 +894,14 @@ function draw(pathway, elem) {
function serializeSVG(svgElement) { function serializeSVG(svgElement) {
svgElement.querySelectorAll("line.link").forEach(line => { svgElement.querySelectorAll("path.link").forEach(line => {
const style = getComputedStyle(line); const style = getComputedStyle(line);
line.setAttribute("stroke", style.stroke); line.setAttribute("stroke", style.stroke);
line.setAttribute("stroke-width", style.strokeWidth); line.setAttribute("stroke-width", style.strokeWidth);
line.setAttribute("fill", style.fill); line.setAttribute("fill", style.fill);
}); });
svgElement.querySelectorAll("line.link_no_arrow").forEach(line => { svgElement.querySelectorAll("path.link_no_arrow").forEach(line => {
const style = getComputedStyle(line); const style = getComputedStyle(line);
line.setAttribute("stroke", style.stroke); line.setAttribute("stroke", style.stroke);
line.setAttribute("stroke-width", style.strokeWidth); line.setAttribute("stroke-width", style.strokeWidth);

View File

@ -6,7 +6,6 @@
x-data="modalForm({ state: { selectedEdge: '', imageUrl: '' } })" x-data="modalForm({ state: { selectedEdge: '', imageUrl: '' } })"
@modal-opened.window=" @modal-opened.window="
const links = d3.selectAll('path.highlighted'); const links = d3.selectAll('path.highlighted');
if (!links.empty()) { if (!links.empty()) {
const el = links.node(); const el = links.node();
const selectElement = document.getElementById('delete_pathway_edge_edges'); const selectElement = document.getElementById('delete_pathway_edge_edges');
@ -18,9 +17,7 @@
break; break;
} }
} }
selectElement.dispatchEvent(new Event('change')); selectElement.dispatchEvent(new Event('change'));
} }
" "
@close="reset()" @close="reset()"

View File

@ -6,17 +6,14 @@
x-data="modalForm({ state: { selectedNode: '', imageUrl: '' } })" x-data="modalForm({ state: { selectedNode: '', imageUrl: '' } })"
@modal-opened.window=" @modal-opened.window="
const el = d3.select('circle.highlighted').node(); const el = d3.select('circle.highlighted').node();
if (el !== null) { if (el !== null) {
const selectElement = document.getElementById('delete_pathway_node_nodes'); const selectElement = document.getElementById('delete_pathway_node_nodes');
for (let option of selectElement.options) { for (let option of selectElement.options) {
if (option.value === el.__data__.url) { if (option.value === el.__data__.url) {
option.selected = true; option.selected = true;
break; break;
} }
} }
selectElement.dispatchEvent(new Event('change')); selectElement.dispatchEvent(new Event('change'));
} }
" "

View File

@ -56,8 +56,10 @@
<ul class="menu bg-base-200 rounded-box"> <ul class="menu bg-base-200 rounded-box">
{% for um in group.user_member.all %} {% for um in group.user_member.all %}
<li> <li>
<a href="{% if user.is_superuser %}{{ um.url }}{% else %}{{ "#" }}{% endif %}" class="hover:bg-base-300" <a
>{{ um.username }} href="{% if user.is_superuser %}{{ um.url }}{% else %}{{ "#" }}{% endif %}"
class="hover:bg-base-300"
>{{ um.username }}
{% if not um.is_active %}<i>(inactive)</i>{% endif %}</a {% if not um.is_active %}<i>(inactive)</i>{% endif %}</a
> >
</li> </li>

View File

@ -55,6 +55,14 @@
</div> </div>
</div> </div>
<!-- Description -->
{% if node.description and node.description != "no description" %}
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Description</div>
<div class="collapse-content">{{ node.description }}</div>
</div>
{% endif %}
{% epdb_slot_templates "epdb.objects.node.viz" as viz_templates %} {% epdb_slot_templates "epdb.objects.node.viz" as viz_templates %}
{% for tpl in viz_templates %} {% for tpl in viz_templates %}

View File

@ -21,12 +21,13 @@
.link { .link {
stroke: #999; stroke: #999;
stroke-opacity: 0.6; stroke-opacity: 0.6;
/* marker-end: url(#arrow); */ stroke-width: 1.5px;
} }
.link_no_arrow { .link_no_arrow {
stroke: #999; stroke: #999;
stroke-opacity: 0.6; stroke-opacity: 0.6;
stroke-width: 1.5px;
} }
.node image { .node image {
@ -77,12 +78,10 @@
stroke: red; stroke: red;
stroke-width: 3px; stroke-width: 3px;
} }
.outedge { .outedge {
stroke: green; stroke: green;
stroke-width: 3px; stroke-width: 3px;
} }
</style> </style>
<script src="{% static 'js/pw.js' %}"></script> <script src="{% static 'js/pw.js' %}"></script>
@ -122,7 +121,7 @@
<div class="collapse-title text-xl font-medium"> <div class="collapse-title text-xl font-medium">
Graphical Representation Graphical Representation
</div> </div>
<div class="collapse-content "> <div class="collapse-content">
<div class="bg-base-100 mb-2 rounded-lg p-2"> <div class="bg-base-100 mb-2 rounded-lg p-2">
<div class="navbar bg-base-100 rounded-lg"> <div class="navbar bg-base-100 rounded-lg">
<div class="flex-1"> <div class="flex-1">
@ -179,6 +178,52 @@
tabindex="0" tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-50 w-60 p-2" class="dropdown-content menu bg-base-100 rounded-box z-50 w-60 p-2"
> >
<li>
<a id="compound-names-toggle-button" class="cursor-pointer">
<svg
id="compound-names-icon"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-eye"
>
<path
d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"
/>
<circle cx="12" cy="12" r="3" />
</svg>
Compound Names
</a>
</li>
<li>
<a id="reaction-names-toggle-button" class="cursor-pointer">
<svg
id="reaction-names-icon"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-eye"
>
<path
d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"
/>
<circle cx="12" cy="12" r="3" />
</svg>
Reaction Names
</a>
</li>
{% if pathway.setting.model.app_domain %} {% if pathway.setting.model.app_domain %}
<li> <li>
<a id="app-domain-toggle-button" class="cursor-pointer"> <a id="app-domain-toggle-button" class="cursor-pointer">
@ -204,6 +249,7 @@
</a> </a>
</li> </li>
{% endif %} {% endif %}
{% if 1 == 0 %}
<li> <li>
<a id="timeseries-toggle-button" class="cursor-pointer"> <a id="timeseries-toggle-button" class="cursor-pointer">
<svg <svg
@ -254,6 +300,7 @@
Show Predicted Properties Show Predicted Properties
</a> </a>
</li> </li>
{% endif %}
</ul> </ul>
</div> </div>
</div> </div>
@ -497,6 +544,10 @@
{{ pathway.d3_json|json_script:"pathway" }} {{ pathway.d3_json|json_script:"pathway" }}
<script> <script>
// Global switch for compound names view
var compoundNamesViewEnabled = false;
// Gloabl switch for reaction names view
var reactionNamesViewEnabled = false;
// Global switch for app domain view // Global switch for app domain view
var appDomainViewEnabled = false; var appDomainViewEnabled = false;
// Global switch for timeseries view // Global switch for timeseries view
@ -532,6 +583,58 @@
descContent.innerHTML = newDesc; descContent.innerHTML = newDesc;
} }
const compoundNamesBtn = document.getElementById(
"compound-names-toggle-button",
);
if (compoundNamesBtn) {
compoundNamesBtn.addEventListener("click", function () {
compoundNamesViewEnabled = !compoundNamesViewEnabled;
const icon = document.getElementById("compound-names-icon");
if (compoundNamesViewEnabled) {
// Change to eye-off icon
icon.innerHTML =
'<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><line x1="2" x2="22" y1="2" y2="22"/>';
nodes.forEach((x) => {
if (x.name) {
d3.select(x.el)
.append("text")
.text((d) => d.plain_name)
.attr("text-anchor", "middle")
.attr("y", -20)
.style("font-size", "4px");
}
});
} else {
// Change back to eye icon
icon.innerHTML =
'<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>';
nodes.forEach((x) => {
d3.select(x.el).select("text").remove();
});
}
});
}
const reactionNamesBtn = document.getElementById(
"reaction-names-toggle-button",
);
if (reactionNamesBtn) {
reactionNamesBtn.addEventListener("click", function () {
reactionNamesViewEnabled = !reactionNamesViewEnabled;
const icon = document.getElementById("reaction-names-icon");
if (reactionNamesViewEnabled) {
// Change to eye-off icon
icon.innerHTML =
'<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><line x1="2" x2="22" y1="2" y2="22"/>';
d3.selectAll(".link-label").style("display", null);
} else {
// Change back to eye icon
icon.innerHTML =
'<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>';
d3.selectAll(".link-label").style("display", "none");
}
});
}
// App domain toggle // App domain toggle
const appDomainBtn = document.getElementById("app-domain-toggle-button"); const appDomainBtn = document.getElementById("app-domain-toggle-button");
if (appDomainBtn) { if (appDomainBtn) {

View File

@ -24,6 +24,76 @@
<td>Setting Name</td> <td>Setting Name</td>
<td>{{ setting_to_render.name }}</td> <td>{{ setting_to_render.name }}</td>
</tr> </tr>
<tr>
<td>Setting URL</td>
<td>
<a href="{{ setting_to_render.url }}" class="link link-primary">{{ setting_to_render.url }}</a>
<div
x-data="{
value: '{{ setting_to_render.url }}',
copied: false,
async copy() {
await navigator.clipboard.writeText(this.value)
this.copied = true
setTimeout(() => {
this.copied = false
}, 1500)
}
}"
class="join"
>
<button
type="button"
class="btn btn-ghost"
:data-tip="copied ? 'Copied!' : 'Copy'"
@click="copy"
aria-label="Copy to clipboard"
>
<svg
x-show="!copied"
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2"
/>
<rect
width="12"
height="12"
x="8"
y="8"
rx="2"
ry="2"
/>
</svg>
<svg
x-show="copied"
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
</button>
</div>
</td>
</tr>
{% if setting_to_render.description %} {% if setting_to_render.description %}
<tr> <tr>
<td>Setting Description</td> <td>Setting Description</td>

View File

@ -212,6 +212,8 @@
e.preventDefault(); e.preventDefault();
const button = this; const button = this;
button.disabled = true; button.disabled = true;
// Set text depending on mode
if (document.getElementById("predict-submit-button").innerText === "Build") { if (document.getElementById("predict-submit-button").innerText === "Build") {
button.textContent = "Building..."; button.textContent = "Building...";
} else { } else {

View File

@ -1,5 +1,7 @@
from django.conf import settings as s from django.conf import settings as s
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from epdb.exceptions import InvalidSMILESException, InvalidMolfileException
from epdb.logic import PackageManager from epdb.logic import PackageManager
from epdb.models import Compound, User, CompoundStructure from epdb.models import Compound, User, CompoundStructure
@ -17,6 +19,10 @@ class CompoundTest(TestCase):
cls.user = User.objects.get(username="anonymous") cls.user = User.objects.get(username="anonymous")
cls.package = PackageManager.create_package(cls.user, "Anon Test Package", "No Desc") cls.package = PackageManager.create_package(cls.user, "Anon Test Package", "No Desc")
# A valid V2000 molfile for 4-Nitrobenzoic acid (O=C(O)C1=CC=C([N+](=O)[O-])C=C1)
cls.VALID_MOLFILE = """\n Mrv2211 01012500002D\n\n 12 12 0 0 0 0 999 V2000\n 1.4289 -0.8250 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.7145 -0.4125 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.0000 -0.8250 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.0000 -1.6500 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.7145 -2.0625 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 1.4289 -1.6500 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.7145 0.4125 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.7145 1.2375 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0\n 1.4289 0.8250 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0\n 0.7145 -2.8875 0.0000 N 0 3 0 0 0 0 0 0 0 0 0 0\n 0.0000 -3.3000 0.0000 O 0 5 0 0 0 0 0 0 0 0 0 0\n 1.4289 -3.3000 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0\n 1 2 2 0 0 0 0\n 2 3 1 0 0 0 0\n 3 4 2 0 0 0 0\n 4 5 1 0 0 0 0\n 5 6 2 0 0 0 0\n 6 1 1 0 0 0 0\n 2 7 1 0 0 0 0\n 7 8 2 0 0 0 0\n 7 9 1 0 0 0 0\n 5 10 1 0 0 0 0\n 10 11 1 0 0 0 0\n 10 12 2 0 0 0 0\nM CHG 2 10 1 11 -1\nM END\n"""
cls.INVALID_MOLFILE = "this is not a valid molfile"
def test_smoke(self): def test_smoke(self):
c = Compound.create( c = Compound.create(
self.package, self.package,
@ -33,13 +39,13 @@ class CompoundTest(TestCase):
self.assertEqual(c.description, "No Desc") self.assertEqual(c.description, "No Desc")
def test_missing_smiles(self): def test_missing_smiles(self):
with self.assertRaises(ValueError): with self.assertRaises(InvalidSMILESException):
_ = Compound.create(self.package, smiles=None, name="Afoxolaner", description="No Desc") _ = Compound.create(self.package, smiles=None, name="Afoxolaner", description="No Desc")
with self.assertRaises(ValueError): with self.assertRaises(InvalidSMILESException):
_ = Compound.create(self.package, smiles="", name="Afoxolaner", description="No Desc") _ = Compound.create(self.package, smiles="", name="Afoxolaner", description="No Desc")
with self.assertRaises(ValueError): with self.assertRaises(InvalidSMILESException):
_ = Compound.create(self.package, smiles=" ", name="Afoxolaner", description="No Desc") _ = Compound.create(self.package, smiles=" ", name="Afoxolaner", description="No Desc")
def test_smiles_are_trimmed(self): def test_smiles_are_trimmed(self):
@ -96,7 +102,7 @@ class CompoundTest(TestCase):
self.assertEqual(len(self.package.compounds), 1) self.assertEqual(len(self.package.compounds), 1)
def test_wrong_smiles(self): def test_wrong_smiles(self):
with self.assertRaises(ValueError): with self.assertRaises(InvalidSMILESException):
_ = Compound.create( _ = Compound.create(
self.package, self.package,
smiles="C1C(=NOC1(C2=CC(=CC(=C2)Cl)C(F)(F)F)C(F)(F)F)C3=CC=C(C=CC=CC=C43)C(=O)NCC(=O)NCC(F)(F)F", smiles="C1C(=NOC1(C2=CC(=CC(=C2)Cl)C(F)(F)F)C(F)(F)F)C3=CC=C(C=CC=CC=C43)C(=O)NCC(=O)NCC(F)(F)F",
@ -188,3 +194,153 @@ class CompoundTest(TestCase):
c1.set_default_structure(c2) c1.set_default_structure(c2)
self.assertNotEqual(default_structure, c2) self.assertNotEqual(default_structure, c2)
def test_create_structure_from_molfile(self):
c = Compound.create(
self.package,
smiles="PLACEHOLDER",
molfile=self.VALID_MOLFILE,
name="Molfile Compound",
description="Created from molfile",
)
cs = CompoundStructure.create(
compound=c,
smiles="PLACEHOLDER",
molfile=self.VALID_MOLFILE,
name="Molfile Structure",
description="Structure from molfile",
)
# The SMILES must have been derived from the molfile, not the placeholder
self.assertNotEqual(cs.smiles, "PLACEHOLDER")
self.assertIsNotNone(cs.smiles)
self.assertTrue(len(cs.smiles) > 0)
def test_create_structure_from_molfile_stores_molfile(self):
c = Compound.create(
self.package,
smiles="c1c(C(=O)O)ccc([N+]([O-])=O)c1",
name="Molfile Store Test",
description="No Desc",
)
cs = c.default_structure
# O=C(O)C1=CC=C([N+](=O)[O-])C=C1 will be overwritten with
# c1c(C(=O)O)ccc([N+]([O-])=O)c1 and molfile will be set
# on the existing structure
_ = CompoundStructure.create(
compound=c,
smiles="O=C(O)C1=CC=C([N+](=O)[O-])C=C1",
molfile=self.VALID_MOLFILE,
name="Structure with Molfile",
description="No Desc",
)
# Fetch fresh from DB to confirm persistence
cs_db = CompoundStructure.objects.get(pk=cs.pk)
self.assertIsNotNone(cs_db.molfile)
self.assertNotEqual(cs_db.molfile.strip(), "")
def test_create_structure_from_invalid_molfile_raises(self):
c = Compound.create(
self.package,
smiles="O=C(O)C1=CC=C([N+](=O)[O-])C=C1",
name="Invalid Molfile Test",
description="No Desc",
)
with self.assertRaises(InvalidMolfileException):
CompoundStructure.create(
compound=c,
smiles="O=C(O)C1=CC=C([N+](=O)[O-])C=C1",
molfile=self.INVALID_MOLFILE,
name="Bad Structure",
description="No Desc",
)
def test_molfile_takes_precedence_over_smiles(self):
"""When both molfile and smiles are supplied, molfile must win."""
c = Compound.create(
self.package,
smiles="O=C(O)C1=CC=C([N+](=O)[O-])C=C1",
name="Precedence Test",
description="No Desc",
)
cs = CompoundStructure.create(
compound=c,
smiles="C", # intentionally wrong / different SMILES
molfile=self.VALID_MOLFILE,
name="Molfile Wins",
description="No Desc",
)
# SMILES should be derived from molfile, not the supplied "C"
self.assertNotEqual(cs.smiles, "C")
def test_empty_molfile_falls_back_to_smiles(self):
"""An empty or whitespace-only molfile should be ignored and SMILES used instead."""
c = Compound.create(
self.package,
smiles="O=C(O)C1=CC=C([N+](=O)[O-])C=C1",
name="Empty Molfile Test",
description="No Desc",
)
cs = CompoundStructure.create(
compound=c,
smiles="O=C(O)C1=CC=C([N+](=O)[O-])C=C1",
molfile=" ", # whitespace only should be ignored
name="Fallback to SMILES",
description="No Desc",
)
self.assertEqual(cs.smiles, "O=C(O)C1=CC=C([N+](=O)[O-])C=C1")
def test_none_molfile_falls_back_to_smiles(self):
"""None as molfile should be ignored and SMILES used instead."""
c = Compound.create(
self.package,
smiles="O=C(O)C1=CC=C([N+](=O)[O-])C=C1",
name="None Molfile Test",
description="No Desc",
)
cs = CompoundStructure.create(
compound=c,
smiles="O=C(O)C1=CC=C([N+](=O)[O-])C=C1",
molfile=None,
name="Fallback None Molfile",
description="No Desc",
)
self.assertEqual(cs.smiles, "O=C(O)C1=CC=C([N+](=O)[O-])C=C1")
def test_molfile_deduplication(self):
"""Creating a structure twice from the same molfile should return the existing object."""
c = Compound.create(
self.package,
smiles="O=C(O)C1=CC=C([N+](=O)[O-])C=C1",
name="Molfile Dedup Test",
description="No Desc",
)
cs1 = CompoundStructure.create(
compound=c,
smiles="PLACEHOLDER",
molfile=self.VALID_MOLFILE,
name="Molfile Structure",
description="No Desc",
)
cs2 = CompoundStructure.create(
compound=c,
smiles="PLACEHOLDER",
molfile=self.VALID_MOLFILE,
name="Molfile Structure",
description="No Desc",
)
self.assertEqual(cs1.pk, cs2.pk)

View File

@ -1,6 +1,7 @@
from django.conf import settings as s from django.conf import settings as s
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from epdb.exceptions import InvalidSMILESException
from epdb.logic import PackageManager from epdb.logic import PackageManager
from epdb.models import Compound, User, Reaction, Rule from epdb.models import Compound, User, Reaction, Rule
@ -163,7 +164,7 @@ class ReactionTest(TestCase):
self.assertEqual(len(self.package.reactions), 1) self.assertEqual(len(self.package.reactions), 1)
def test_wrong_smiles(self): def test_wrong_smiles(self):
with self.assertRaises(ValueError): with self.assertRaises(InvalidSMILESException):
_ = Reaction.create( _ = Reaction.create(
package=self.package, package=self.package,
name="Eawag BBD reaction r0001", name="Eawag BBD reaction r0001",

View File

@ -11,6 +11,9 @@ from epdb.models import Compound, Scenario, ExternalDatabase
class CompoundViewTest(TestCase): class CompoundViewTest(TestCase):
fixtures = ["test_fixtures_incl_model.jsonl.gz"] fixtures = ["test_fixtures_incl_model.jsonl.gz"]
# A valid V2000 molfile for 4-Nitrobenzoic acid (O=C(O)C1=CC=C([N+](=O)[O-])C=C1)
VALID_MOLFILE = """\n Mrv2211 01012500002D\n\n 12 12 0 0 0 0 999 V2000\n 1.4289 -0.8250 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.7145 -0.4125 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.0000 -0.8250 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.0000 -1.6500 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.7145 -2.0625 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 1.4289 -1.6500 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.7145 0.4125 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.7145 1.2375 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0\n 1.4289 0.8250 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0\n 0.7145 -2.8875 0.0000 N 0 3 0 0 0 0 0 0 0 0 0 0\n 0.0000 -3.3000 0.0000 O 0 5 0 0 0 0 0 0 0 0 0 0\n 1.4289 -3.3000 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0\n 1 2 2 0 0 0 0\n 2 3 1 0 0 0 0\n 3 4 2 0 0 0 0\n 4 5 1 0 0 0 0\n 5 6 2 0 0 0 0\n 6 1 1 0 0 0 0\n 2 7 1 0 0 0 0\n 7 8 2 0 0 0 0\n 7 9 1 0 0 0 0\n 5 10 1 0 0 0 0\n 10 11 1 0 0 0 0\n 10 12 2 0 0 0 0\nM CHG 2 10 1 11 -1\nM END\n"""
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
super(CompoundViewTest, cls).setUpClass() super(CompoundViewTest, cls).setUpClass()
@ -396,3 +399,108 @@ class CompoundViewTest(TestCase):
c = Compound.objects.get(url=compound_url) c = Compound.objects.get(url=compound_url)
self.assertEqual(len(c.aliases), 0) self.assertEqual(len(c.aliases), 0)
def test_create_compound_via_molfile(self):
"""POSTing a valid molfile without a SMILES should create a compound successfully."""
response = self.client.post(
reverse("compounds"),
{
"compound-name": "4-Nitrobenzoic acid",
"compound-description": "Created from molfile",
"compound-molfile": self.VALID_MOLFILE,
},
)
self.assertEqual(response.status_code, 302)
compound_url = response.url
c = Compound.objects.get(url=compound_url)
self.assertEqual(c.name, "4-Nitrobenzoic acid")
self.assertEqual(c.description, "Created from molfile")
# SMILES should have been extracted from molfile, so it must be non-empty
self.assertIsNotNone(c.default_structure.smiles)
self.assertNotEqual(c.default_structure.smiles.strip(), "")
def test_create_compound_molfile_takes_precedence_over_smiles(self):
"""When both molfile and SMILES are submitted, the molfile should win."""
response = self.client.post(
reverse("compounds"),
{
"compound-name": "4-Nitrobenzoic acid",
"compound-description": "Molfile precedence test",
"compound-smiles": "C", # intentionally wrong/different
"compound-molfile": self.VALID_MOLFILE,
},
)
self.assertEqual(response.status_code, 302)
compound_url = response.url
c = Compound.objects.get(url=compound_url)
# The resulting SMILES must NOT be the dummy "C" supplied via compound-smiles
self.assertNotEqual(c.default_structure.smiles, "C")
def test_create_compound_via_invalid_molfile_returns_error(self):
"""POSTing an invalid molfile should not create a compound and should return an error response."""
initial_count = self.user1_default_package.compounds.count()
response = self.client.post(
reverse("compounds"),
{
"compound-name": "Bad Molfile Compound",
"compound-description": "Should fail",
"compound-molfile": "this is not a molfile at all",
},
)
# The view should signal an error (non-2xx or a redirect to an error page, not 302 to a new compound)
self.assertNotEqual(response.status_code, 302)
# No new compound should have been created
self.assertEqual(self.user1_default_package.compounds.count(), initial_count)
def test_create_compound_via_empty_molfile_falls_back_to_smiles(self):
"""Submitting an empty molfile with a valid SMILES should fall back to SMILES."""
response = self.client.post(
reverse("compounds"),
{
"compound-name": "Fallback SMILES Compound",
"compound-description": "Empty molfile fallback",
"compound-smiles": "C(CCl)Cl",
"compound-molfile": " ", # whitespace only
},
)
self.assertEqual(response.status_code, 302)
compound_url = response.url
c = Compound.objects.get(url=compound_url)
self.assertEqual(c.default_structure.smiles, "C(CCl)Cl")
def test_create_compound_via_molfile_deduplication(self):
"""Submitting the same molfile twice should return the existing compound."""
response1 = self.client.post(
reverse("compounds"),
{
"compound-name": "4-Nitrobenzoic acid",
"compound-description": "Molfile dedup test",
"compound-molfile": self.VALID_MOLFILE,
},
)
self.assertEqual(response1.status_code, 302)
compound_url_1 = response1.url
response2 = self.client.post(
reverse("compounds"),
{
"compound-name": "4-Nitrobenzoic acid",
"compound-description": "Molfile dedup test",
"compound-molfile": self.VALID_MOLFILE,
},
)
self.assertEqual(response2.status_code, 302)
self.assertEqual(response2.url, compound_url_1)
self.assertEqual(self.user1_default_package.compounds.count(), 1)

View File

@ -46,6 +46,14 @@ class ModelViewTest(TestCase):
) )
expected = [ expected = [
{
"products": [["CCN(CC)C(=O)C1=CC(C=O)=CC=C1"]],
"probability": 0.75,
"btrule": {
"url": "http://localhost:8000/package/1869d3f0-60bb-41fd-b6f8-afa75ffb09d3/simple-ambit-rule/2f2e0c39-e109-4836-959f-2bda2524f022",
"name": "bt0001-3568",
},
},
{ {
"products": [["O=C(O)C1=CC(CO)=CC=C1", "CCNCC"]], "products": [["O=C(O)C1=CC(CO)=CC=C1", "CCNCC"]],
"probability": 0.25, "probability": 0.25,
@ -62,14 +70,6 @@ class ModelViewTest(TestCase):
"name": "bt0243-4301", "name": "bt0243-4301",
}, },
}, },
{
"products": [["CCN(CC)C(=O)C1=CC(C=O)=CC=C1"]],
"probability": 0.75,
"btrule": {
"url": "http://localhost:8000/package/1869d3f0-60bb-41fd-b6f8-afa75ffb09d3/simple-ambit-rule/2f2e0c39-e109-4836-959f-2bda2524f022",
"name": "bt0001-3568",
},
},
] ]
actual = response.json()["pred"] actual = response.json()["pred"]

View File

@ -72,8 +72,10 @@ class PackageViewTest(TestCase):
def test_import_package(self): def test_import_package(self):
file = SimpleUploadedFile( file = SimpleUploadedFile(
"Fixture_Package.json", "EAWAG-BBD_32de3cf4-e3e6-4168-956e-32fa5ddb0ce1.json",
open(s.FIXTURE_DIRS[0] / "Fixture_Package.json", "rb").read(), open(
s.FIXTURE_DIRS[0] / "EAWAG-BBD_32de3cf4-e3e6-4168-956e-32fa5ddb0ce1.json", "rb"
).read(),
content_type="application/json", content_type="application/json",
) )
@ -86,10 +88,11 @@ class PackageViewTest(TestCase):
p = Package.objects.get(url=package_url) p = Package.objects.get(url=package_url)
self.assertEqual(p.pathways.count(), 22) self.assertEqual(p.pathways.count(), 219)
self.assertEqual(p.rules.count(), 45) self.assertEqual(p.rules.count(), 498)
self.assertEqual(p.compounds.count(), 223) self.assertEqual(p.compounds.count(), 1399)
self.assertEqual(p.reactions.count(), 212) self.assertEqual(p.reactions.count(), 1480)
self.assertEqual(p.scenarios.count(), 1914)
upp = UserPackagePermission.objects.get(package=p, user=self.user1) upp = UserPackagePermission.objects.get(package=p, user=self.user1)
self.assertEqual(upp.permission, Permission.ALL[0]) self.assertEqual(upp.permission, Permission.ALL[0])

File diff suppressed because it is too large Load Diff