11 Commits

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

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

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

Reviewed-on: enviPath/enviPy#349
Co-authored-by: Liam Brydon <lbry121@aucklanduni.ac.nz>
Co-committed-by: Liam Brydon <lbry121@aucklanduni.ac.nz>
2026-03-11 19:12:55 +13:00
b737fc93eb [Feature] Search for Permissions, Prep Compound / Structure to be extended, Prep Template overwrites (#347)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#347
2026-03-11 11:27:15 +13:00
d4295c9349 [Fix] bootstrap command now reflects new Scenario/AdditionalInformation structure (#346)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#346
2026-03-07 03:14:28 +13:00
26 changed files with 534 additions and 91 deletions

View File

@ -92,10 +92,19 @@ if os.environ.get("REGISTRATION_MANDATORY", False) == "True":
ROOT_URLCONF = "envipath.urls" ROOT_URLCONF = "envipath.urls"
TEMPLATE_DIRS = [
os.path.join(BASE_DIR, "templates"),
]
# If we have a non-public tenant, we might need to overwrite some templates
# search TENANT folder first...
if TENANT != "public":
TEMPLATE_DIRS.insert(0, os.path.join(BASE_DIR, TENANT, "templates"))
TEMPLATES = [ TEMPLATES = [
{ {
"BACKEND": "django.template.backends.django.DjangoTemplates", "BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": (os.path.join(BASE_DIR, "templates"),), "DIRS": TEMPLATE_DIRS,
"APP_DIRS": True, "APP_DIRS": True,
"OPTIONS": { "OPTIONS": {
"context_processors": [ "context_processors": [
@ -399,3 +408,9 @@ if MS_ENTRA_ENABLED:
# Site ID 10 -> beta.envipath.org # Site ID 10 -> beta.envipath.org
MATOMO_SITE_ID = os.environ.get("MATOMO_SITE_ID", "10") MATOMO_SITE_ID = os.environ.get("MATOMO_SITE_ID", "10")
# CAP
CAP_ENABLED = os.environ.get("CAP_ENABLED", "False") == "True"
CAP_API_BASE = os.environ.get("CAP_API_BASE", None)
CAP_SITE_KEY = os.environ.get("CAP_SITE_KEY", None)
CAP_SECRET_KEY = os.environ.get("CAP_SECRET_KEY", None)

View File

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

View File

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

View File

@ -9,7 +9,6 @@ import logging
import json import json
from epdb.models import Scenario from epdb.models import Scenario
from epdb.logic import PackageManager
from epdb.views import _anonymous_or_real from epdb.views import _anonymous_or_real
from ..pagination import EnhancedPageNumberPagination from ..pagination import EnhancedPageNumberPagination
from ..schemas import ( from ..schemas import (
@ -17,7 +16,7 @@ from ..schemas import (
ScenarioOutSchema, ScenarioOutSchema,
ScenarioCreateSchema, ScenarioCreateSchema,
) )
from ..dal import get_user_entities_for_read, get_package_entities_for_read from ..dal import get_user_entities_for_read, get_package_entities_for_read, get_package_for_write
from envipy_additional_information import registry from envipy_additional_information import registry
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -58,7 +57,7 @@ def create_scenario(request, package_uuid: UUID, payload: ScenarioCreateSchema =
user = _anonymous_or_real(request) user = _anonymous_or_real(request)
try: try:
current_package = PackageManager.get_package_by_id(user, package_uuid) current_package = get_package_for_write(user, package_uuid)
except ValueError as e: except ValueError as e:
error_msg = str(e) error_msg = str(e)
if "does not exist" in error_msg: if "does not exist" in error_msg:

View File

@ -16,6 +16,10 @@ class EPDBConfig(AppConfig):
model_name = getattr(settings, "EPDB_PACKAGE_MODEL", "epdb.Package") model_name = getattr(settings, "EPDB_PACKAGE_MODEL", "epdb.Package")
logger.info(f"Using Package model: {model_name}") logger.info(f"Using Package model: {model_name}")
from .autodiscovery import autodiscover
autodiscover()
if settings.PLUGINS_ENABLED: if settings.PLUGINS_ENABLED:
from bridge.contracts import Property from bridge.contracts import Property
from utilities.plugin import discover_plugins from utilities.plugin import discover_plugins

5
epdb/autodiscovery.py Normal file
View File

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

View File

@ -1,3 +1,4 @@
from collections import defaultdict
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import nh3 import nh3
@ -11,8 +12,16 @@ from ninja.security import SessionAuth
from utilities.chem import FormatConverter from utilities.chem import FormatConverter
from utilities.misc import PackageExporter from utilities.misc import PackageExporter
from .logic import GroupManager, PackageManager, SearchManager, SettingManager, UserManager from .logic import (
EPDBURLParser,
GroupManager,
PackageManager,
SearchManager,
SettingManager,
UserManager,
)
from .models import ( from .models import (
AdditionalInformation,
Compound, Compound,
CompoundStructure, CompoundStructure,
Edge, Edge,
@ -94,6 +103,8 @@ class SimpleObject(Schema):
return "reviewed" if obj.compound.package.reviewed else "unreviewed" return "reviewed" if obj.compound.package.reviewed else "unreviewed"
elif isinstance(obj, Node) or isinstance(obj, Edge): elif isinstance(obj, Node) or isinstance(obj, Edge):
return "reviewed" if obj.pathway.package.reviewed else "unreviewed" return "reviewed" if obj.pathway.package.reviewed else "unreviewed"
elif isinstance(obj, dict) and "review_status" in obj:
return "reviewed" if obj.get("review_status") else "unreviewed"
else: else:
raise ValueError("Object has no package") raise ValueError("Object has no package")
@ -1327,7 +1338,14 @@ class ScenarioSchema(Schema):
@staticmethod @staticmethod
def resolve_collection(obj: Scenario): def resolve_collection(obj: Scenario):
return obj.additional_information res = defaultdict(list)
for ai in obj.get_additional_information(direct_only=False):
data = ai.data
data["related"] = ai.content_object.simple_json() if ai.content_object else None
res[ai.type].append(data)
return res
@staticmethod @staticmethod
def resolve_review_status(obj: Rule): def resolve_review_status(obj: Rule):
@ -1392,7 +1410,11 @@ def create_package_scenario(request, package_uuid):
study_type = request.POST.get("type") study_type = request.POST.get("type")
ais = [] ais = []
types = request.POST.getlist("adInfoTypes[]") types = request.POST.get("adInfoTypes[]", [])
if types:
types = types.split(",")
for t in types: for t in types:
ais.append(build_additional_information_from_request(request, t)) ais.append(build_additional_information_from_request(request, t))
@ -1434,6 +1456,49 @@ def delete_scenario(request, package_uuid, scenario_uuid):
} }
@router.post(
"/package/{uuid:package_uuid}/additional-information", response={200: str | Any, 403: Error}
)
def create_package_additional_information(request, package_uuid):
from utilities.legacy import build_additional_information_from_request
try:
p = get_package_for_write(request.user, package_uuid)
scen = request.POST.get("scenario")
scenario = Scenario.objects.get(package=p, url=scen)
url_parser = EPDBURLParser(request.POST.get("attach_obj"))
attach_obj = url_parser.get_object()
if not hasattr(attach_obj, "additional_information"):
raise ValueError("Can't attach additional information to this object!")
if not attach_obj.url.startswith(p.url):
raise ValueError(
"Additional Information can only be set to objects stored in the same package!"
)
types = request.POST.get("adInfoTypes[]", "").split(",")
for t in types:
ai = build_additional_information_from_request(request, t)
AdditionalInformation.create(
p,
ai,
scenario=scenario,
content_object=attach_obj,
)
# TODO implement additional information endpoint ?
return redirect(f"{scenario.url}")
except ValueError:
return 403, {
"message": f"Getting Package with id {package_uuid} failed due to insufficient rights!"
}
########### ###########
# Pathway # # Pathway #
########### ###########
@ -1464,7 +1529,7 @@ class PathwayEdge(Schema):
class PathwayNode(Schema): class PathwayNode(Schema):
atomCount: int = Field(None, alias="atom_count") atomCount: int = Field(None, alias="atom_count")
depth: int = Field(None, alias="depth") depth: float = Field(None, alias="depth")
dt50s: List[Dict[str, str]] = Field([], alias="dt50s") dt50s: List[Dict[str, str]] = Field([], alias="dt50s")
engineeredIntermediate: bool = Field(None, alias="engineered_intermediate") engineeredIntermediate: bool = Field(None, alias="engineered_intermediate")
id: str = Field(None, alias="url") id: str = Field(None, alias="url")
@ -1805,7 +1870,7 @@ class EdgeSchema(Schema):
startNodes: List["EdgeNode"] = Field([], alias="start_nodes") startNodes: List["EdgeNode"] = Field([], alias="start_nodes")
@staticmethod @staticmethod
def resolve_review_status(obj: Node): def resolve_review_status(obj: Edge):
return "reviewed" if obj.pathway.package.reviewed else "unreviewed" return "reviewed" if obj.pathway.package.reviewed else "unreviewed"

View File

@ -1,4 +1,3 @@
import json
import logging import logging
import re import re
from typing import Any, Dict, List, Optional, Set, Union, Tuple from typing import Any, Dict, List, Optional, Set, Union, Tuple
@ -11,6 +10,7 @@ from django.db import transaction
from pydantic import ValidationError from pydantic import ValidationError
from epdb.models import ( from epdb.models import (
AdditionalInformation,
Compound, Compound,
CompoundStructure, CompoundStructure,
Edge, Edge,
@ -634,15 +634,30 @@ class PackageManager(object):
# Stores old_id to new_id # Stores old_id to new_id
mapping = {} mapping = {}
# Stores new_scen_id to old_parent_scen_id
parent_mapping = {}
# Mapping old scen_id to old_obj_id # Mapping old scen_id to old_obj_id
scen_mapping = defaultdict(list) scen_mapping = defaultdict(list)
# Enzymelink Mapping rule_id to enzymelink objects # Enzymelink Mapping rule_id to enzymelink objects
enzyme_mapping = defaultdict(list) enzyme_mapping = defaultdict(list)
# old_parent_id to child
postponed_scens = defaultdict(list)
# Store Scenarios # Store Scenarios
for scenario in data["scenarios"]: for scenario in data["scenarios"]:
skip_scen = False
# Check if parent exists and park this Scenario to convert it later into an
# AdditionalInformation object
for ex in scenario.get("additionalInformationCollection", {}).get(
"additionalInformation", []
):
if ex["name"] == "referringscenario":
postponed_scens[ex["data"]].append(scenario)
skip_scen = True
break
if skip_scen:
continue
scen = Scenario() scen = Scenario()
scen.package = pack scen.package = pack
scen.uuid = UUID(scenario["id"].split("/")[-1]) if keep_ids else uuid4() scen.uuid = UUID(scenario["id"].split("/")[-1]) if keep_ids else uuid4()
@ -655,19 +670,12 @@ class PackageManager(object):
mapping[scenario["id"]] = scen.uuid mapping[scenario["id"]] = scen.uuid
new_add_inf = defaultdict(list)
# TODO Store AI...
for ex in scenario.get("additionalInformationCollection", {}).get( for ex in scenario.get("additionalInformationCollection", {}).get(
"additionalInformation", [] "additionalInformation", []
): ):
name = ex["name"] name = ex["name"]
addinf_data = ex["data"] addinf_data = ex["data"]
# park the parent scen id for now and link it later
if name == "referringscenario":
parent_mapping[scen.uuid] = addinf_data
continue
# Broken eP Data # Broken eP Data
if name == "initialmasssediment" and addinf_data == "missing data": if name == "initialmasssediment" and addinf_data == "missing data":
continue continue
@ -675,17 +683,11 @@ class PackageManager(object):
continue continue
try: try:
res = AdditionalInformationConverter.convert(name, addinf_data) ai = AdditionalInformationConverter.convert(name, addinf_data)
res_cls_name = res.__class__.__name__ AdditionalInformation.create(pack, ai, scenario=scen)
ai_data = json.loads(res.model_dump_json())
ai_data["uuid"] = f"{uuid4()}"
new_add_inf[res_cls_name].append(ai_data)
except (ValidationError, ValueError): except (ValidationError, ValueError):
logger.error(f"Failed to convert {name} with {addinf_data}") logger.error(f"Failed to convert {name} with {addinf_data}")
scen.additional_information = new_add_inf
scen.save()
print("Scenarios imported...") print("Scenarios imported...")
# Store compounds and its structures # Store compounds and its structures
@ -925,14 +927,46 @@ class PackageManager(object):
print("Pathways imported...") print("Pathways imported...")
# Linking Phase for parent, children in postponed_scens.items():
for child, parent in parent_mapping.items(): for child in children:
child_obj = Scenario.objects.get(uuid=child) for ex in child.get("additionalInformationCollection", {}).get(
parent_obj = Scenario.objects.get(uuid=mapping[parent]) "additionalInformation", []
child_obj.parent = parent_obj ):
child_obj.save() child_id = child["id"]
name = ex["name"]
addinf_data = ex["data"]
if name == "referringscenario":
continue
# Broken eP Data
if name == "initialmasssediment" and addinf_data == "missing data":
continue
if name == "columnheight" and addinf_data == "(2)-(2.5);(6)-(8)":
continue
ai = AdditionalInformationConverter.convert(name, addinf_data)
if child_id not in scen_mapping:
logger.info(
f"{child_id} not found in scen_mapping. Seems like its not attached to any object"
)
print(
f"{child_id} not found in scen_mapping. Seems like its not attached to any object"
)
scen = Scenario.objects.get(uuid=mapping[parent])
mapping[child_id] = scen.uuid
for obj in scen_mapping[child_id]:
_ = AdditionalInformation.create(pack, ai, scen, content_object=obj)
for scen_id, objects in scen_mapping.items(): for scen_id, objects in scen_mapping.items():
new_id = mapping.get(scen_id)
if new_id is None:
logger.warning(f"Could not find mapping for {scen_id}")
print(f"Could not find mapping for {scen_id}")
continue
scen = Scenario.objects.get(uuid=mapping[scen_id]) scen = Scenario.objects.get(uuid=mapping[scen_id])
for o in objects: for o in objects:
o.scenarios.add(scen) o.scenarios.add(scen)
@ -965,6 +999,7 @@ class PackageManager(object):
matches = re.findall(r">(R[0-9]+)<", evidence["evidence"]) matches = re.findall(r">(R[0-9]+)<", evidence["evidence"])
if not matches or len(matches) != 1: if not matches or len(matches) != 1:
logger.warning(f"Could not find reaction id in {evidence['evidence']}") logger.warning(f"Could not find reaction id in {evidence['evidence']}")
print(f"Could not find reaction id in {evidence['evidence']}")
continue continue
e.add_kegg_reaction_id(matches[0]) e.add_kegg_reaction_id(matches[0])
@ -984,7 +1019,6 @@ class PackageManager(object):
print("Fixing Node depths...") print("Fixing Node depths...")
total_pws = Pathway.objects.filter(package=pack).count() total_pws = Pathway.objects.filter(package=pack).count()
for p, pw in enumerate(Pathway.objects.filter(package=pack)): for p, pw in enumerate(Pathway.objects.filter(package=pack)):
print(pw.url)
in_count = defaultdict(lambda: 0) in_count = defaultdict(lambda: 0)
out_count = defaultdict(lambda: 0) out_count = defaultdict(lambda: 0)
@ -1020,7 +1054,6 @@ class PackageManager(object):
if str(prod.uuid) not in seen: if str(prod.uuid) not in seen:
old_depth = prod.depth old_depth = prod.depth
if old_depth != i + 1: if old_depth != i + 1:
print(f"updating depth from {old_depth} to {i + 1}")
prod.depth = i + 1 prod.depth = i + 1
prod.save() prod.save()
@ -1031,7 +1064,7 @@ class PackageManager(object):
if new_level: if new_level:
levels.append(new_level) levels.append(new_level)
print(f"{p + 1}/{total_pws} fixed.") print(f"{p + 1}/{total_pws} fixed.", end="\r")
return pack return pack

View File

@ -1,6 +1,7 @@
import os import os
import subprocess import subprocess
from django.conf import settings
from django.core.management import call_command from django.core.management import call_command
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
@ -45,11 +46,13 @@ class Command(BaseCommand):
if not os.path.exists(dump_file): if not os.path.exists(dump_file):
raise ValueError(f"Dump file {dump_file} does not exist") raise ValueError(f"Dump file {dump_file} does not exist")
print(f"Dropping database {options['name']} y/n: ", end="") db_name = options["name"]
print(f"Dropping database {db_name} y/n: ", end="")
if input() in "yY": if input() in "yY":
result = subprocess.run( result = subprocess.run(
["dropdb", "appdb"], ["dropdb", db_name],
capture_output=True, capture_output=True,
text=True, text=True,
) )
@ -57,20 +60,24 @@ class Command(BaseCommand):
else: else:
raise ValueError("Aborted") raise ValueError("Aborted")
print(f"Creating database {options['name']}") print(f"Creating database {db_name}")
result = subprocess.run( result = subprocess.run(
["createdb", "appdb"], ["createdb", db_name],
capture_output=True, capture_output=True,
text=True, text=True,
) )
print(result.stdout) print(result.stdout)
print(f"Restoring database {options['name']} from {dump_file}") print(f"Restoring database {db_name} from {dump_file}")
result = subprocess.run( result = subprocess.run(
["pg_restore", "-d", "appdb", dump_file, "--no-owner"], ["pg_restore", "-d", db_name, dump_file, "--no-owner"],
capture_output=True, capture_output=True,
text=True, text=True,
) )
print(result.stdout) print(result.stdout)
call_command("localize_urls", "--old", options["oldurl"], "--new", options["newurl"])
if db_name == settings.DATABASES["default"]["NAME"]:
call_command("localize_urls", "--old", options["oldurl"], "--new", options["newurl"])
else:
print("Skipping localize_urls as database is not the default one.")

View File

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

View File

@ -765,7 +765,12 @@ class Package(EnviPathModel):
class Compound( class Compound(
EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin, AdditionalInformationMixin PolymorphicModel,
EnviPathModel,
AliasMixin,
ScenarioMixin,
ChemicalIdentifierMixin,
AdditionalInformationMixin,
): ):
package = models.ForeignKey( package = models.ForeignKey(
s.EPDB_PACKAGE_MODEL, verbose_name="Package", on_delete=models.CASCADE, db_index=True s.EPDB_PACKAGE_MODEL, verbose_name="Package", on_delete=models.CASCADE, db_index=True
@ -1095,7 +1100,12 @@ class Compound(
class CompoundStructure( class CompoundStructure(
EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin, AdditionalInformationMixin PolymorphicModel,
EnviPathModel,
AliasMixin,
ScenarioMixin,
ChemicalIdentifierMixin,
AdditionalInformationMixin,
): ):
compound = models.ForeignKey("epdb.Compound", on_delete=models.CASCADE, db_index=True) compound = models.ForeignKey("epdb.Compound", on_delete=models.CASCADE, db_index=True)
smiles = models.TextField(blank=False, null=False, verbose_name="SMILES") smiles = models.TextField(blank=False, null=False, verbose_name="SMILES")
@ -1775,9 +1785,9 @@ class Reaction(
edges = Edge.objects.filter(edge_label=self) edges = Edge.objects.filter(edge_label=self)
for e in edges: for e in edges:
for scen in e.scenarios.all(): for scen in e.scenarios.all():
for ai in scen.additional_information.keys(): for ai in scen.get_additional_information():
if ai == "Enzyme": if ai.type == "Enzyme":
res.extend(scen.additional_information[ai]) res.append(ai.get())
return res return res
@ -2334,7 +2344,10 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
"reaction_probability": self.kv.get("probability"), "reaction_probability": self.kv.get("probability"),
"start_node_urls": [x.url for x in self.start_nodes.all()], "start_node_urls": [x.url for x in self.start_nodes.all()],
"end_node_urls": [x.url for x in self.end_nodes.all()], "end_node_urls": [x.url for x in self.end_nodes.all()],
"scenarios": [{"name": s.get_name(), "url": s.url} for s in self.scenarios.all()], "scenarios": [
{"name": s.get_name(), "url": s.url, "review_status": s.package.reviewed}
for s in self.scenarios.all()
],
} }
for n in self.start_nodes.all(): for n in self.start_nodes.all():
@ -3458,9 +3471,7 @@ class EnviFormer(PackageBasedModel):
def predict_batch(self, smiles: List[str], *args, **kwargs): def predict_batch(self, smiles: List[str], *args, **kwargs):
# Standardizer removes all but one compound from a raw SMILES string, so they need to be processed separately # Standardizer removes all but one compound from a raw SMILES string, so they need to be processed separately
canon_smiles = [ canon_smiles = [
".".join( ".".join([FormatConverter.standardize(s, remove_stereo=True) for s in smi.split(".")])
[FormatConverter.standardize(s, remove_stereo=True) for s in smiles.split(".")]
)
for smi in smiles for smi in smiles
] ]
logger.info(f"Submitting {canon_smiles} to {self.get_name()}") logger.info(f"Submitting {canon_smiles} to {self.get_name()}")
@ -4138,7 +4149,7 @@ class Scenario(EnviPathModel):
ais = AdditionalInformation.objects.filter(scenario=self) ais = AdditionalInformation.objects.filter(scenario=self)
if direct_only: if direct_only:
return ais.filter(content_object__isnull=True) return ais.filter(object_id__isnull=True)
else: else:
return ais return ais
@ -4180,7 +4191,6 @@ class AdditionalInformation(models.Model):
ai: "EnviPyModel", ai: "EnviPyModel",
scenario=None, scenario=None,
content_object=None, content_object=None,
skip_cleaning=False,
): ):
add_inf = AdditionalInformation() add_inf = AdditionalInformation()
add_inf.package = package add_inf.package = package

17
epdb/template_registry.py Normal file
View File

@ -0,0 +1,17 @@
from collections import defaultdict
from threading import Lock
_registry = defaultdict(list)
_lock = Lock()
def register_template(slot: str, template_name: str, *, order: int = 100):
item = (order, template_name)
with _lock:
if item not in _registry[slot]:
_registry[slot].append(item)
_registry[slot].sort(key=lambda x: x[0])
def get_templates(slot: str):
return [template_name for _, template_name in _registry.get(slot, [])]

View File

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

View File

@ -3,6 +3,7 @@ import logging
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Iterable from typing import Any, Dict, List, Iterable
import requests
import nh3 import nh3
from django.conf import settings as s from django.conf import settings as s
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
@ -147,6 +148,11 @@ def handler500(request):
def login(request): def login(request):
context = get_base_context(request) context = get_base_context(request)
if s.CAP_ENABLED:
context["CAP_ENABLED"] = s.CAP_ENABLED
context["CAP_API_BASE"] = s.CAP_API_BASE
context["CAP_SITE_KEY"] = s.CAP_SITE_KEY
if request.method == "GET": if request.method == "GET":
context["title"] = "enviPath" context["title"] = "enviPath"
context["next"] = request.GET.get("next", "") context["next"] = request.GET.get("next", "")
@ -224,6 +230,11 @@ def logout(request):
def register(request): def register(request):
context = get_base_context(request) context = get_base_context(request)
if s.CAP_ENABLED:
context["CAP_ENABLED"] = s.CAP_ENABLED
context["CAP_API_BASE"] = s.CAP_API_BASE
context["CAP_SITE_KEY"] = s.CAP_SITE_KEY
if request.method == "GET": if request.method == "GET":
# Redirect to unified login page with signup tab # Redirect to unified login page with signup tab
next_url = request.GET.get("next", "") next_url = request.GET.get("next", "")
@ -238,6 +249,33 @@ def register(request):
if next := request.POST.get("next"): if next := request.POST.get("next"):
context["next"] = next context["next"] = next
# Catpcha
if s.CAP_ENABLED:
cap_token = request.POST.get("cap-token")
if not cap_token:
context["message"] = "Missing CAP Token."
return render(request, "static/login.html", context)
verify_url = f"{s.CAP_API_BASE}/{s.CAP_SITE_KEY}/siteverify"
payload = {
"secret": s.CAP_SECRET_KEY,
"response": cap_token,
}
try:
resp = requests.post(verify_url, json=payload, timeout=10)
resp.raise_for_status()
verify_data = resp.json()
except requests.RequestException:
context["message"] = "Captcha verification failed."
return render(request, "static/login.html", context)
if not verify_data.get("success"):
context["message"] = "Captcha check failed. Please try again."
return render(request, "static/login.html", context)
# End Captcha
username = request.POST.get("username", "").strip() username = request.POST.get("username", "").strip()
email = request.POST.get("email", "").strip() email = request.POST.get("email", "").strip()
password = request.POST.get("password", "").strip() password = request.POST.get("password", "").strip()
@ -917,7 +955,7 @@ def package_models(request, package_uuid):
params["threshold"] = threshold params["threshold"] = threshold
mod = EnviFormer.create(**params) mod = EnviFormer.create(**params)
elif model_type == "mlrr": elif model_type == "ml-relative-reasoning":
# ML Specific # ML Specific
threshold = float(request.POST.get("model-threshold", 0.5)) threshold = float(request.POST.get("model-threshold", 0.5))
# TODO handle additional fingerprinter # TODO handle additional fingerprinter
@ -941,7 +979,7 @@ def package_models(request, package_uuid):
params["app_domain_local_compatibility_threshold"] = local_compatibility_threshold params["app_domain_local_compatibility_threshold"] = local_compatibility_threshold
mod = MLRelativeReasoning.create(**params) mod = MLRelativeReasoning.create(**params)
elif model_type == "rbrr": elif model_type == "rule-based-relative-reasoning":
params["rule_packages"] = [ params["rule_packages"] = [
PackageManager.get_package_by_url(current_user, p) for p in rule_packages PackageManager.get_package_by_url(current_user, p) for p in rule_packages
] ]

Binary file not shown.

View File

@ -79,9 +79,9 @@ class PepperPrediction(PredictedProperty):
dist = stats.lognorm(s=sigma_ln, scale=np.exp(mu_ln)) dist = stats.lognorm(s=sigma_ln, scale=np.exp(mu_ln))
# Exact probabilities # Exact probabilities
p_green = dist.cdf(p) # P(X < a) p_green = dist.cdf(p) # P(X < p) prob not persistent
p_yellow = dist.cdf(vp) - p_green # P(a <= X <= b) p_yellow = 1.0 - dist.cdf(p) # P (X > p) prob persistent
p_red = 1.0 - dist.cdf(vp) # P(X > b) p_red = 1.0 - dist.cdf(vp) # P(X > vp) prob very persistent
# Plotting range # Plotting range
q_low, q_high = dist.ppf(quantiles) q_low, q_high = dist.ppf(quantiles)

View File

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

View File

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

View File

@ -18,8 +18,25 @@
<!-- Schema form --> <!-- Schema form -->
<template x-if="schema && !loading"> <template x-if="schema && !loading">
<div class="space-y-4"> <div class="space-y-4">
<template x-if="attach_object">
<div>
<h4>
<span
class="text-lg font-semibold"
x-text="schema['x-title'] + ' attached to'"
></span>
<a
class="text-lg font-semibold underline text-blue-600 hover:text-blue-800"
:href="attach_object.url"
x-text="attach_object.name"
target="_blank"
></a>
</h4>
</div>
</template>
<!-- Title from schema --> <!-- Title from schema -->
<template x-if="schema['x-title'] || schema.title"> <template x-if="(schema['x-title'] || schema.title) && !attach_object">
<h4 <h4
class="text-lg font-semibold" class="text-lg font-semibold"
x-text="data.name || schema['x-title'] || schema.title" x-text="data.name || schema['x-title'] || schema.title"

View File

@ -71,24 +71,129 @@
<label class="label"> <label class="label">
<span class="label-text">User or Group</span> <span class="label-text">User or Group</span>
</label> </label>
<select <div
id="select_grantee" class="relative"
name="grantee" x-data="{
class="select select-bordered w-full select-sm" searchQuery: '',
required selectedItem: null,
showResults: false,
filteredResults: [],
allItems: [
{% for u in users %}
{ type: 'user', name: '{{ u.username }}', url: '{{ u.url }}',
display: '{{ u.username }}' },
{% endfor %}
{% for g in groups %}
{ type: 'group', name: '{{ g.name|safe }}', url: '{{ g.url }}',
display: '{{ g.name|safe }}' },
{% endfor %}
],
init() {
this.filteredResults = this.allItems;
},
search() {
if (this.searchQuery.length === 0) {
this.filteredResults = this.allItems;
} else {
this.filteredResults = this.allItems.filter(item =>
item.name.toLowerCase().includes(this.searchQuery.toLowerCase())
);
}
this.showResults = true;
},
selectItem(item) {
this.selectedItem = item;
this.searchQuery = item.display;
this.showResults = false;
},
clearSelection() {
this.selectedItem = null;
this.searchQuery = '';
this.showResults = false;
}
}"
@click.away="showResults = false"
> >
<optgroup label="Users"> <input
{% for u in users %} type="text"
<option value="{{ u.url }}">{{ u.username }}</option> x-model="searchQuery"
{% endfor %} @input="search()"
</optgroup> @focus="showResults = true; search()"
<optgroup label="Groups"> @keydown.escape="showResults = false"
{% for g in groups %} @keydown.arrow-down.prevent="$refs.resultsList?.children[0]?.focus()"
<option value="{{ g.url }}">{{ g.name|safe }}</option> class="input input-bordered w-full input-sm"
{% endfor %} placeholder="Search users or groups..."
</optgroup> autocomplete="off"
</select> required
/>
<!-- Clear button -->
<button
type="button"
x-show="searchQuery.length > 0"
@click="clearSelection()"
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
</button>
<!-- Hidden input for form submission -->
<input
type="hidden"
name="grantee"
x-bind:value="selectedItem?.url || ''"
required
/>
<!-- Search results dropdown -->
<div
x-show="showResults && filteredResults.length > 0"
x-transition
class="absolute z-50 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-y-auto"
>
<ul x-ref="resultsList" id="resultsList" class="py-1">
<template
x-for="(item, index) in filteredResults"
:key="item.url"
>
<li>
<button
type="button"
@click="selectItem(item)"
@keydown.enter="selectItem(item)"
@keydown.escape="showResults = false"
@keydown.arrow-up.prevent="index > 0 ? $event.target.parentElement.previousElementSibling?.children[0]?.focus() : null"
@keydown.arrow-down.prevent="index < filteredResults.length - 1 ? $event.target.parentElement.nextElementSibling?.children[0]?.focus() : null"
class="w-full px-4 py-2 text-left hover:bg-base-200 focus:bg-base-200 focus:outline-none flex items-center space-x-2"
>
<span
x-text="item.type === 'user' ? '👤' : '👥'"
class="text-sm opacity-60"
></span>
<span x-text="item.display"></span>
<span
x-text="item.type === 'user' ? '(User)' : '(Group)'"
class="text-xs opacity-50 ml-auto"
></span>
</button>
</li>
</template>
</ul>
</div>
<!-- No results message -->
<div
x-show="showResults && filteredResults.length === 0 && searchQuery.length > 0"
x-transition
class="absolute z-50 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg"
>
<div class="px-4 py-2 text-gray-500 text-sm">
No users or groups found
</div>
</div>
</div>
</div> </div>
<div class="col-span-2 text-center"> <div class="col-span-2 text-center">
<label class="label justify-center"> <label class="label justify-center">
<span class="label-text">Read</span> <span class="label-text">Read</span>

View File

@ -189,7 +189,8 @@
x-data="schemaRenderer({ x-data="schemaRenderer({
rjsf: schemas[item.type.toLowerCase()], rjsf: schemas[item.type.toLowerCase()],
data: item.data, data: item.data,
mode: 'view' mode: 'view',
attach_object: item.attach_object
})" })"
x-init="init()" x-init="init()"
> >

View File

@ -218,6 +218,12 @@
<input type="hidden" name="next" value="{{ next }}" /> <input type="hidden" name="next" value="{{ next }}" />
{% if CAP_ENABLED %}
<cap-widget
data-cap-api-endpoint="{{ CAP_API_BASE }}/{{ CAP_SITE_KEY }}/"
></cap-widget>
{% endif %}
<!-- ToS and Academic Use Notice --> <!-- ToS and Academic Use Notice -->
<div class="text-xs text-base-content/70 mt-2"> <div class="text-xs text-base-content/70 mt-2">
<p> <p>
@ -233,7 +239,6 @@
enviPath is free for academic and non-commercial use only. enviPath is free for academic and non-commercial use only.
</p> </p>
</div> </div>
<button type="submit" name="confirmsignup" class="btn btn-success w-full"> <button type="submit" name="confirmsignup" class="btn btn-success w-full">
Sign Up Sign Up
</button> </button>

View File

@ -19,7 +19,16 @@
type="text/css" type="text/css"
/> />
{% block extra_styles %}{% endblock %} {% if CAP_ENABLED %}
<script src="https://cdn.jsdelivr.net/npm/@cap.js/widget@0.1.41/cap.min.js"></script>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@cap.js/widget@0.1.41/src/cap.min.css"
/>
{% endif %}
{% block extra_styles %}
{% endblock %}
</head> </head>
<body class="bg-base-100"> <body class="bg-base-100">
<div class="flex h-screen"> <div class="flex h-screen">

View File

@ -20,7 +20,16 @@ class TestPackagePage(EnviPyStaticLiveServerTestCase):
page.get_by_role("button", name="Actions").click() page.get_by_role("button", name="Actions").click()
page.get_by_role("button", name="Edit Permissions").click() page.get_by_role("button", name="Edit Permissions").click()
# Add read and write permission to enviPath Users group # Add read and write permission to enviPath Users group
page.locator("#select_grantee").select_option(label="enviPath Users") search_input = page.locator('input[placeholder="Search users or groups..."]')
search_input.fill("enviPath")
# Wait for the results list to appear and be populated
page.wait_for_selector("#resultsList", state="visible")
# Click the first button in the results list
first_button = page.locator("#resultsList button").first
first_button.click()
page.locator("#read_new").check() page.locator("#read_new").check()
page.locator("#write_new").check() page.locator("#write_new").check()
page.get_by_role("button", name="+", exact=True).click() page.get_by_role("button", name="+", exact=True).click()

2
uv.lock generated
View File

@ -841,7 +841,7 @@ provides-extras = ["ms-login", "dev", "pepper-plugin"]
[[package]] [[package]]
name = "envipy-additional-information" name = "envipy-additional-information"
version = "0.4.2" version = "0.4.2"
source = { git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git?branch=develop#40459366648a03b01432998b32fdabd5556a1bae" } source = { git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git?branch=develop#04f6a01b8c5cd1342464e004e0cfaec9abc13ac5" }
dependencies = [ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
] ]