6 Commits

Author SHA1 Message Date
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
14 changed files with 259 additions and 41 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": [

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

@ -94,6 +94,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")
@ -1392,7 +1394,7 @@ 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[]", "").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))
@ -1464,7 +1466,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 +1807,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

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

View File

@ -917,7 +917,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 +941,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

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

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