forked from enviPath/enviPy
Compare commits
10 Commits
d4295c9349
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| f7c45b8015 | |||
| 68aea97013 | |||
| 3cc7fa9e8b | |||
| 21f3390a43 | |||
| 8cdf91c8fb | |||
| bafbf11322 | |||
| f1a9456d1d | |||
| e0764126e3 | |||
| ef0c45b203 | |||
| b737fc93eb |
@ -92,10 +92,19 @@ if os.environ.get("REGISTRATION_MANDATORY", False) == "True":
|
||||
|
||||
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 = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": (os.path.join(BASE_DIR, "templates"),),
|
||||
"DIRS": TEMPLATE_DIRS,
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
@ -399,3 +408,9 @@ if MS_ENTRA_ENABLED:
|
||||
|
||||
# Site ID 10 -> beta.envipath.org
|
||||
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)
|
||||
|
||||
@ -60,7 +60,7 @@ class ScenarioCreationAPITests(TestCase):
|
||||
)
|
||||
|
||||
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):
|
||||
"""Test that unauthorized access returns 403."""
|
||||
|
||||
@ -41,6 +41,24 @@ def get_package_for_read(user, package_uuid: UUID):
|
||||
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):
|
||||
"""Get scenario by UUID with read permission check."""
|
||||
try:
|
||||
|
||||
@ -9,7 +9,6 @@ import logging
|
||||
import json
|
||||
|
||||
from epdb.models import Scenario
|
||||
from epdb.logic import PackageManager
|
||||
from epdb.views import _anonymous_or_real
|
||||
from ..pagination import EnhancedPageNumberPagination
|
||||
from ..schemas import (
|
||||
@ -17,7 +16,7 @@ from ..schemas import (
|
||||
ScenarioOutSchema,
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -58,7 +57,7 @@ def create_scenario(request, package_uuid: UUID, payload: ScenarioCreateSchema =
|
||||
user = _anonymous_or_real(request)
|
||||
|
||||
try:
|
||||
current_package = PackageManager.get_package_by_id(user, package_uuid)
|
||||
current_package = get_package_for_write(user, package_uuid)
|
||||
except ValueError as e:
|
||||
error_msg = str(e)
|
||||
if "does not exist" in error_msg:
|
||||
|
||||
@ -16,6 +16,10 @@ class EPDBConfig(AppConfig):
|
||||
model_name = getattr(settings, "EPDB_PACKAGE_MODEL", "epdb.Package")
|
||||
logger.info(f"Using Package model: {model_name}")
|
||||
|
||||
from .autodiscovery import autodiscover
|
||||
|
||||
autodiscover()
|
||||
|
||||
if settings.PLUGINS_ENABLED:
|
||||
from bridge.contracts import Property
|
||||
from utilities.plugin import discover_plugins
|
||||
|
||||
5
epdb/autodiscovery.py
Normal file
5
epdb/autodiscovery.py
Normal file
@ -0,0 +1,5 @@
|
||||
from django.utils.module_loading import autodiscover_modules
|
||||
|
||||
|
||||
def autodiscover():
|
||||
autodiscover_modules("epdb_hooks")
|
||||
@ -1,3 +1,4 @@
|
||||
from collections import defaultdict
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import nh3
|
||||
@ -11,8 +12,16 @@ from ninja.security import SessionAuth
|
||||
from utilities.chem import FormatConverter
|
||||
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 (
|
||||
AdditionalInformation,
|
||||
Compound,
|
||||
CompoundStructure,
|
||||
Edge,
|
||||
@ -94,6 +103,8 @@ class SimpleObject(Schema):
|
||||
return "reviewed" if obj.compound.package.reviewed else "unreviewed"
|
||||
elif isinstance(obj, Node) or isinstance(obj, Edge):
|
||||
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:
|
||||
raise ValueError("Object has no package")
|
||||
|
||||
@ -1327,7 +1338,14 @@ class ScenarioSchema(Schema):
|
||||
|
||||
@staticmethod
|
||||
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
|
||||
def resolve_review_status(obj: Rule):
|
||||
@ -1392,7 +1410,11 @@ def create_package_scenario(request, package_uuid):
|
||||
study_type = request.POST.get("type")
|
||||
|
||||
ais = []
|
||||
types = request.POST.getlist("adInfoTypes[]")
|
||||
types = request.POST.get("adInfoTypes[]", [])
|
||||
|
||||
if types:
|
||||
types = types.split(",")
|
||||
|
||||
for t in types:
|
||||
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 #
|
||||
###########
|
||||
@ -1464,7 +1529,7 @@ class PathwayEdge(Schema):
|
||||
|
||||
class PathwayNode(Schema):
|
||||
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")
|
||||
engineeredIntermediate: bool = Field(None, alias="engineered_intermediate")
|
||||
id: str = Field(None, alias="url")
|
||||
@ -1805,7 +1870,7 @@ class EdgeSchema(Schema):
|
||||
startNodes: List["EdgeNode"] = Field([], alias="start_nodes")
|
||||
|
||||
@staticmethod
|
||||
def resolve_review_status(obj: Node):
|
||||
def resolve_review_status(obj: Edge):
|
||||
return "reviewed" if obj.pathway.package.reviewed else "unreviewed"
|
||||
|
||||
|
||||
|
||||
@ -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),
|
||||
]
|
||||
@ -765,7 +765,12 @@ class Package(EnviPathModel):
|
||||
|
||||
|
||||
class Compound(
|
||||
EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin, AdditionalInformationMixin
|
||||
PolymorphicModel,
|
||||
EnviPathModel,
|
||||
AliasMixin,
|
||||
ScenarioMixin,
|
||||
ChemicalIdentifierMixin,
|
||||
AdditionalInformationMixin,
|
||||
):
|
||||
package = models.ForeignKey(
|
||||
s.EPDB_PACKAGE_MODEL, verbose_name="Package", on_delete=models.CASCADE, db_index=True
|
||||
@ -1095,7 +1100,12 @@ class Compound(
|
||||
|
||||
|
||||
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)
|
||||
smiles = models.TextField(blank=False, null=False, verbose_name="SMILES")
|
||||
@ -1775,9 +1785,9 @@ class Reaction(
|
||||
edges = Edge.objects.filter(edge_label=self)
|
||||
for e in edges:
|
||||
for scen in e.scenarios.all():
|
||||
for ai in scen.additional_information.keys():
|
||||
if ai == "Enzyme":
|
||||
res.extend(scen.additional_information[ai])
|
||||
for ai in scen.get_additional_information():
|
||||
if ai.type == "Enzyme":
|
||||
res.append(ai.get())
|
||||
return res
|
||||
|
||||
|
||||
@ -2334,7 +2344,10 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
|
||||
"reaction_probability": self.kv.get("probability"),
|
||||
"start_node_urls": [x.url for x in self.start_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():
|
||||
@ -3458,9 +3471,7 @@ class EnviFormer(PackageBasedModel):
|
||||
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
|
||||
canon_smiles = [
|
||||
".".join(
|
||||
[FormatConverter.standardize(s, remove_stereo=True) for s in smiles.split(".")]
|
||||
)
|
||||
".".join([FormatConverter.standardize(s, remove_stereo=True) for s in smi.split(".")])
|
||||
for smi in smiles
|
||||
]
|
||||
logger.info(f"Submitting {canon_smiles} to {self.get_name()}")
|
||||
@ -4138,7 +4149,7 @@ class Scenario(EnviPathModel):
|
||||
ais = AdditionalInformation.objects.filter(scenario=self)
|
||||
|
||||
if direct_only:
|
||||
return ais.filter(content_object__isnull=True)
|
||||
return ais.filter(object_id__isnull=True)
|
||||
else:
|
||||
return ais
|
||||
|
||||
@ -4180,7 +4191,6 @@ class AdditionalInformation(models.Model):
|
||||
ai: "EnviPyModel",
|
||||
scenario=None,
|
||||
content_object=None,
|
||||
skip_cleaning=False,
|
||||
):
|
||||
add_inf = AdditionalInformation()
|
||||
add_inf.package = package
|
||||
|
||||
17
epdb/template_registry.py
Normal file
17
epdb/template_registry.py
Normal 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, [])]
|
||||
@ -2,6 +2,8 @@ from django import template
|
||||
from pydantic import AnyHttpUrl, ValidationError
|
||||
from pydantic.type_adapter import TypeAdapter
|
||||
|
||||
from epdb.template_registry import get_templates
|
||||
|
||||
register = template.Library()
|
||||
|
||||
url_adapter = TypeAdapter(AnyHttpUrl)
|
||||
@ -19,3 +21,8 @@ def is_url(value):
|
||||
return True
|
||||
except ValidationError:
|
||||
return False
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def epdb_slot_templates(slot):
|
||||
return get_templates(slot)
|
||||
|
||||
@ -3,6 +3,7 @@ import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Iterable
|
||||
|
||||
import requests
|
||||
import nh3
|
||||
from django.conf import settings as s
|
||||
from django.contrib.auth import get_user_model
|
||||
@ -147,6 +148,11 @@ def handler500(request):
|
||||
def login(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":
|
||||
context["title"] = "enviPath"
|
||||
context["next"] = request.GET.get("next", "")
|
||||
@ -224,6 +230,11 @@ def logout(request):
|
||||
def register(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":
|
||||
# Redirect to unified login page with signup tab
|
||||
next_url = request.GET.get("next", "")
|
||||
@ -238,6 +249,33 @@ def register(request):
|
||||
if next := request.POST.get("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()
|
||||
email = request.POST.get("email", "").strip()
|
||||
password = request.POST.get("password", "").strip()
|
||||
@ -917,7 +955,7 @@ def package_models(request, package_uuid):
|
||||
params["threshold"] = threshold
|
||||
|
||||
mod = EnviFormer.create(**params)
|
||||
elif model_type == "mlrr":
|
||||
elif model_type == "ml-relative-reasoning":
|
||||
# ML Specific
|
||||
threshold = float(request.POST.get("model-threshold", 0.5))
|
||||
# TODO handle additional fingerprinter
|
||||
@ -941,7 +979,7 @@ def package_models(request, package_uuid):
|
||||
params["app_domain_local_compatibility_threshold"] = local_compatibility_threshold
|
||||
|
||||
mod = MLRelativeReasoning.create(**params)
|
||||
elif model_type == "rbrr":
|
||||
elif model_type == "rule-based-relative-reasoning":
|
||||
params["rule_packages"] = [
|
||||
PackageManager.get_package_by_url(current_user, p) for p in rule_packages
|
||||
]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -79,9 +79,9 @@ class PepperPrediction(PredictedProperty):
|
||||
dist = stats.lognorm(s=sigma_ln, scale=np.exp(mu_ln))
|
||||
|
||||
# Exact probabilities
|
||||
p_green = dist.cdf(p) # P(X < a)
|
||||
p_yellow = dist.cdf(vp) - p_green # P(a <= X <= b)
|
||||
p_red = 1.0 - dist.cdf(vp) # P(X > b)
|
||||
p_green = dist.cdf(p) # P(X < p) prob not persistent
|
||||
p_yellow = 1.0 - dist.cdf(p) # P (X > p) prob persistent
|
||||
p_red = 1.0 - dist.cdf(vp) # P(X > vp) prob very persistent
|
||||
|
||||
# Plotting range
|
||||
q_low, q_high = dist.ppf(quantiles)
|
||||
|
||||
@ -88,6 +88,7 @@ document.addEventListener("alpine:init", () => {
|
||||
options.debugErrors ??
|
||||
(typeof window !== "undefined" &&
|
||||
window.location?.search?.includes("debugErrors=1")),
|
||||
attach_object: options.attach_object || null,
|
||||
|
||||
async init() {
|
||||
if (options.schemaUrl) {
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
{% extends "collections/paginated_base.html" %}
|
||||
{% load envipytags %}
|
||||
|
||||
{% block page_title %}Compounds{% endblock %}
|
||||
|
||||
{% block action_button %}
|
||||
<div class="flex items-center gap-2">
|
||||
{% if meta.can_edit %}
|
||||
<button
|
||||
type="button"
|
||||
@ -12,10 +14,21 @@
|
||||
New Compound
|
||||
</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 %}
|
||||
|
||||
{% block action_modals %}
|
||||
{% 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 %}
|
||||
|
||||
{% block description %}
|
||||
|
||||
@ -18,8 +18,25 @@
|
||||
<!-- Schema form -->
|
||||
<template x-if="schema && !loading">
|
||||
<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 -->
|
||||
<template x-if="schema['x-title'] || schema.title">
|
||||
<template x-if="(schema['x-title'] || schema.title) && !attach_object">
|
||||
<h4
|
||||
class="text-lg font-semibold"
|
||||
x-text="data.name || schema['x-title'] || schema.title"
|
||||
|
||||
@ -71,24 +71,129 @@
|
||||
<label class="label">
|
||||
<span class="label-text">User or Group</span>
|
||||
</label>
|
||||
<select
|
||||
id="select_grantee"
|
||||
name="grantee"
|
||||
class="select select-bordered w-full select-sm"
|
||||
required
|
||||
>
|
||||
<optgroup label="Users">
|
||||
<div
|
||||
class="relative"
|
||||
x-data="{
|
||||
searchQuery: '',
|
||||
selectedItem: null,
|
||||
showResults: false,
|
||||
filteredResults: [],
|
||||
allItems: [
|
||||
{% for u in users %}
|
||||
<option value="{{ u.url }}">{{ u.username }}</option>
|
||||
{ type: 'user', name: '{{ u.username }}', url: '{{ u.url }}',
|
||||
display: '{{ u.username }}' },
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
<optgroup label="Groups">
|
||||
{% for g in groups %}
|
||||
<option value="{{ g.url }}">{{ g.name|safe }}</option>
|
||||
{ type: 'group', name: '{{ g.name|safe }}', url: '{{ g.url }}',
|
||||
display: '{{ g.name|safe }}' },
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
</select>
|
||||
],
|
||||
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"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
x-model="searchQuery"
|
||||
@input="search()"
|
||||
@focus="showResults = true; search()"
|
||||
@keydown.escape="showResults = false"
|
||||
@keydown.arrow-down.prevent="$refs.resultsList?.children[0]?.focus()"
|
||||
class="input input-bordered w-full input-sm"
|
||||
placeholder="Search users or groups..."
|
||||
autocomplete="off"
|
||||
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 class="col-span-2 text-center">
|
||||
<label class="label justify-center">
|
||||
<span class="label-text">Read</span>
|
||||
|
||||
@ -189,7 +189,8 @@
|
||||
x-data="schemaRenderer({
|
||||
rjsf: schemas[item.type.toLowerCase()],
|
||||
data: item.data,
|
||||
mode: 'view'
|
||||
mode: 'view',
|
||||
attach_object: item.attach_object
|
||||
})"
|
||||
x-init="init()"
|
||||
>
|
||||
|
||||
@ -218,6 +218,12 @@
|
||||
|
||||
<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 -->
|
||||
<div class="text-xs text-base-content/70 mt-2">
|
||||
<p>
|
||||
@ -233,7 +239,6 @@
|
||||
enviPath is free for academic and non-commercial use only.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" name="confirmsignup" class="btn btn-success w-full">
|
||||
Sign Up
|
||||
</button>
|
||||
|
||||
@ -19,7 +19,16 @@
|
||||
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>
|
||||
<body class="bg-base-100">
|
||||
<div class="flex h-screen">
|
||||
|
||||
@ -20,7 +20,16 @@ class TestPackagePage(EnviPyStaticLiveServerTestCase):
|
||||
page.get_by_role("button", name="Actions").click()
|
||||
page.get_by_role("button", name="Edit Permissions").click()
|
||||
# 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("#write_new").check()
|
||||
page.get_by_role("button", name="+", exact=True).click()
|
||||
|
||||
2
uv.lock
generated
2
uv.lock
generated
@ -841,7 +841,7 @@ provides-extras = ["ms-login", "dev", "pepper-plugin"]
|
||||
[[package]]
|
||||
name = "envipy-additional-information"
|
||||
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 = [
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user