4 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
14 changed files with 202 additions and 17 deletions

View File

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

@ -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,
@ -1329,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):
@ -1394,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.get("adInfoTypes[]", "").split(",") 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))
@ -1436,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 #
########### ###########

View File

@ -4191,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()

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,8 +1,10 @@
{% 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 %}
<div class="flex items-center gap-2">
{% if meta.can_edit %} {% if meta.can_edit %}
<button <button
type="button" type="button"
@ -12,10 +14,21 @@
New Compound New Compound
</button> </button>
{% endif %} {% 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

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