17 Commits

Author SHA1 Message Date
58ab5b33e3 [Fix] Filter Active Users (#314) (#329)
Adding users to a group or setting permissions on a package now filter for active users. Also any inactive members of group/package get marked as such.

<img width="490" alt="{3B906C71-F3AE-41E4-A61C-B8377D79F685}.png" src="attachments/09cf149a-9d7a-4560-8ce7-9f3487527ee2">

Reviewed-on: enviPath/enviPy#329
Co-authored-by: Liam Brydon <lbry121@aucklanduni.ac.nz>
Co-committed-by: Liam Brydon <lbry121@aucklanduni.ac.nz>
2026-02-12 20:20:16 +13:00
73f0202267 [Fix] Filter Scenarios with Parent (#311) (#323)
The scenarios lists both in /scenarios and /package/<id>/scenario no longer show related scenarios (children).
All related scenarios are shown on the scenario page under Related Scenarios if there are any.
<img width="500" alt="{C2D38DED-A402-4A27-A241-BC2302C62A50}.png" src="attachments/1371c177-220c-42d5-94ff-56f9fbab761f">

Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#323
Co-authored-by: Liam Brydon <lbry121@aucklanduni.ac.nz>
Co-committed-by: Liam Brydon <lbry121@aucklanduni.ac.nz>
2026-02-11 23:19:20 +13:00
27c5bad9c5 [Fix] Upgraded ai-lib, temporarily ignore additional validation errors (#328)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#328
2026-02-11 03:49:20 +13:00
5789f20e7f [Feature] Create API Key Authenticaton for v1 API (#327)
Add API key authentication to v1 API
Also includes:
- management command to create keys for users
- Improvements to API tests

Minor:
- more robust way to start docker dev container.

Reviewed-on: enviPath/enviPy#327
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2026-02-11 02:29:54 +13:00
c0cfdb9255 [Style] Adds custom name display for timeseries (#320)
<img width="1336" alt="image.png" src="attachments/58e49257-976e-469f-a19e-069c8915c437">

Co-authored-by: jebus <lorsbach@envipath.com>
Reviewed-on: enviPath/enviPy#320
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2026-02-04 08:15:16 +13:00
5da8dbc191 [Feature] Timeseries Pathway view (#319)
**Warning depends on Timeseries feature to be merged**

Implements a way to display OECD 301F data on the pathway view.
This is mostly a PoC and needs to be improved once the pathway rendering is updated.

![image.png](/attachments/053965d7-78f7-487a-b5d0-898612708fa3)

Co-authored-by: jebus <lorsbach@envipath.com>
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#319
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2026-02-04 05:19:25 +13:00
dc18b73e08 [Feature] Adds timeseries display (#313)
Adds a way to input/display timeseries data to the additional information

Reviewed-on: enviPath/enviPy#313
Reviewed-by: jebus <lorsbach@envipath.com>
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2026-02-04 01:01:06 +13:00
d80dfb5ee3 [Feature] Dynamic additional information rendering in frontend (#282)
This implements a version of #274, relying on Pydantics built in JSON schema and JSON rendering.
Requires additional UI tagging in the ai model repo but will remove HTML tags.

Example scenario with filled information: 5882df9c-dae1-4d80-a40e-db4724271456/scenario/3a4d395a-6a6d-4154-8ce3-ced667fceec0

Reviewed-on: enviPath/enviPy#282
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2026-01-31 00:44:03 +13:00
9f63a9d4de [Fix] Fixed ObjectDoesNotExist for send_registration_mail, fixed duplicate mail sending (#312)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#312
2026-01-29 20:24:11 +13:00
5565b9cb9e [Fix] UI bugs, Registrations Mail, BTRules Popup, Legacy API fixes (#309)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#309
2026-01-29 11:13:34 +13:00
ab0b5a5186 [Feature] Leftovers after Release (#303)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#303
2026-01-22 10:26:38 +13:00
f905bf21cf [Feature] Update ToS to be more legally safe and sensible (#301)
- Improved ToS content
- Add ToS pointer and academic use note at signup
- Remove legal collection page (unnecessary)

Reviewed-on: enviPath/enviPy#301
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2026-01-20 03:18:40 +13:00
1fd993927c [Feature] Check os.environ for ENV_PATH (#300)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#300
2026-01-19 23:41:43 +13:00
2a2fe4f147 Static pages update (#299)
I have updated the static pages following @wicker comments on #275

Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Reviewed-on: enviPath/enviPy#299
Co-authored-by: Liam Brydon <lbry121@aucklanduni.ac.nz>
Co-committed-by: Liam Brydon <lbry121@aucklanduni.ac.nz>
2026-01-19 23:37:08 +13:00
5f5ae76182 [Fix] Fix Prediction Spinner, ensure proper pathway status is set
Fixes Spinner and status message display on pathway page

Reviewed-on: enviPath/enviPy#291
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2026-01-15 23:09:12 +13:00
1c2f70b3b9 [Feature] Add batch-predict to site-map (#285)
Adds batch predict to the site-map but does not give it prominence.
This is to avoid non-experts "accidentally" flooding the system.

Happy to move it to the main menu if better, @jebus?

Reviewed-on: enviPath/enviPy#285
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2026-01-15 22:30:31 +13:00
54f8302104 [FIX] Fixed Search Output, Legacy API Model Endpoint, Handle ObjectsDoesNotExists in views (#297)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#297
2026-01-15 20:39:54 +13:00
102 changed files with 7765 additions and 1712 deletions

View File

@ -48,11 +48,6 @@ runs:
shell: bash
run: |
uv run python scripts/pnpm_wrapper.py install
cat << 'EOF' > pnpm-workspace.yaml
onlyBuiltDependencies:
- '@parcel/watcher'
- '@tailwindcss/oxide'
EOF
uv run python scripts/pnpm_wrapper.py run build
- name: Wait for Postgres

View File

@ -8,13 +8,12 @@ These instructions will guide you through setting up the project for local devel
- Python 3.11 or later
- [uv](https://github.com/astral-sh/uv) - Python package manager
- **Docker and Docker Compose** - Required for running PostgreSQL database
- **Docker and Docker Compose** - Required for running PostgreSQL database and Redis (for async Celery tasks)
- Git
- Make
> **Note:** This application requires PostgreSQL (uses `ArrayField`). Docker is the easiest way to run PostgreSQL locally.
### 1. Install Dependencies
This project uses `uv` to manage dependencies and `poe-the-poet` for task running. First, [install `uv` if you don't have it yet](https://docs.astral.sh/uv/guides/install-python/).
@ -79,25 +78,48 @@ uv run poe bootstrap # Bootstrap data only
uv run poe shell # Open the Django shell
uv run poe build # Build frontend assets and collect static files
uv run poe clean # Remove database volumes (WARNING: destroys all data)
uv run poe celery # Start Celery worker for async task processing
uv run poe celery-dev # Start database and Celery worker
```
### 4. Async Celery Setup (Optional)
By default, Celery tasks run synchronously (`CELERY_TASK_ALWAYS_EAGER = True`), which means prediction tasks block the HTTP request until completion. To enable asynchronous task processing with live status updates on pathway pages:
1. **Set the Celery flag in your `.env` file:**
```bash
FLAG_CELERY_PRESENT=True
```
2. **Start Redis and Celery worker:**
```bash
uv run poe celery-dev
```
3. **Start the development server** (in another terminal):
```bash
uv run poe dev
```
### Troubleshooting
* **Docker Connection Error:** If you see an error like `open //./pipe/dockerDesktopLinuxEngine: The system cannot find the file specified` (on Windows), it likely means your Docker Desktop application is not running. Please start Docker Desktop and try the command again.
- **Docker Connection Error:** If you see an error like `open //./pipe/dockerDesktopLinuxEngine: The system cannot find the file specified` (on Windows), it likely means your Docker Desktop application is not running. Please start Docker Desktop and try the command again.
* **SSH Keys for Git Dependencies:** Some dependencies are installed from private git repositories and require SSH authentication. Ensure your SSH keys are configured correctly for Git.
* For a general guide, see [GitHub's official documentation](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent).
* **Windows Users:** If `uv sync` hangs while fetching git dependencies, you may need to explicitly configure Git to use the Windows OpenSSH client and use the `ssh-agent` to manage your key's passphrase.
- **SSH Keys for Git Dependencies:** Some dependencies are installed from private git repositories and require SSH authentication. Ensure your SSH keys are configured correctly for Git.
- For a general guide, see [GitHub's official documentation](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent).
- **Windows Users:** If `uv sync` hangs while fetching git dependencies, you may need to explicitly configure Git to use the Windows OpenSSH client and use the `ssh-agent` to manage your key's passphrase.
1. **Point Git to the correct SSH executable:**
```powershell
git config --global core.sshCommand "C:/Windows/System32/OpenSSH/ssh.exe"
```
2. **Enable and use the SSH agent:**
1. **Point Git to the correct SSH executable:**
```powershell
git config --global core.sshCommand "C:/Windows/System32/OpenSSH/ssh.exe"
```
2. **Enable and use the SSH agent:**
```powershell
# Run these commands in an administrator PowerShell
Get-Service ssh-agent | Set-Service -StartupType Automatic -PassThru | Start-Service
```powershell
# Run these commands in an administrator PowerShell
Get-Service ssh-agent | Set-Service -StartupType Automatic -PassThru | Start-Service
# Add your key to the agent. It will prompt for the passphrase once.
ssh-add
```
# Add your key to the agent. It will prompt for the passphrase once.
ssh-add
```

View File

@ -1,6 +1,6 @@
services:
db:
image: postgres:15
image: postgres:18
container_name: envipath-postgres
environment:
POSTGRES_USER: postgres
@ -9,12 +9,18 @@ services:
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- postgres_data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: envipath-redis
ports:
- "6379:6379"
volumes:
postgres_data:

View File

@ -21,7 +21,9 @@ from sklearn.tree import DecisionTreeClassifier
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / ".env", override=False)
ENV_PATH = os.environ.get("ENV_PATH", BASE_DIR / ".env")
print(f"Loading env from {ENV_PATH}")
load_dotenv(ENV_PATH, override=False)
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
@ -50,7 +52,7 @@ INSTALLED_APPS = [
# Custom
"epapi", # API endpoints (v1, etc.)
"epdb",
# "migration",
"migration",
]
TENANT = os.environ.get("TENANT", "public")

View File

@ -0,0 +1 @@
"""Tests for epapi utility modules."""

View File

@ -0,0 +1,218 @@
"""
Tests for validation error utilities.
Tests the format_validation_error() and handle_validation_error() functions
that transform Pydantic validation errors into user-friendly messages.
"""
from django.test import TestCase, tag
import json
from pydantic import BaseModel, ValidationError, field_validator
from typing import Literal
from ninja.errors import HttpError
from epapi.utils.validation_errors import format_validation_error, handle_validation_error
@tag("api", "utils")
class ValidationErrorUtilityTests(TestCase):
"""Test validation error utility functions."""
def test_format_missing_field_error(self):
"""Test formatting of missing required field error."""
# Create a model with required field
class TestModel(BaseModel):
required_field: str
# Trigger validation error
try:
TestModel()
except ValidationError as e:
errors = e.errors()
self.assertEqual(len(errors), 1)
formatted = format_validation_error(errors[0])
self.assertEqual(formatted, "This field is required")
def test_format_enum_error(self):
"""Test formatting of enum validation error."""
class TestModel(BaseModel):
status: Literal["active", "inactive"]
try:
TestModel(status="invalid")
except ValidationError as e:
errors = e.errors()
self.assertEqual(len(errors), 1)
formatted = format_validation_error(errors[0])
# Literal errors get formatted as "Please enter ..." with the valid options
self.assertIn("Please enter", formatted)
self.assertIn("active", formatted)
self.assertIn("inactive", formatted)
def test_format_type_errors(self):
"""Test formatting of type validation errors (string, int, float)."""
test_cases = [
# (field_type, invalid_value, expected_message)
# Note: We don't check exact error_type as Pydantic may use different types
# (e.g., int_type vs int_parsing) but we verify the formatted message is correct
(str, 123, "Please enter a valid string"),
(int, "not_a_number", "Please enter a valid int"),
(float, "not_a_float", "Please enter a valid float"),
]
for field_type, invalid_value, expected_message in test_cases:
with self.subTest(field_type=field_type.__name__):
class TestModel(BaseModel):
field: field_type
try:
TestModel(field=invalid_value)
except ValidationError as e:
errors = e.errors()
self.assertEqual(len(errors), 1)
formatted = format_validation_error(errors[0])
self.assertEqual(formatted, expected_message)
def test_format_value_error(self):
"""Test formatting of value error from custom validator."""
class TestModel(BaseModel):
age: int
@field_validator("age")
@classmethod
def validate_age(cls, v):
if v < 0:
raise ValueError("Age must be positive")
return v
try:
TestModel(age=-5)
except ValidationError as e:
errors = e.errors()
self.assertEqual(len(errors), 1)
formatted = format_validation_error(errors[0])
self.assertEqual(formatted, "Age must be positive")
def test_format_unknown_error_type_fallback(self):
"""Test that unknown error types fall back to default formatting."""
# Mock an error with an unknown type
mock_error = {
"type": "unknown_custom_type",
"msg": "Input should be a valid email address",
"ctx": {},
}
formatted = format_validation_error(mock_error)
# Should use the else branch which does replacements on the message
self.assertEqual(formatted, "Please enter a valid email address")
def test_handle_validation_error_structure(self):
"""Test that handle_validation_error raises HttpError with correct structure."""
class TestModel(BaseModel):
name: str
count: int
try:
TestModel(name=123, count="invalid")
except ValidationError as e:
# handle_validation_error should raise HttpError
with self.assertRaises(HttpError) as context:
handle_validation_error(e)
http_error = context.exception
self.assertEqual(http_error.status_code, 400)
# Parse the JSON from the error message
error_data = json.loads(http_error.message)
# Check structure
self.assertEqual(error_data["type"], "validation_error")
self.assertIn("field_errors", error_data)
self.assertIn("message", error_data)
self.assertEqual(error_data["message"], "Please correct the errors below")
# Check that both fields have errors
self.assertIn("name", error_data["field_errors"])
self.assertIn("count", error_data["field_errors"])
def test_handle_validation_error_no_pydantic_internals(self):
"""Test that handle_validation_error doesn't expose Pydantic internals."""
class TestModel(BaseModel):
email: str
try:
TestModel(email=123)
except ValidationError as e:
with self.assertRaises(HttpError) as context:
handle_validation_error(e)
http_error = context.exception
error_data = json.loads(http_error.message)
error_str = json.dumps(error_data)
# Ensure no Pydantic internals are exposed
self.assertNotIn("pydantic", error_str.lower())
self.assertNotIn("https://errors.pydantic.dev", error_str)
self.assertNotIn("loc", error_str)
def test_handle_validation_error_user_friendly_messages(self):
"""Test that all error messages are user-friendly."""
class TestModel(BaseModel):
name: str
age: int
status: Literal["active", "inactive"]
try:
TestModel(name=123, status="invalid") # Multiple errors
except ValidationError as e:
with self.assertRaises(HttpError) as context:
handle_validation_error(e)
http_error = context.exception
error_data = json.loads(http_error.message)
# All messages should be user-friendly (contain "Please" or "This field")
for field, messages in error_data["field_errors"].items():
for message in messages:
# User-friendly messages start with "Please" or "This field"
self.assertTrue(
message.startswith("Please") or message.startswith("This field"),
f"Message '{message}' is not user-friendly",
)
def test_handle_validation_error_multiple_errors_same_field(self):
"""Test handling multiple validation errors for the same field."""
class TestModel(BaseModel):
value: int
@field_validator("value")
@classmethod
def validate_range(cls, v):
if v < 0:
raise ValueError("Must be non-negative")
if v > 100:
raise ValueError("Must be at most 100")
return v
# Test with string (type error) - this will fail before the validator runs
try:
TestModel(value="invalid")
except ValidationError as e:
with self.assertRaises(HttpError) as context:
handle_validation_error(e)
http_error = context.exception
error_data = json.loads(http_error.message)
# Should have error for 'value' field
self.assertIn("value", error_data["field_errors"])
self.assertIsInstance(error_data["field_errors"]["value"], list)
self.assertGreater(len(error_data["field_errors"]["value"]), 0)

View File

@ -0,0 +1,448 @@
"""
Tests for Additional Information API endpoints.
Tests CRUD operations on scenario additional information including the new PATCH endpoint.
"""
from django.test import TestCase, tag
import json
from uuid import uuid4
from epdb.logic import PackageManager, UserManager
from epdb.models import Scenario
@tag("api", "additional_information")
class AdditionalInformationAPITests(TestCase):
"""Test additional information API endpoints."""
@classmethod
def setUpTestData(cls):
"""Set up test data: user, package, and scenario."""
cls.user = UserManager.create_user(
"ai-test-user",
"ai-test@envipath.com",
"SuperSafe",
set_setting=False,
add_to_group=False,
is_active=True,
)
cls.other_user = UserManager.create_user(
"ai-other-user",
"ai-other@envipath.com",
"SuperSafe",
set_setting=False,
add_to_group=False,
is_active=True,
)
cls.package = PackageManager.create_package(
cls.user, "AI Test Package", "Test package for additional information"
)
# Package owned by other_user (no access for cls.user)
cls.other_package = PackageManager.create_package(
cls.other_user, "Other Package", "Package without access"
)
# Create a scenario for testing
cls.scenario = Scenario.objects.create(
package=cls.package,
name="Test Scenario",
description="Test scenario for additional information tests",
scenario_type="biodegradation",
scenario_date="2024-01-01",
additional_information={}, # Initialize with empty dict
)
cls.other_scenario = Scenario.objects.create(
package=cls.other_package,
name="Other Scenario",
description="Scenario in package without access",
scenario_type="biodegradation",
scenario_date="2024-01-01",
additional_information={},
)
def test_list_all_schemas(self):
"""Test GET /api/v1/information/schema/ returns all schemas."""
self.client.force_login(self.user)
response = self.client.get("/api/v1/information/schema/")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIsInstance(data, dict)
# Should have multiple schemas
self.assertGreater(len(data), 0)
# Each schema should have RJSF format
for name, schema in data.items():
self.assertIn("schema", schema)
self.assertIn("uiSchema", schema)
self.assertIn("formData", schema)
self.assertIn("groups", schema)
def test_get_specific_schema(self):
"""Test GET /api/v1/information/schema/{model_name}/ returns specific schema."""
self.client.force_login(self.user)
# Assuming 'temperature' is a valid model
response = self.client.get("/api/v1/information/schema/temperature/")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIn("schema", data)
self.assertIn("uiSchema", data)
def test_get_nonexistent_schema_returns_404(self):
"""Test GET for non-existent schema returns 404."""
self.client.force_login(self.user)
response = self.client.get("/api/v1/information/schema/nonexistent/")
self.assertEqual(response.status_code, 404)
def test_list_scenario_information_empty(self):
"""Test GET /api/v1/scenario/{uuid}/information/ returns empty list initially."""
self.client.force_login(self.user)
response = self.client.get(f"/api/v1/scenario/{self.scenario.uuid}/information/")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIsInstance(data, list)
self.assertEqual(len(data), 0)
def test_create_additional_information(self):
"""Test POST creates additional information."""
self.client.force_login(self.user)
# Create temperature information (assuming temperature model exists)
payload = {"interval": {"start": 20, "end": 25}}
response = self.client.post(
f"/api/v1/scenario/{self.scenario.uuid}/information/temperature/",
data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["status"], "created")
self.assertIn("uuid", data)
self.assertIsNotNone(data["uuid"])
def test_create_with_invalid_data_returns_400(self):
"""Test POST with invalid data returns 400 with validation errors."""
self.client.force_login(self.user)
# Invalid data (missing required fields or wrong types)
payload = {"invalid_field": "value"}
response = self.client.post(
f"/api/v1/scenario/{self.scenario.uuid}/information/temperature/",
data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
data = response.json()
# Should have validation error details in 'detail' field
self.assertIn("detail", data)
def test_validation_errors_are_user_friendly(self):
"""Test that validation errors are user-friendly and field-specific."""
self.client.force_login(self.user)
# Invalid data - wrong type (string instead of number in interval)
payload = {"interval": {"start": "not_a_number", "end": 25}}
response = self.client.post(
f"/api/v1/scenario/{self.scenario.uuid}/information/temperature/",
data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
data = response.json()
# Parse the error response - Django Ninja wraps errors in 'detail'
error_str = data.get("detail") or data.get("error")
self.assertIsNotNone(error_str, "Response should contain error details")
# Parse the JSON error string
error_data = json.loads(error_str)
# Check structure
self.assertEqual(error_data.get("type"), "validation_error")
self.assertIn("field_errors", error_data)
self.assertIn("message", error_data)
# Ensure error messages are user-friendly (no Pydantic URLs or technical jargon)
error_str = json.dumps(error_data)
self.assertNotIn("pydantic", error_str.lower())
self.assertNotIn("https://errors.pydantic.dev", error_str)
self.assertNotIn("loc", error_str) # No technical field like 'loc'
# Check that error message is helpful
self.assertIn("Please", error_data["message"]) # User-friendly language
def test_patch_additional_information(self):
"""Test PATCH updates existing additional information."""
self.client.force_login(self.user)
# First create an item
create_payload = {"interval": {"start": 20, "end": 25}}
create_response = self.client.post(
f"/api/v1/scenario/{self.scenario.uuid}/information/temperature/",
data=json.dumps(create_payload),
content_type="application/json",
)
item_uuid = create_response.json()["uuid"]
# Then update it with PATCH
update_payload = {"interval": {"start": 30, "end": 35}}
patch_response = self.client.patch(
f"/api/v1/scenario/{self.scenario.uuid}/information/item/{item_uuid}/",
data=json.dumps(update_payload),
content_type="application/json",
)
self.assertEqual(patch_response.status_code, 200)
data = patch_response.json()
self.assertEqual(data["status"], "updated")
self.assertEqual(data["uuid"], item_uuid) # UUID preserved
# Verify the data was updated
list_response = self.client.get(f"/api/v1/scenario/{self.scenario.uuid}/information/")
items = list_response.json()
self.assertEqual(len(items), 1)
updated_item = items[0]
self.assertEqual(updated_item["uuid"], item_uuid)
self.assertEqual(updated_item["data"]["interval"]["start"], 30)
self.assertEqual(updated_item["data"]["interval"]["end"], 35)
def test_patch_nonexistent_item_returns_404(self):
"""Test PATCH on non-existent item returns 404."""
self.client.force_login(self.user)
fake_uuid = str(uuid4())
payload = {"interval": {"start": 30, "end": 35}}
response = self.client.patch(
f"/api/v1/scenario/{self.scenario.uuid}/information/item/{fake_uuid}/",
data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 404)
def test_patch_with_invalid_data_returns_400(self):
"""Test PATCH with invalid data returns 400."""
self.client.force_login(self.user)
# First create an item
create_payload = {"interval": {"start": 20, "end": 25}}
create_response = self.client.post(
f"/api/v1/scenario/{self.scenario.uuid}/information/temperature/",
data=json.dumps(create_payload),
content_type="application/json",
)
item_uuid = create_response.json()["uuid"]
# Try to update with invalid data
invalid_payload = {"invalid_field": "value"}
patch_response = self.client.patch(
f"/api/v1/scenario/{self.scenario.uuid}/information/item/{item_uuid}/",
data=json.dumps(invalid_payload),
content_type="application/json",
)
self.assertEqual(patch_response.status_code, 400)
def test_patch_validation_errors_are_user_friendly(self):
"""Test that PATCH validation errors are user-friendly and field-specific."""
self.client.force_login(self.user)
# First create an item
create_payload = {"interval": {"start": 20, "end": 25}}
create_response = self.client.post(
f"/api/v1/scenario/{self.scenario.uuid}/information/temperature/",
data=json.dumps(create_payload),
content_type="application/json",
)
item_uuid = create_response.json()["uuid"]
# Update with invalid data - wrong type (string instead of number in interval)
invalid_payload = {"interval": {"start": "not_a_number", "end": 25}}
patch_response = self.client.patch(
f"/api/v1/scenario/{self.scenario.uuid}/information/item/{item_uuid}/",
data=json.dumps(invalid_payload),
content_type="application/json",
)
self.assertEqual(patch_response.status_code, 400)
data = patch_response.json()
# Parse the error response - Django Ninja wraps errors in 'detail'
error_str = data.get("detail") or data.get("error")
self.assertIsNotNone(error_str, "Response should contain error details")
# Parse the JSON error string
error_data = json.loads(error_str)
# Check structure
self.assertEqual(error_data.get("type"), "validation_error")
self.assertIn("field_errors", error_data)
self.assertIn("message", error_data)
# Ensure error messages are user-friendly (no Pydantic URLs or technical jargon)
error_str = json.dumps(error_data)
self.assertNotIn("pydantic", error_str.lower())
self.assertNotIn("https://errors.pydantic.dev", error_str)
self.assertNotIn("loc", error_str) # No technical field like 'loc'
# Check that error message is helpful
self.assertIn("Please", error_data["message"]) # User-friendly language
def test_delete_additional_information(self):
"""Test DELETE removes additional information."""
self.client.force_login(self.user)
# Create an item
create_payload = {"interval": {"start": 20, "end": 25}}
create_response = self.client.post(
f"/api/v1/scenario/{self.scenario.uuid}/information/temperature/",
data=json.dumps(create_payload),
content_type="application/json",
)
item_uuid = create_response.json()["uuid"]
# Delete it
delete_response = self.client.delete(
f"/api/v1/scenario/{self.scenario.uuid}/information/item/{item_uuid}/"
)
self.assertEqual(delete_response.status_code, 200)
data = delete_response.json()
self.assertEqual(data["status"], "deleted")
# Verify deletion
list_response = self.client.get(f"/api/v1/scenario/{self.scenario.uuid}/information/")
items = list_response.json()
self.assertEqual(len(items), 0)
def test_delete_nonexistent_item_returns_404(self):
"""Test DELETE on non-existent item returns 404."""
self.client.force_login(self.user)
fake_uuid = str(uuid4())
response = self.client.delete(
f"/api/v1/scenario/{self.scenario.uuid}/information/item/{fake_uuid}/"
)
self.assertEqual(response.status_code, 404)
def test_multiple_items_crud(self):
"""Test creating, updating, and deleting multiple items."""
self.client.force_login(self.user)
# Create first item
item1_payload = {"interval": {"start": 20, "end": 25}}
response1 = self.client.post(
f"/api/v1/scenario/{self.scenario.uuid}/information/temperature/",
data=json.dumps(item1_payload),
content_type="application/json",
)
item1_uuid = response1.json()["uuid"]
# Create second item (different type if available, or same type)
item2_payload = {"interval": {"start": 30, "end": 35}}
response2 = self.client.post(
f"/api/v1/scenario/{self.scenario.uuid}/information/temperature/",
data=json.dumps(item2_payload),
content_type="application/json",
)
item2_uuid = response2.json()["uuid"]
# Verify both exist
list_response = self.client.get(f"/api/v1/scenario/{self.scenario.uuid}/information/")
items = list_response.json()
self.assertEqual(len(items), 2)
# Update first item
update_payload = {"interval": {"start": 15, "end": 20}}
self.client.patch(
f"/api/v1/scenario/{self.scenario.uuid}/information/item/{item1_uuid}/",
data=json.dumps(update_payload),
content_type="application/json",
)
# Delete second item
self.client.delete(f"/api/v1/scenario/{self.scenario.uuid}/information/item/{item2_uuid}/")
# Verify final state: one item with updated data
list_response = self.client.get(f"/api/v1/scenario/{self.scenario.uuid}/information/")
items = list_response.json()
self.assertEqual(len(items), 1)
self.assertEqual(items[0]["uuid"], item1_uuid)
self.assertEqual(items[0]["data"]["interval"]["start"], 15)
def test_list_info_denied_without_permission(self):
"""User cannot list info for scenario in package they don't have access to"""
self.client.force_login(self.user)
response = self.client.get(f"/api/v1/scenario/{self.other_scenario.uuid}/information/")
self.assertEqual(response.status_code, 403)
def test_add_info_denied_without_permission(self):
"""User cannot add info to scenario in package they don't have access to"""
self.client.force_login(self.user)
payload = {"interval": {"start": 25, "end": 30}}
response = self.client.post(
f"/api/v1/scenario/{self.other_scenario.uuid}/information/temperature/",
json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 403)
def test_update_info_denied_without_permission(self):
"""User cannot update info in scenario they don't have access to"""
self.client.force_login(self.other_user)
# First create an item as other_user
create_payload = {"interval": {"start": 20, "end": 25}}
create_response = self.client.post(
f"/api/v1/scenario/{self.other_scenario.uuid}/information/temperature/",
data=json.dumps(create_payload),
content_type="application/json",
)
item_uuid = create_response.json()["uuid"]
# Try to update as user (who doesn't have access)
self.client.force_login(self.user)
update_payload = {"interval": {"start": 30, "end": 35}}
response = self.client.patch(
f"/api/v1/scenario/{self.other_scenario.uuid}/information/item/{item_uuid}/",
data=json.dumps(update_payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 403)
def test_delete_info_denied_without_permission(self):
"""User cannot delete info from scenario they don't have access to"""
self.client.force_login(self.other_user)
# First create an item as other_user
create_payload = {"interval": {"start": 20, "end": 25}}
create_response = self.client.post(
f"/api/v1/scenario/{self.other_scenario.uuid}/information/temperature/",
data=json.dumps(create_payload),
content_type="application/json",
)
item_uuid = create_response.json()["uuid"]
# Try to delete as user (who doesn't have access)
self.client.force_login(self.user)
response = self.client.delete(
f"/api/v1/scenario/{self.other_scenario.uuid}/information/item/{item_uuid}/"
)
self.assertEqual(response.status_code, 403)
def test_nonexistent_scenario_returns_404(self):
"""Test operations on non-existent scenario return 404."""
self.client.force_login(self.user)
fake_uuid = uuid4()
response = self.client.get(f"/api/v1/scenario/{fake_uuid}/information/")
self.assertEqual(response.status_code, 404)

View File

@ -261,13 +261,6 @@ class GlobalCompoundListPermissionTest(APIPermissionTestBase):
self.assertEqual(response.status_code, 200)
payload = response.json()
# user2 should see compounds from:
# - reviewed_package (public)
# - unreviewed_package_read (READ permission)
# - unreviewed_package_write (WRITE permission)
# - unreviewed_package_all (ALL permission)
# - group_package (via group membership)
# Total: 5 compounds
self.assertEqual(payload["total_items"], 5)
visible_uuids = {item["uuid"] for item in payload["items"]}
@ -303,54 +296,6 @@ class GlobalCompoundListPermissionTest(APIPermissionTestBase):
# user1 owns all packages, so sees all compounds
self.assertEqual(payload["total_items"], 7)
def test_read_permission_allows_viewing(self):
"""READ permission allows viewing compounds."""
self.client.force_login(self.user2)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, 200)
payload = response.json()
# Check that read_compound is included
uuids = [item["uuid"] for item in payload["items"]]
self.assertIn(str(self.read_compound.uuid), uuids)
def test_write_permission_allows_viewing(self):
"""WRITE permission also allows viewing compounds."""
self.client.force_login(self.user2)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, 200)
payload = response.json()
# Check that write_compound is included
uuids = [item["uuid"] for item in payload["items"]]
self.assertIn(str(self.write_compound.uuid), uuids)
def test_all_permission_allows_viewing(self):
"""ALL permission allows viewing compounds."""
self.client.force_login(self.user2)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, 200)
payload = response.json()
# Check that all_compound is included
uuids = [item["uuid"] for item in payload["items"]]
self.assertIn(str(self.all_compound.uuid), uuids)
def test_group_permission_allows_viewing(self):
"""Group membership grants access to group-permitted packages."""
self.client.force_login(self.user2)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, 200)
payload = response.json()
# Check that group_compound is included
uuids = [item["uuid"] for item in payload["items"]]
self.assertIn(str(self.group_compound.uuid), uuids)
@tag("api", "end2end")
class PackageScopedCompoundListPermissionTest(APIPermissionTestBase):

View File

@ -134,7 +134,7 @@ class BaseTestAPIGetPaginated:
f"({self.total_reviewed} <= {self.default_page_size})"
)
response = self.client.get(self.global_endpoint, {"page": 2})
response = self.client.get(self.global_endpoint, {"page": 2, "review_status": True})
self.assertEqual(response.status_code, 200)
payload = response.json()

View File

@ -0,0 +1,301 @@
"""
Tests for Scenario Creation Endpoint Error Handling.
Tests comprehensive error handling for POST /api/v1/package/{uuid}/scenario/
including package not found, permission denied, validation errors, and database errors.
"""
from django.test import TestCase, tag
import json
from uuid import uuid4
from epdb.logic import PackageManager, UserManager
from epdb.models import Scenario
@tag("api", "scenario_creation")
class ScenarioCreationAPITests(TestCase):
"""Test scenario creation endpoint error handling."""
@classmethod
def setUpTestData(cls):
"""Set up test data: users and packages."""
cls.user = UserManager.create_user(
"scenario-test-user",
"scenario-test@envipath.com",
"SuperSafe",
set_setting=False,
add_to_group=False,
is_active=True,
)
cls.other_user = UserManager.create_user(
"other-user",
"other@envipath.com",
"SuperSafe",
set_setting=False,
add_to_group=False,
is_active=True,
)
cls.package = PackageManager.create_package(
cls.user, "Test Package", "Test package for scenario creation"
)
def test_create_scenario_package_not_found(self):
"""Test that non-existent package UUID returns 404."""
self.client.force_login(self.user)
fake_uuid = uuid4()
payload = {
"name": "Test Scenario",
"description": "Test description",
"scenario_date": "2024-01-01",
"scenario_type": "biodegradation",
"additional_information": [],
}
response = self.client.post(
f"/api/v1/package/{fake_uuid}/scenario/",
data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 404)
self.assertIn("Package not found", response.json()["detail"])
def test_create_scenario_insufficient_permissions(self):
"""Test that unauthorized access returns 403."""
self.client.force_login(self.other_user)
payload = {
"name": "Test Scenario",
"description": "Test description",
"scenario_date": "2024-01-01",
"scenario_type": "biodegradation",
"additional_information": [],
}
response = self.client.post(
f"/api/v1/package/{self.package.uuid}/scenario/",
data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 403)
self.assertIn("permission", response.json()["detail"].lower())
def test_create_scenario_invalid_ai_type(self):
"""Test that unknown additional information type returns 400."""
self.client.force_login(self.user)
payload = {
"name": "Test Scenario",
"description": "Test description",
"scenario_date": "2024-01-01",
"scenario_type": "biodegradation",
"additional_information": [
{"type": "invalid_type_that_does_not_exist", "data": {"some_field": "some_value"}}
],
}
response = self.client.post(
f"/api/v1/package/{self.package.uuid}/scenario/",
data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
response_data = response.json()
self.assertIn("Validation errors", response_data["detail"])
def test_create_scenario_validation_error(self):
"""Test that invalid additional information data returns 400."""
self.client.force_login(self.user)
# Use malformed data structure for an actual AI type
payload = {
"name": "Test Scenario",
"description": "Test description",
"scenario_date": "2024-01-01",
"scenario_type": "biodegradation",
"additional_information": [
{
"type": "invalid_type_name",
"data": None, # This should cause a validation error
}
],
}
response = self.client.post(
f"/api/v1/package/{self.package.uuid}/scenario/",
data=json.dumps(payload),
content_type="application/json",
)
# Should return 422 for validation errors
self.assertEqual(response.status_code, 422)
def test_create_scenario_success(self):
"""Test that valid scenario creation returns 200."""
self.client.force_login(self.user)
payload = {
"name": "Test Scenario",
"description": "Test description",
"scenario_date": "2024-01-01",
"scenario_type": "biodegradation",
"additional_information": [],
}
response = self.client.post(
f"/api/v1/package/{self.package.uuid}/scenario/",
data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["name"], "Test Scenario")
self.assertEqual(data["description"], "Test description")
# Verify scenario was actually created
scenario = Scenario.objects.get(name="Test Scenario")
self.assertEqual(scenario.package, self.package)
self.assertEqual(scenario.scenario_type, "biodegradation")
def test_create_scenario_auto_name(self):
"""Test that empty name triggers auto-generation."""
self.client.force_login(self.user)
payload = {
"name": "", # Empty name should be auto-generated
"description": "Test description",
"scenario_date": "2024-01-01",
"scenario_type": "biodegradation",
"additional_information": [],
}
response = self.client.post(
f"/api/v1/package/{self.package.uuid}/scenario/",
data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
data = response.json()
# Auto-generated name should follow pattern "Scenario N"
self.assertTrue(data["name"].startswith("Scenario "))
def test_create_scenario_xss_protection(self):
"""Test that XSS attempts are sanitized."""
self.client.force_login(self.user)
payload = {
"name": "<script>alert('xss')</script>Clean Name",
"description": "<img src=x onerror=alert('xss')>Description",
"scenario_date": "2024-01-01",
"scenario_type": "biodegradation",
"additional_information": [],
}
response = self.client.post(
f"/api/v1/package/{self.package.uuid}/scenario/",
data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
data = response.json()
# XSS should be cleaned out
self.assertNotIn("<script>", data["name"])
self.assertNotIn("onerror", data["description"])
def test_create_scenario_missing_required_field(self):
"""Test that missing required fields returns validation error."""
self.client.force_login(self.user)
# Missing 'name' field entirely
payload = {
"description": "Test description",
"scenario_date": "2024-01-01",
"scenario_type": "biodegradation",
"additional_information": [],
}
response = self.client.post(
f"/api/v1/package/{self.package.uuid}/scenario/",
data=json.dumps(payload),
content_type="application/json",
)
# Should return 422 for schema validation errors
self.assertEqual(response.status_code, 422)
def test_create_scenario_type_error_in_ai(self):
"""Test that TypeError in AI instantiation returns 400."""
self.client.force_login(self.user)
payload = {
"name": "Test Scenario",
"description": "Test description",
"scenario_date": "2024-01-01",
"scenario_type": "biodegradation",
"additional_information": [
{
"type": "invalid_type_name",
"data": "string instead of dict", # Wrong type
}
],
}
response = self.client.post(
f"/api/v1/package/{self.package.uuid}/scenario/",
data=json.dumps(payload),
content_type="application/json",
)
# Should return 422 for validation errors
self.assertEqual(response.status_code, 422)
def test_create_scenario_default_values(self):
"""Test that default values are applied correctly."""
self.client.force_login(self.user)
# Minimal payload with only name
payload = {"name": "Minimal Scenario"}
response = self.client.post(
f"/api/v1/package/{self.package.uuid}/scenario/",
data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["name"], "Minimal Scenario")
# Check defaults are applied
scenario = Scenario.objects.get(name="Minimal Scenario")
# Default description from model is "no description"
self.assertIn(scenario.description.lower(), ["", "no description"])
def test_create_scenario_unicode_characters(self):
"""Test that unicode characters are handled properly."""
self.client.force_login(self.user)
payload = {
"name": "Test Scenario 测试 🧪",
"description": "Description with émojis and spëcial çhars",
"scenario_date": "2024-01-01",
"scenario_type": "biodegradation",
"additional_information": [],
}
response = self.client.post(
f"/api/v1/package/{self.package.uuid}/scenario/",
data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIn("测试", data["name"])
self.assertIn("émojis", data["description"])

View File

@ -0,0 +1,114 @@
"""
Property-based tests for schema generation.
Tests that verify schema generation works correctly for all models,
regardless of their structure.
"""
import pytest
from typing import Type
from pydantic import BaseModel
from envipy_additional_information import registry, EnviPyModel
from epapi.utils.schema_transformers import build_rjsf_output
class TestSchemaGeneration:
"""Test that all models can generate valid RJSF schemas."""
@pytest.mark.parametrize("model_name,model_cls", list(registry.list_models().items()))
def test_all_models_generate_rjsf(self, model_name: str, model_cls: Type[BaseModel]):
"""Every model in the registry should generate valid RJSF format."""
# Skip non-EnviPyModel classes (parsers, etc.)
if not issubclass(model_cls, EnviPyModel):
pytest.skip(f"{model_name} is not an EnviPyModel")
# Should not raise exception
result = build_rjsf_output(model_cls)
# Verify structure
assert isinstance(result, dict), f"{model_name}: Result should be a dict"
assert "schema" in result, f"{model_name}: Missing 'schema' key"
assert "uiSchema" in result, f"{model_name}: Missing 'uiSchema' key"
assert "formData" in result, f"{model_name}: Missing 'formData' key"
assert "groups" in result, f"{model_name}: Missing 'groups' key"
# Verify types
assert isinstance(result["schema"], dict), f"{model_name}: schema should be dict"
assert isinstance(result["uiSchema"], dict), f"{model_name}: uiSchema should be dict"
assert isinstance(result["formData"], dict), f"{model_name}: formData should be dict"
assert isinstance(result["groups"], list), f"{model_name}: groups should be list"
# Verify schema has properties
assert "properties" in result["schema"], f"{model_name}: schema should have 'properties'"
assert isinstance(result["schema"]["properties"], dict), (
f"{model_name}: properties should be dict"
)
@pytest.mark.parametrize("model_name,model_cls", list(registry.list_models().items()))
def test_ui_schema_matches_schema_fields(self, model_name: str, model_cls: Type[BaseModel]):
"""uiSchema keys should match schema properties (or be nested for intervals)."""
if not issubclass(model_cls, EnviPyModel):
pytest.skip(f"{model_name} is not an EnviPyModel")
result = build_rjsf_output(model_cls)
schema_props = set(result["schema"]["properties"].keys())
ui_schema_keys = set(result["uiSchema"].keys())
# uiSchema should have entries for all top-level properties
# (intervals may have nested start/end, but the main field should be present)
assert ui_schema_keys.issubset(schema_props), (
f"{model_name}: uiSchema has keys not in schema: {ui_schema_keys - schema_props}"
)
@pytest.mark.parametrize("model_name,model_cls", list(registry.list_models().items()))
def test_groups_is_list_of_strings(self, model_name: str, model_cls: Type[BaseModel]):
"""Groups should be a list of strings."""
if not issubclass(model_cls, EnviPyModel):
pytest.skip(f"{model_name} is not an EnviPyModel")
result = build_rjsf_output(model_cls)
groups = result["groups"]
assert isinstance(groups, list), f"{model_name}: groups should be list"
assert all(isinstance(g, str) for g in groups), (
f"{model_name}: all groups should be strings, got {groups}"
)
assert len(groups) > 0, f"{model_name}: should have at least one group"
@pytest.mark.parametrize("model_name,model_cls", list(registry.list_models().items()))
def test_form_data_matches_schema(self, model_name: str, model_cls: Type[BaseModel]):
"""formData keys should match schema properties."""
if not issubclass(model_cls, EnviPyModel):
pytest.skip(f"{model_name} is not an EnviPyModel")
result = build_rjsf_output(model_cls)
schema_props = set(result["schema"]["properties"].keys())
form_data_keys = set(result["formData"].keys())
# formData should only contain keys that are in schema
assert form_data_keys.issubset(schema_props), (
f"{model_name}: formData has keys not in schema: {form_data_keys - schema_props}"
)
class TestWidgetTypes:
"""Test that widget types are valid."""
@pytest.mark.parametrize("model_name,model_cls", list(registry.list_models().items()))
def test_widget_types_are_valid(self, model_name: str, model_cls: Type[BaseModel]):
"""All widget types in uiSchema should be valid WidgetType values."""
from envipy_additional_information.ui_config import WidgetType
if not issubclass(model_cls, EnviPyModel):
pytest.skip(f"{model_name} is not an EnviPyModel")
result = build_rjsf_output(model_cls)
valid_widgets = {wt.value for wt in WidgetType}
for field_name, ui_config in result["uiSchema"].items():
widget = ui_config.get("ui:widget")
if widget:
assert widget in valid_widgets, (
f"{model_name}.{field_name}: Invalid widget '{widget}'. Valid: {valid_widgets}"
)

View File

@ -0,0 +1,94 @@
from datetime import timedelta
from django.test import TestCase, tag
from django.utils import timezone
from epdb.logic import PackageManager, UserManager
from epdb.models import APIToken
@tag("api", "auth")
class BearerTokenAuthTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = UserManager.create_user(
"token-user",
"token-user@envipath.com",
"SuperSafe",
set_setting=False,
add_to_group=False,
is_active=True,
)
default_pkg = cls.user.default_package
cls.user.default_package = None
cls.user.save()
if default_pkg:
default_pkg.delete()
cls.unreviewed_package = PackageManager.create_package(
cls.user, "Token Auth Package", "Package for token auth tests"
)
def _auth_header(self, raw_token):
return {"HTTP_AUTHORIZATION": f"Bearer {raw_token}"}
def test_valid_token_allows_access(self):
_, raw_token = APIToken.create_token(self.user, name="Valid Token", expires_days=1)
response = self.client.get("/api/v1/compounds/", **self._auth_header(raw_token))
self.assertEqual(response.status_code, 200)
def test_expired_token_rejected(self):
token, raw_token = APIToken.create_token(self.user, name="Expired Token", expires_days=1)
token.expires_at = timezone.now() - timedelta(days=1)
token.save(update_fields=["expires_at"])
response = self.client.get("/api/v1/compounds/", **self._auth_header(raw_token))
self.assertEqual(response.status_code, 401)
def test_inactive_token_rejected(self):
token, raw_token = APIToken.create_token(self.user, name="Inactive Token", expires_days=1)
token.is_active = False
token.save(update_fields=["is_active"])
response = self.client.get("/api/v1/compounds/", **self._auth_header(raw_token))
self.assertEqual(response.status_code, 401)
def test_invalid_token_rejected(self):
response = self.client.get("/api/v1/compounds/", HTTP_AUTHORIZATION="Bearer invalid-token")
self.assertEqual(response.status_code, 401)
def test_no_token_rejected(self):
self.client.logout()
response = self.client.get("/api/v1/compounds/")
self.assertEqual(response.status_code, 401)
def test_bearer_populates_request_user_for_packages(self):
response = self.client.get("/api/v1/packages/")
self.assertEqual(response.status_code, 200)
payload = response.json()
uuids = {item["uuid"] for item in payload["items"]}
self.assertNotIn(str(self.unreviewed_package.uuid), uuids)
_, raw_token = APIToken.create_token(self.user, name="Package Token", expires_days=1)
response = self.client.get("/api/v1/packages/", **self._auth_header(raw_token))
self.assertEqual(response.status_code, 200)
payload = response.json()
uuids = {item["uuid"] for item in payload["items"]}
self.assertIn(str(self.unreviewed_package.uuid), uuids)
def test_session_auth_still_works_without_bearer(self):
self.client.force_login(self.user)
response = self.client.get("/api/v1/packages/")
self.assertEqual(response.status_code, 200)
payload = response.json()
uuids = {item["uuid"] for item in payload["items"]}
self.assertIn(str(self.unreviewed_package.uuid), uuids)

0
epapi/utils/__init__.py Normal file
View File

View File

@ -0,0 +1,181 @@
"""
Schema transformation utilities for converting Pydantic models to RJSF format.
This module provides functions to extract UI configuration from Pydantic models
and transform them into React JSON Schema Form (RJSF) compatible format.
"""
from typing import Type, Optional, Any
import jsonref
from pydantic import BaseModel
from envipy_additional_information.ui_config import UIConfig
from envipy_additional_information import registry
def extract_groups(model_cls: Type[BaseModel]) -> list[str]:
"""
Extract groups from registry-stored group information.
Args:
model_cls: The model class
Returns:
List of group names the model belongs to
"""
return registry.get_groups(model_cls)
def extract_ui_metadata(model_cls: Type[BaseModel]) -> dict[str, Any]:
"""
Extract model-level UI metadata from UI class.
Returns metadata attributes that are NOT UIConfig instances.
Common metadata includes: unit, description, title.
"""
metadata: dict[str, Any] = {}
if not hasattr(model_cls, "UI"):
return metadata
ui_class = getattr(model_cls, "UI")
# Iterate over all attributes in the UI class
for attr_name in dir(ui_class):
# Skip private attributes
if attr_name.startswith("_"):
continue
# Get the attribute value
try:
attr_value = getattr(ui_class, attr_name)
except AttributeError:
continue
# Skip callables but keep types/classes
if callable(attr_value) and not isinstance(attr_value, type):
continue
# Skip UIConfig instances (these are field-level configs, not metadata)
# This includes both UIConfig and IntervalConfig
if isinstance(attr_value, UIConfig):
continue
metadata[attr_name] = attr_value
return metadata
def extract_ui_config_from_model(model_cls: Type[BaseModel]) -> dict[str, Any]:
"""
Extract UI configuration from model's UI class.
Returns a dictionary mapping field names to their UI schema configurations.
Trusts the config classes to handle their own transformation logic.
"""
ui_configs: dict[str, Any] = {}
if not hasattr(model_cls, "UI"):
return ui_configs
ui_class = getattr(model_cls, "UI")
schema = model_cls.model_json_schema()
field_names = schema.get("properties", {}).keys()
# Extract config for each field
for field_name in field_names:
# Skip if UI config doesn't exist for this field (field may be hidden from UI)
if not hasattr(ui_class, field_name):
continue
ui_config = getattr(ui_class, field_name)
if isinstance(ui_config, UIConfig):
ui_configs[field_name] = ui_config.to_ui_schema_field()
return ui_configs
def build_ui_schema(model_cls: Type[BaseModel]) -> dict:
"""Generate RJSF uiSchema from model's UI class."""
ui_schema = {}
# Extract field-level UI configs
field_configs = extract_ui_config_from_model(model_cls)
for field_name, config in field_configs.items():
ui_schema[field_name] = config
return ui_schema
def build_schema(model_cls: Type[BaseModel]) -> dict[str, Any]:
"""
Build JSON schema from Pydantic model, applying UI metadata.
Dereferences all $ref pointers to produce fully inlined schema.
This ensures the frontend receives schemas with enum values and nested
properties fully resolved, without needing client-side ref resolution.
Extracts model-level metadata from UI class (title, unit, etc.) and applies
it to the generated schema. This ensures UI metadata is the single source of truth.
"""
schema = model_cls.model_json_schema()
# Dereference $ref pointers (inlines $defs) using jsonref
# This ensures the frontend receives schemas with enum values and nested
# properties fully resolved, currently necessary for client-side rendering.
# FIXME: This is a hack to get the schema to work with alpine schema-form.js replace once we migrate to client-side framework.
schema = jsonref.replace_refs(schema, proxies=False)
# Remove $defs section as all refs are now inlined
if "$defs" in schema:
del schema["$defs"]
# Extract and apply UI metadata (title, unit, description, etc.)
ui_metadata = extract_ui_metadata(model_cls)
# Apply all metadata consistently as custom properties with x- prefix
# This ensures consistency and avoids conflicts with standard JSON Schema properties
for key, value in ui_metadata.items():
if value is not None:
schema[f"x-{key}"] = value
# Set standard title property from UI metadata for JSON Schema compliance
if "title" in ui_metadata:
schema["title"] = ui_metadata["title"]
elif "label" in ui_metadata:
schema["title"] = ui_metadata["label"]
return schema
def build_rjsf_output(model_cls: Type[BaseModel], initial_data: Optional[dict] = None) -> dict:
"""
Main function that returns complete RJSF format.
Trusts the config classes to handle their own transformation logic.
No special-case handling - if a config knows how to transform itself, it will.
Returns:
dict with keys: schema, uiSchema, formData, groups
"""
# Build schema with UI metadata applied
schema = build_schema(model_cls)
# Build UI schema - config classes handle their own transformation
ui_schema = build_ui_schema(model_cls)
# Extract groups from marker interfaces
groups = extract_groups(model_cls)
# Use provided initial_data or empty dict
form_data = initial_data if initial_data is not None else {}
return {
"schema": schema,
"uiSchema": ui_schema,
"formData": form_data,
"groups": groups,
}

View File

@ -0,0 +1,82 @@
"""Shared utilities for handling Pydantic validation errors."""
import json
from pydantic import ValidationError
from pydantic_core import ErrorDetails
from ninja.errors import HttpError
def format_validation_error(error: ErrorDetails) -> str:
"""Format a Pydantic validation error into a user-friendly message.
Args:
error: A Pydantic error details dictionary containing 'msg', 'type', 'ctx', etc.
Returns:
A user-friendly error message string.
"""
msg = error.get("msg") or "Invalid value"
error_type = error.get("type") or ""
# Handle common validation types with friendly messages
if error_type == "enum":
ctx = error.get("ctx", {})
expected = ctx.get("expected", "") if ctx else ""
return f"Please select a valid option{': ' + expected if expected else ''}"
elif error_type == "literal_error":
# Literal errors (like Literal["active", "inactive"])
return msg.replace("Input should be ", "Please enter ")
elif error_type == "missing":
return "This field is required"
elif error_type == "string_type":
return "Please enter a valid string"
elif error_type == "int_type":
return "Please enter a valid int"
elif error_type == "int_parsing":
return "Please enter a valid int"
elif error_type == "float_type":
return "Please enter a valid float"
elif error_type == "float_parsing":
return "Please enter a valid float"
elif error_type == "value_error":
# Strip "Value error, " prefix from custom validator messages
return msg.replace("Value error, ", "")
else:
# Default: use the message from Pydantic but clean it up
return msg.replace("Input should be ", "Please enter ").replace("Value error, ", "")
def handle_validation_error(e: ValidationError) -> None:
"""Convert a Pydantic ValidationError into a structured HttpError.
This function transforms Pydantic validation errors into a JSON structure
that the frontend expects for displaying field-level errors.
Args:
e: The Pydantic ValidationError to handle.
Raises:
HttpError: Always raises a 400 error with structured JSON containing
type, field_errors, and message fields.
"""
# Transform Pydantic validation errors into user-friendly format
field_errors: dict[str, list[str]] = {}
for error in e.errors():
# Get the field name from location tuple
loc = error.get("loc", ())
field = str(loc[-1]) if loc else "root"
# Format the error message
friendly_msg = format_validation_error(error)
if field not in field_errors:
field_errors[field] = []
field_errors[field].append(friendly_msg)
# Return structured error for frontend parsing
error_response = {
"type": "validation_error",
"field_errors": field_errors,
"message": "Please correct the errors below",
}
raise HttpError(400, json.dumps(error_response))

View File

@ -1,8 +1,34 @@
import hashlib
from ninja.security import HttpBearer
from ninja.errors import HttpError
from epdb.models import APIToken
class BearerTokenAuth(HttpBearer):
def authenticate(self, request, token):
# FIXME: placeholder; implement it in O(1) time
raise HttpError(401, "Invalid or expired token")
if token is None:
return None
hashed_token = hashlib.sha256(token.encode()).hexdigest()
user = APIToken.authenticate(hashed_token, hashed=True)
if not user:
raise HttpError(401, "Invalid or expired token")
request.user = user
return user
class OptionalBearerTokenAuth:
"""Bearer auth that allows unauthenticated access.
Validates the Bearer token if present (401 on invalid token),
otherwise lets the request through for anonymous/session access.
"""
def __init__(self):
self._bearer = BearerTokenAuth()
def __call__(self, request):
return self._bearer(request) or request.user

View File

@ -1,12 +1,12 @@
from django.db.models import Model
from epdb.logic import PackageManager
from epdb.models import CompoundStructure, User, Package, Compound
from epdb.models import CompoundStructure, User, Package, Compound, Scenario
from uuid import UUID
from .errors import EPAPINotFoundError, EPAPIPermissionDeniedError
def get_compound_or_error(user, compound_uuid: UUID):
def get_compound_for_read(user, compound_uuid: UUID):
"""
Get compound by UUID with permission check.
"""
@ -23,7 +23,7 @@ def get_compound_or_error(user, compound_uuid: UUID):
return compound
def get_package_or_error(user, package_uuid: UUID):
def get_package_for_read(user, package_uuid: UUID):
"""
Get package by UUID with permission check.
"""
@ -41,14 +41,40 @@ def get_package_or_error(user, package_uuid: UUID):
return package
def get_user_packages_qs(user: User | None):
def get_scenario_for_read(user, scenario_uuid: UUID):
"""Get scenario by UUID with read permission check."""
try:
scenario = Scenario.objects.select_related("package").get(uuid=scenario_uuid)
except Scenario.DoesNotExist:
raise EPAPINotFoundError(f"Scenario with UUID {scenario_uuid} not found")
if not user or user.is_anonymous or not PackageManager.readable(user, scenario.package):
raise EPAPIPermissionDeniedError("Insufficient permissions to access this scenario.")
return scenario
def get_scenario_for_write(user, scenario_uuid: UUID):
"""Get scenario by UUID with write permission check."""
try:
scenario = Scenario.objects.select_related("package").get(uuid=scenario_uuid)
except Scenario.DoesNotExist:
raise EPAPINotFoundError(f"Scenario with UUID {scenario_uuid} not found")
if not user or user.is_anonymous or not PackageManager.writable(user, scenario.package):
raise EPAPIPermissionDeniedError("Insufficient permissions to modify this scenario.")
return scenario
def get_user_packages_for_read(user: User | None):
"""Get all packages readable by the user."""
if not user or user.is_anonymous:
return PackageManager.get_reviewed_packages()
return PackageManager.get_all_readable_packages(user, include_reviewed=True)
def get_user_entities_qs(model_class: Model, user: User | None):
def get_user_entities_for_read(model_class: Model, user: User | None):
"""Build queryset for reviewed package entities."""
if not user or user.is_anonymous:
@ -60,16 +86,14 @@ def get_user_entities_qs(model_class: Model, user: User | None):
return qs
def get_package_scoped_entities_qs(
model_class: Model, package_uuid: UUID, user: User | None = None
):
def get_package_entities_for_read(model_class: Model, package_uuid: UUID, user: User | None = None):
"""Build queryset for specific package entities."""
package = get_package_or_error(user, package_uuid)
package = get_package_for_read(user, package_uuid)
qs = model_class.objects.filter(package=package).select_related("package")
return qs
def get_user_structures_qs(user: User | None):
def get_user_structure_for_read(user: User | None):
"""Build queryset for structures accessible to the user (via compound->package)."""
if not user or user.is_anonymous:
@ -83,13 +107,13 @@ def get_user_structures_qs(user: User | None):
return qs
def get_package_compound_scoped_structure_qs(
def get_package_compound_structure_for_read(
package_uuid: UUID, compound_uuid: UUID, user: User | None = None
):
"""Build queryset for specific package compound structures."""
get_package_or_error(user, package_uuid)
compound = get_compound_or_error(user, compound_uuid)
get_package_for_read(user, package_uuid)
compound = get_compound_for_read(user, compound_uuid)
qs = CompoundStructure.objects.filter(compound=compound).select_related("compound__package")
return qs

View File

@ -0,0 +1,174 @@
from ninja import Router, Body
from ninja.errors import HttpError
from uuid import UUID
from pydantic import ValidationError
from typing import Dict, Any
import logging
from envipy_additional_information import registry
from envipy_additional_information.groups import GroupEnum
from epapi.utils.schema_transformers import build_rjsf_output
from epapi.utils.validation_errors import handle_validation_error
from ..dal import get_scenario_for_read, get_scenario_for_write
logger = logging.getLogger(__name__)
router = Router(tags=["Additional Information"])
@router.get("/information/schema/")
def list_all_schemas(request):
"""Return all schemas in RJSF format with lowercase class names as keys."""
result = {}
for name, cls in registry.list_models().items():
try:
result[name] = build_rjsf_output(cls)
except Exception as e:
logger.warning(f"Failed to generate schema for {name}: {e}")
continue
return result
@router.get("/information/schema/{model_name}/")
def get_model_schema(request, model_name: str):
"""Return RJSF schema for specific model."""
cls = registry.get_model(model_name.lower())
if not cls:
raise HttpError(404, f"Unknown model: {model_name}")
return build_rjsf_output(cls)
@router.get("/scenario/{uuid:scenario_uuid}/information/")
def list_scenario_info(request, scenario_uuid: UUID):
"""List all additional information for a scenario"""
scenario = get_scenario_for_read(request.user, scenario_uuid)
result = []
for ai in scenario.get_additional_information():
result.append(
{
"type": ai.__class__.__name__,
"uuid": getattr(ai, "uuid", None),
"data": ai.model_dump(mode="json"),
}
)
return result
@router.post("/scenario/{uuid:scenario_uuid}/information/{model_name}/")
def add_scenario_info(
request, scenario_uuid: UUID, model_name: str, payload: Dict[str, Any] = Body(...)
):
"""Add new additional information to scenario"""
cls = registry.get_model(model_name.lower())
if not cls:
raise HttpError(404, f"Unknown model: {model_name}")
try:
instance = cls(**payload) # Pydantic validates
except ValidationError as e:
handle_validation_error(e)
scenario = get_scenario_for_write(request.user, scenario_uuid)
# Model method now returns the UUID
created_uuid = scenario.add_additional_information(instance)
return {"status": "created", "uuid": created_uuid}
@router.patch("/scenario/{uuid:scenario_uuid}/information/item/{uuid:ai_uuid}/")
def update_scenario_info(
request, scenario_uuid: UUID, ai_uuid: UUID, payload: Dict[str, Any] = Body(...)
):
"""Update existing additional information for a scenario"""
scenario = get_scenario_for_write(request.user, scenario_uuid)
ai_uuid_str = str(ai_uuid)
# Find item to determine type for validation
found_type = None
for type_name, items in scenario.additional_information.items():
if any(item.get("uuid") == ai_uuid_str for item in items):
found_type = type_name
break
if found_type is None:
raise HttpError(404, f"Additional information not found: {ai_uuid}")
# Get the model class for validation
cls = registry.get_model(found_type.lower())
if not cls:
raise HttpError(500, f"Unknown model type in data: {found_type}")
# Validate the payload against the model
try:
instance = cls(**payload)
except ValidationError as e:
handle_validation_error(e)
# Use model method for update
try:
scenario.update_additional_information(ai_uuid_str, instance)
except ValueError as e:
raise HttpError(404, str(e))
return {"status": "updated", "uuid": ai_uuid_str}
@router.delete("/scenario/{uuid:scenario_uuid}/information/item/{uuid:ai_uuid}/")
def delete_scenario_info(request, scenario_uuid: UUID, ai_uuid: UUID):
"""Delete additional information from scenario"""
scenario = get_scenario_for_write(request.user, scenario_uuid)
try:
scenario.remove_additional_information(str(ai_uuid))
except ValueError as e:
raise HttpError(404, str(e))
return {"status": "deleted"}
@router.get("/information/groups/")
def list_groups(request):
"""Return list of available group names."""
return {"groups": GroupEnum.values()}
@router.get("/information/groups/{group_name}/")
def get_group_models(request, group_name: str):
"""
Return models for a specific group organized by subcategory.
Args:
group_name: One of "sludge", "soil", or "sediment" (string)
Returns:
Dictionary with subcategories (exp, spike, comp, misc, or group name)
as keys and lists of model info as values
"""
# Convert string to enum (raises ValueError if invalid)
try:
group_enum = GroupEnum(group_name)
except ValueError:
valid = ", ".join(GroupEnum.values())
raise HttpError(400, f"Invalid group '{group_name}'. Valid: {valid}")
try:
group_data = registry.collect_group(group_enum)
except (ValueError, TypeError) as e:
raise HttpError(400, str(e))
result = {}
for subcategory, models in group_data.items():
result[subcategory] = [
{
"name": cls.__name__.lower(),
"class": cls.__name__,
"title": getattr(cls.UI, "title", cls.__name__)
if hasattr(cls, "UI")
else cls.__name__,
}
for cls in models
]
return result

View File

@ -6,7 +6,7 @@ from uuid import UUID
from epdb.models import Compound
from ..pagination import EnhancedPageNumberPagination
from ..schemas import CompoundOutSchema, ReviewStatusFilter
from ..dal import get_user_entities_qs, get_package_scoped_entities_qs
from ..dal import get_user_entities_for_read, get_package_entities_for_read
router = Router()
@ -21,7 +21,7 @@ def list_all_compounds(request):
"""
List all compounds from reviewed packages.
"""
return get_user_entities_qs(Compound, request.user).order_by("name").all()
return get_user_entities_for_read(Compound, request.user).order_by("name").all()
@router.get(
@ -38,4 +38,4 @@ def list_package_compounds(request, package_uuid: UUID):
List all compounds for a specific package.
"""
user = request.user
return get_package_scoped_entities_qs(Compound, package_uuid, user).order_by("name").all()
return get_package_entities_for_read(Compound, package_uuid, user).order_by("name").all()

View File

@ -6,7 +6,7 @@ from uuid import UUID
from epdb.models import EPModel
from ..pagination import EnhancedPageNumberPagination
from ..schemas import ModelOutSchema, ReviewStatusFilter
from ..dal import get_user_entities_qs, get_package_scoped_entities_qs
from ..dal import get_user_entities_for_read, get_package_entities_for_read
router = Router()
@ -21,7 +21,7 @@ def list_all_models(request):
"""
List all models from reviewed packages.
"""
return get_user_entities_qs(EPModel, request.user).order_by("name").all()
return get_user_entities_for_read(EPModel, request.user).order_by("name").all()
@router.get(
@ -38,4 +38,4 @@ def list_package_models(request, package_uuid: UUID):
List all models for a specific package.
"""
user = request.user
return get_package_scoped_entities_qs(EPModel, package_uuid, user).order_by("name").all()
return get_package_entities_for_read(EPModel, package_uuid, user).order_by("name").all()

View File

@ -3,7 +3,8 @@ from ninja import Router
from ninja_extra.pagination import paginate
import logging
from ..dal import get_user_packages_qs
from ..auth import OptionalBearerTokenAuth
from ..dal import get_user_packages_for_read
from ..pagination import EnhancedPageNumberPagination
from ..schemas import PackageOutSchema, SelfReviewStatusFilter
@ -11,7 +12,11 @@ router = Router()
logger = logging.getLogger(__name__)
@router.get("/packages/", response=EnhancedPageNumberPagination.Output[PackageOutSchema], auth=None)
@router.get(
"/packages/",
response=EnhancedPageNumberPagination.Output[PackageOutSchema],
auth=OptionalBearerTokenAuth(),
)
@paginate(
EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
@ -23,5 +28,5 @@ def list_all_packages(request):
"""
user = request.user
qs = get_user_packages_qs(user)
qs = get_user_packages_for_read(user)
return qs.order_by("name").all()

View File

@ -6,7 +6,7 @@ from uuid import UUID
from epdb.models import Pathway
from ..pagination import EnhancedPageNumberPagination
from ..schemas import PathwayOutSchema, ReviewStatusFilter
from ..dal import get_user_entities_qs, get_package_scoped_entities_qs
from ..dal import get_user_entities_for_read, get_package_entities_for_read
router = Router()
@ -22,7 +22,7 @@ def list_all_pathways(request):
List all pathways from reviewed packages.
"""
user = request.user
return get_user_entities_qs(Pathway, user).order_by("name").all()
return get_user_entities_for_read(Pathway, user).order_by("name").all()
@router.get(
@ -39,4 +39,4 @@ def list_package_pathways(request, package_uuid: UUID):
List all pathways for a specific package.
"""
user = request.user
return get_package_scoped_entities_qs(Pathway, package_uuid, user).order_by("name").all()
return get_package_entities_for_read(Pathway, package_uuid, user).order_by("name").all()

View File

@ -6,7 +6,7 @@ from uuid import UUID
from epdb.models import Reaction
from ..pagination import EnhancedPageNumberPagination
from ..schemas import ReactionOutSchema, ReviewStatusFilter
from ..dal import get_user_entities_qs, get_package_scoped_entities_qs
from ..dal import get_user_entities_for_read, get_package_entities_for_read
router = Router()
@ -22,7 +22,7 @@ def list_all_reactions(request):
List all reactions from reviewed packages.
"""
user = request.user
return get_user_entities_qs(Reaction, user).order_by("name").all()
return get_user_entities_for_read(Reaction, user).order_by("name").all()
@router.get(
@ -39,4 +39,4 @@ def list_package_reactions(request, package_uuid: UUID):
List all reactions for a specific package.
"""
user = request.user
return get_package_scoped_entities_qs(Reaction, package_uuid, user).order_by("name").all()
return get_package_entities_for_read(Reaction, package_uuid, user).order_by("name").all()

View File

@ -6,7 +6,7 @@ from uuid import UUID
from epdb.models import Rule
from ..pagination import EnhancedPageNumberPagination
from ..schemas import ReviewStatusFilter, RuleOutSchema
from ..dal import get_user_entities_qs, get_package_scoped_entities_qs
from ..dal import get_user_entities_for_read, get_package_entities_for_read
router = Router()
@ -22,7 +22,7 @@ def list_all_rules(request):
List all rules from reviewed packages.
"""
user = request.user
return get_user_entities_qs(Rule, user).order_by("name").all()
return get_user_entities_for_read(Rule, user).order_by("name").all()
@router.get(
@ -39,4 +39,4 @@ def list_package_rules(request, package_uuid: UUID):
List all rules for a specific package.
"""
user = request.user
return get_package_scoped_entities_qs(Rule, package_uuid, user).order_by("name").all()
return get_package_entities_for_read(Rule, package_uuid, user).order_by("name").all()

View File

@ -1,12 +1,26 @@
from django.conf import settings as s
from ninja import Router
from django.db import IntegrityError, OperationalError, DatabaseError
from ninja import Router, Body
from ninja.errors import HttpError
from ninja_extra.pagination import paginate
from uuid import UUID
from pydantic import ValidationError
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 ReviewStatusFilter, ScenarioOutSchema
from ..dal import get_user_entities_qs, get_package_scoped_entities_qs
from ..schemas import (
ScenarioOutSchema,
ScenarioCreateSchema,
ScenarioReviewStatusAndRelatedFilter,
)
from ..dal import get_user_entities_for_read, get_package_entities_for_read
from envipy_additional_information import registry
logger = logging.getLogger(__name__)
router = Router()
@ -15,11 +29,12 @@ router = Router()
@paginate(
EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
filter_schema=ReviewStatusFilter,
filter_schema=ScenarioReviewStatusAndRelatedFilter,
)
def list_all_scenarios(request):
user = request.user
return get_user_entities_qs(Scenario, user).order_by("name").all()
items = get_user_entities_for_read(Scenario, user)
return items.order_by("name").all()
@router.get(
@ -29,8 +44,87 @@ def list_all_scenarios(request):
@paginate(
EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
filter_schema=ReviewStatusFilter,
filter_schema=ScenarioReviewStatusAndRelatedFilter,
)
def list_package_scenarios(request, package_uuid: UUID):
user = request.user
return get_package_scoped_entities_qs(Scenario, package_uuid, user).order_by("name").all()
items = get_package_entities_for_read(Scenario, package_uuid, user)
return items.order_by("name").all()
@router.post("/package/{uuid:package_uuid}/scenario/", response=ScenarioOutSchema)
def create_scenario(request, package_uuid: UUID, payload: ScenarioCreateSchema = Body(...)):
"""Create a new scenario with optional additional information."""
user = _anonymous_or_real(request)
try:
current_package = PackageManager.get_package_by_id(user, package_uuid)
except ValueError as e:
error_msg = str(e)
if "does not exist" in error_msg:
raise HttpError(404, f"Package not found: {package_uuid}")
elif "Insufficient permissions" in error_msg:
raise HttpError(403, "You do not have permission to access this package")
else:
logger.error(f"Unexpected ValueError from get_package_by_id: {error_msg}")
raise HttpError(400, "Invalid package request")
# Build additional information models from payload
additional_information_models = []
validation_errors = []
for ai_item in payload.additional_information:
# Get model class from registry
model_cls = registry.get_model(ai_item.type.lower())
if not model_cls:
validation_errors.append(f"Unknown additional information type: {ai_item.type}")
continue
try:
# Validate and create model instance
instance = model_cls(**ai_item.data)
additional_information_models.append(instance)
except ValidationError as e:
# Collect validation errors to return to user
error_messages = [err.get("msg", "Validation error") for err in e.errors()]
validation_errors.append(f"{ai_item.type}: {', '.join(error_messages)}")
except (TypeError, AttributeError, KeyError) as e:
logger.warning(f"Failed to instantiate {ai_item.type} model: {str(e)}")
validation_errors.append(f"{ai_item.type}: Invalid data structure - {str(e)}")
except Exception as e:
logger.error(f"Unexpected error instantiating {ai_item.type}: {str(e)}")
validation_errors.append(f"{ai_item.type}: Failed to process - please check your data")
# If there are validation errors, return them
if validation_errors:
raise HttpError(
400,
json.dumps(
{
"error": "Validation errors in additional information",
"details": validation_errors,
}
),
)
# Create scenario using the existing Scenario.create method
try:
new_scenario = Scenario.create(
package=current_package,
name=payload.name,
description=payload.description,
scenario_date=payload.scenario_date,
scenario_type=payload.scenario_type,
additional_information=additional_information_models,
)
except IntegrityError as e:
logger.error(f"Database integrity error creating scenario: {str(e)}")
raise HttpError(400, "Scenario creation failed - data constraint violation")
except OperationalError as e:
logger.error(f"Database operational error creating scenario: {str(e)}")
raise HttpError(503, "Database temporarily unavailable - please try again")
except (DatabaseError, AttributeError) as e:
logger.error(f"Error creating scenario: {str(e)}")
raise HttpError(500, "Failed to create scenario due to database error")
return new_scenario

View File

@ -0,0 +1,23 @@
from django.conf import settings as s
from ninja import Router
from ninja_extra.pagination import paginate
from epdb.logic import SettingManager
from ..pagination import EnhancedPageNumberPagination
from ..schemas import SettingOutSchema
router = Router()
@router.get("/settings/", response=EnhancedPageNumberPagination.Output[SettingOutSchema])
@paginate(
EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
)
def list_all_pathways(request):
"""
List all pathways from reviewed packages.
"""
user = request.user
return SettingManager.get_all_settings(user)

View File

@ -6,8 +6,8 @@ from uuid import UUID
from ..pagination import EnhancedPageNumberPagination
from ..schemas import CompoundStructureOutSchema, StructureReviewStatusFilter
from ..dal import (
get_user_structures_qs,
get_package_compound_scoped_structure_qs,
get_user_structure_for_read,
get_package_compound_structure_for_read,
)
router = Router()
@ -26,7 +26,7 @@ def list_all_structures(request):
List all structures from all packages.
"""
user = request.user
return get_user_structures_qs(user).order_by("name").all()
return get_user_structure_for_read(user).order_by("name").all()
@router.get(
@ -44,7 +44,7 @@ def list_package_structures(request, package_uuid: UUID, compound_uuid: UUID):
"""
user = request.user
return (
get_package_compound_scoped_structure_qs(package_uuid, compound_uuid, user)
get_package_compound_structure_for_read(package_uuid, compound_uuid, user)
.order_by("name")
.all()
)

View File

@ -1,7 +1,19 @@
from ninja import Router
from ninja.security import SessionAuth
from .auth import BearerTokenAuth
from .endpoints import packages, scenarios, compounds, rules, reactions, pathways, models, structure
from .endpoints import (
packages,
scenarios,
compounds,
rules,
reactions,
pathways,
models,
structure,
additional_information,
settings,
)
# Main router with authentication
router = Router(
@ -20,3 +32,5 @@ router.add_router("", reactions.router)
router.add_router("", pathways.router)
router.add_router("", models.router)
router.add_router("", structure.router)
router.add_router("", additional_information.router)
router.add_router("", settings.router)

View File

@ -1,5 +1,5 @@
from ninja import FilterSchema, FilterLookup, Schema
from typing import Annotated, Optional
from typing import Annotated, Optional, List, Dict, Any
from uuid import UUID
@ -22,6 +22,12 @@ class StructureReviewStatusFilter(FilterSchema):
review_status: Annotated[Optional[bool], FilterLookup("compound__package__reviewed")] = None
class ScenarioReviewStatusAndRelatedFilter(ReviewStatusFilter):
"""Filter schema for review_status and parent query parameter."""
exclude_related: Annotated[Optional[bool], FilterLookup("parent__isnull")] = None
# Base schema for all package-scoped entities
class PackageEntityOutSchema(Schema):
"""Base schema for entities belonging to a package."""
@ -51,6 +57,23 @@ class ScenarioOutSchema(PackageEntityOutSchema):
pass
class AdditionalInformationItemSchema(Schema):
"""Schema for additional information item in scenario creation."""
type: str
data: Dict[str, Any]
class ScenarioCreateSchema(Schema):
"""Schema for creating a new scenario."""
name: str
description: str = ""
scenario_date: str = "No date"
scenario_type: str = "Not specified"
additional_information: List[AdditionalInformationItemSchema] = []
class CompoundOutSchema(PackageEntityOutSchema):
pass
@ -102,3 +125,10 @@ class PackageOutSchema(Schema):
@staticmethod
def resolve_review_status(obj):
return "reviewed" if obj.reviewed else "unreviewed"
class SettingOutSchema(Schema):
uuid: UUID
url: str = ""
name: str
description: str

View File

@ -28,7 +28,7 @@ Package = s.GET_PACKAGE_MODEL()
class UserAdmin(admin.ModelAdmin):
list_display = ["username", "email", "is_active"]
list_display = ["username", "email", "is_active", "is_staff", "is_superuser"]
class UserPackagePermissionAdmin(admin.ModelAdmin):
@ -48,7 +48,7 @@ class JobLogAdmin(admin.ModelAdmin):
class EPAdmin(admin.ModelAdmin):
search_fields = ["name", "description"]
search_fields = ["name", "description", "url", "uuid"]
list_display = ["name", "url", "created"]
ordering = ["-created"]

View File

@ -2,20 +2,12 @@ from typing import List
from django.contrib.auth import get_user_model
from ninja import Router, Schema, Field
from ninja.errors import HttpError
from ninja.pagination import paginate
from ninja.security import HttpBearer
from epapi.v1.auth import BearerTokenAuth
from .logic import PackageManager
from .models import User, Compound, APIToken
class BearerTokenAuth(HttpBearer):
def authenticate(self, request, token):
for token_obj in APIToken.objects.select_related("user").all():
if token_obj.check_token(token) and token_obj.is_valid():
return token_obj.user
raise HttpError(401, "Invalid or expired token")
from .models import User, Compound
def _anonymous_or_real(request):

View File

@ -5,27 +5,33 @@ from django.conf import settings as s
from django.contrib.auth import get_user_model
from django.http import HttpResponse
from django.shortcuts import redirect
from ninja import Field, Form, Router, Schema, Query
from ninja import Field, Form, Query, Router, Schema
from ninja.security import SessionAuth
from utilities.chem import FormatConverter
from utilities.misc import PackageExporter
from .logic import GroupManager, PackageManager, SettingManager, UserManager, SearchManager
from .logic import GroupManager, PackageManager, SearchManager, SettingManager, UserManager
from .models import (
Compound,
CompoundStructure,
Edge,
EnviFormer,
EPModel,
Group,
GroupPackagePermission,
MLRelativeReasoning,
Node,
PackageBasedModel,
ParallelRule,
Pathway,
Reaction,
Rule,
RuleBasedRelativeReasoning,
Scenario,
SimpleAmbitRule,
User,
UserPackagePermission,
ParallelRule,
)
Package = s.GET_PACKAGE_MODEL()
@ -200,6 +206,82 @@ def get_user(request, user_uuid):
}
########
# Group #
########
class GroupMember(Schema):
id: str
identifier: str
name: str
class GroupWrapper(Schema):
group: List[SimpleGroup]
class GroupSchema(Schema):
description: str
id: str = Field(None, alias="url")
identifier: str = "group"
members: List[GroupMember] = Field([], alias="members")
name: str = Field(None, alias="name")
ownerid: str = Field(None, alias="owner.url")
ownername: str = Field(None, alias="owner.get_name")
packages: List["SimplePackage"] = Field([], alias="packages")
readers: List[GroupMember] = Field([], alias="readers")
writers: List[GroupMember] = Field([], alias="writers")
@staticmethod
def resolve_members(obj: Group):
res = []
for member in obj.user_member.all():
res.append(GroupMember(id=member.url, identifier="usermember", name=member.get_name()))
for member in obj.group_member.all():
res.append(GroupMember(id=member.url, identifier="groupmember", name=member.get_name()))
return res
@staticmethod
def resolve_packages(obj: Group):
return Package.objects.filter(
id__in=[
GroupPackagePermission.objects.filter(group=obj).values_list(
"package_id", flat=True
)
]
)
@staticmethod
def resolve_readers(obj: Group):
return GroupSchema.resolve_members(obj)
@staticmethod
def resolve_writers(obj: Group):
return [GroupMember(id=obj.owner.url, identifier="usermember", name=obj.owner.username)]
@router.get("/group", response={200: GroupWrapper, 403: Error})
def get_groups(request):
return {"group": GroupManager.get_groups(request.user)}
@router.get("/group/{uuid:group_uuid}", response={200: GroupSchema, 403: Error})
def get_group(request, group_uuid):
try:
g = GroupManager.get_group_by_id(request.user, group_uuid)
return g
except ValueError:
return 403, {
"message": f"Getting Group with id {group_uuid} failed due to insufficient rights!"
}
##########
# Search #
##########
class Search(Schema):
packages: List[str] = Field(alias="packages[]")
search: str
@ -237,11 +319,11 @@ def search(request, search: Query[Search]):
if "Compound Structures" in search_res:
res["structure"] = search_res["Compound Structures"]
if "Reaction" in search_res:
res["reaction"] = search_res["Reaction"]
if "Reactions" in search_res:
res["reaction"] = search_res["Reactions"]
if "Pathway" in search_res:
res["pathway"] = search_res["Pathway"]
if "Pathways" in search_res:
res["pathway"] = search_res["Pathways"]
if "Rules" in search_res:
res["rule"] = search_res["Rules"]
@ -292,7 +374,7 @@ class PackageSchema(Schema):
).values_list("user", flat=True)
).distinct()
return [{u.id: u.name} for u in users]
return [{u.id: u.get_name()} for u in users]
@staticmethod
def resolve_writers(obj: Package):
@ -302,7 +384,7 @@ class PackageSchema(Schema):
).values_list("user", flat=True)
).distinct()
return [{u.id: u.name} for u in users]
return [{u.id: u.get_name()} for u in users]
@staticmethod
def resolve_review_comment(obj):
@ -884,7 +966,12 @@ def create_package_simple_rule(
raise ValueError("Not yet implemented!")
else:
sr = SimpleAmbitRule.create(
p, r.name, r.description, r.smirks, r.reactantFilterSmarts, r.productFilterSmarts
p,
r.name,
r.description,
r.smirks,
r.reactantFilterSmarts,
r.productFilterSmarts,
)
return redirect(sr.url)
@ -1037,7 +1124,7 @@ class ReactionSchema(Schema):
name: str = Field(None, alias="name")
pathways: List["SimplePathway"] = Field([], alias="related_pathways")
products: List["ReactionCompoundStructure"] = Field([], alias="products")
references: List[Dict[str, List[str]]] = Field([], alias="references")
references: Dict[str, List[str]] = Field({}, alias="references")
reviewStatus: str = Field(None, alias="review_status")
scenarios: List["SimpleScenario"] = Field([], alias="scenarios")
smirks: str = Field("", alias="smirks")
@ -1053,8 +1140,12 @@ class ReactionSchema(Schema):
@staticmethod
def resolve_references(obj: Reaction):
# TODO
return []
rhea_refs = []
for rhea in obj.get_rhea_identifiers():
rhea_refs.append(f"{rhea.identifier_value}")
# TODO UniProt
return {"rheaReferences": rhea_refs, "uniprotCount": []}
@staticmethod
def resolve_medline_references(obj: Reaction):
@ -1447,6 +1538,7 @@ def create_pathway(
setting = SettingManager.get_setting_by_url(request.user, pw.selectedSetting)
new_pw.setting = setting
new_pw.kv.update({"status": "running"})
new_pw.save()
from .tasks import dispatch, predict
@ -1632,7 +1724,7 @@ class EdgeSchema(Schema):
id: str = Field(None, alias="url")
identifier: str = "edge"
name: str = Field(None, alias="name")
reactionName: str = Field(None, alias="edge_label.name")
reactionName: str = Field(None, alias="edge_label.get_name")
reactionURI: str = Field(None, alias="edge_label.url")
reviewStatus: str = Field(None, alias="review_status")
scenarios: List["SimpleScenario"] = Field([], alias="scenarios")
@ -1681,7 +1773,7 @@ class CreateEdge(Schema):
@router.post(
"/package/{uuid:package_uuid}/üathway/{uuid:pathway_uuid}/edge",
"/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}/edge",
response={200: str | Any, 403: Error},
)
def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
@ -1700,10 +1792,26 @@ def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
if e.edgeAsSmirks:
for ed in e.edgeAsSmirks.split(">>")[0].split("\\."):
educts.append(Node.objects.get(pathway=pw, default_node_label__smiles=ed))
stand_ed = FormatConverter.standardize(ed, remove_stereo=True)
educts.append(
Node.objects.get(
pathway=pw,
default_node_label=CompoundStructure.objects.get(
compound__package=p, smiles=stand_ed
).compound.default_structure,
)
)
for pr in e.edgeAsSmirks.split(">>")[1].split("\\."):
products.append(Node.objects.get(pathway=pw, default_node_label__smiles=pr))
stand_pr = FormatConverter.standardize(pr, remove_stereo=True)
products.append(
Node.objects.get(
pathway=pw,
default_node_label=CompoundStructure.objects.get(
compound__package=p, smiles=stand_pr
).compound.default_structure,
)
)
else:
for ed in e.educts.split(","):
educts.append(Node.objects.get(pathway=pw, url=ed.strip()))
@ -1716,7 +1824,7 @@ def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
start_nodes=educts,
end_nodes=products,
rule=None,
name=e.name,
name=None,
description=e.edgeReason,
)
@ -1753,26 +1861,46 @@ class ModelWrapper(Schema):
class ModelSchema(Schema):
aliases: List[str] = Field([], alias="aliases")
description: str = Field(None, alias="description")
evalPackages: List["SimplePackage"] = Field([])
evalPackages: List["SimplePackage"] = Field([], alias="eval_packages")
id: str = Field(None, alias="url")
identifier: str = "relative-reasoning"
# "info" : {
# "Accuracy (Single-Gen)" : "0.5932962678936605" ,
# "Area under PR-Curve (Single-Gen)" : "0.5654653182134282" ,
# "Area under ROC-Curve (Single-Gen)" : "0.8178302405034772" ,
# "Precision (Single-Gen)" : "0.6978730822873083" ,
# "Probability Threshold" : "0.5" ,
# "Recall/Sensitivity (Single-Gen)" : "0.4484149210261006"
# } ,
info: dict = Field({}, alias="info")
name: str = Field(None, alias="name")
pathwayPackages: List["SimplePackage"] = Field([])
pathwayPackages: List["SimplePackage"] = Field([], alias="pathway_packages")
reviewStatus: str = Field(None, alias="review_status")
rulePackages: List["SimplePackage"] = Field([])
rulePackages: List["SimplePackage"] = Field([], alias="rule_packages")
scenarios: List["SimpleScenario"] = Field([], alias="scenarios")
status: str
statusMessage: str
threshold: str
type: str
status: str = Field(None, alias="model_status")
statusMessage: str = Field(None, alias="status_message")
threshold: str = Field(None, alias="threshold")
type: str = Field(None, alias="model_type")
@staticmethod
def resolve_info(obj: EPModel):
return {}
@staticmethod
def resolve_status_message(obj: EPModel):
for k, v in PackageBasedModel.PROGRESS_STATUS_CHOICES.items():
if k == obj.model_status:
return v
return None
@staticmethod
def resolve_threshold(obj: EPModel):
return f"{obj.threshold:.2f}"
@staticmethod
def resolve_model_type(obj: EPModel):
if isinstance(obj, RuleBasedRelativeReasoning):
return "RULEBASED"
elif isinstance(obj, MLRelativeReasoning):
return "ECC"
elif isinstance(obj, EnviFormer):
return "ENVIFORMER"
else:
return None
@router.get("/model", response={200: ModelWrapper, 403: Error})
@ -1833,7 +1961,7 @@ def get_model(request, package_uuid, model_uuid, c: Query[Classify]):
if pr.rule:
res["id"] = pr.rule.url
res["identifier"] = pr.rule.get_rule_identifier()
res["name"] = pr.rule.name
res["name"] = pr.rule.get_name()
res["reviewStatus"] = (
"reviewed" if pr.rule.package.reviewed else "unreviewed"
)

View File

@ -194,8 +194,6 @@ class UserManager(object):
if clean_username != username or clean_email != email:
# This will be caught by the try in view.py/register
raise ValueError("Invalid username or password")
# avoid circular import :S
from .tasks import send_registration_mail
extra_fields = {"is_active": not s.ADMIN_APPROVAL_REQUIRED}
@ -214,10 +212,6 @@ class UserManager(object):
u.default_package = p
u.save()
if not u.is_active:
# send email for verification
send_registration_mail.delay(u.pk)
if set_setting:
u.default_setting = Setting.objects.get(global_default=True)
u.save()
@ -685,7 +679,7 @@ class PackageManager(object):
ai_data = json.loads(res.model_dump_json())
ai_data["uuid"] = f"{uuid4()}"
new_add_inf[res_cls_name].append(ai_data)
except ValidationError:
except (ValidationError, ValueError):
logger.error(f"Failed to convert {name} with {addinf_data}")
scen.additional_information = new_add_inf

View File

@ -8,7 +8,6 @@ from epdb.logic import UserManager, GroupManager, PackageManager, SettingManager
from epdb.models import (
UserSettingPermission,
MLRelativeReasoning,
EnviFormer,
Permission,
User,
ExternalDatabase,
@ -231,7 +230,6 @@ class Command(BaseCommand):
package=pack,
rule_packages=[mapping["EAWAG-BBD"]],
data_packages=[mapping["EAWAG-BBD"]],
eval_packages=[],
threshold=0.5,
name="ECC - BBD - T0.5",
description="ML Relative Reasoning",
@ -239,7 +237,3 @@ class Command(BaseCommand):
ml_model.build_dataset()
ml_model.build_model()
# If available, create EnviFormerModel
if s.ENVIFORMER_PRESENT:
EnviFormer.create(pack, "EnviFormer - T0.5", "EnviFormer Model with Threshold 0.5", 0.5)

View File

@ -0,0 +1,92 @@
from django.conf import settings as s
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from epdb.models import APIToken
class Command(BaseCommand):
help = "Create an API token for a user"
def add_arguments(self, parser):
parser.add_argument(
"--username",
required=True,
help="Username of the user who will own the token",
)
parser.add_argument(
"--name",
required=True,
help="Descriptive name for the token",
)
parser.add_argument(
"--expires-days",
type=int,
default=90,
help="Days until expiration (0 for no expiration)",
)
parser.add_argument(
"--inactive",
action="store_true",
help="Create the token as inactive",
)
parser.add_argument(
"--curl",
action="store_true",
help="Print a curl example using the token",
)
parser.add_argument(
"--base-url",
default=None,
help="Base URL for curl example (default SERVER_URL or http://localhost:8000)",
)
parser.add_argument(
"--endpoint",
default="/api/v1/compounds/",
help="Endpoint path for curl example",
)
def handle(self, *args, **options):
username = options["username"]
name = options["name"]
expires_days = options["expires_days"]
if expires_days < 0:
raise CommandError("--expires-days must be >= 0")
if expires_days == 0:
expires_days = None
user_model = get_user_model()
try:
user = user_model.objects.get(username=username)
except user_model.DoesNotExist as exc:
raise CommandError(f"User not found for username '{username}'") from exc
token, raw_token = APIToken.create_token(user, name=name, expires_days=expires_days)
if options["inactive"]:
token.is_active = False
token.save(update_fields=["is_active"])
self.stdout.write(f"User: {user.username} ({user.email})")
self.stdout.write(f"Token name: {token.name}")
self.stdout.write(f"Token id: {token.id}")
if token.expires_at:
self.stdout.write(f"Expires at: {token.expires_at.isoformat()}")
else:
self.stdout.write("Expires at: never")
self.stdout.write(f"Active: {token.is_active}")
self.stdout.write("Raw token:")
self.stdout.write(raw_token)
if options["curl"]:
base_url = (
options["base_url"] or getattr(s, "SERVER_URL", None) or "http://localhost:8000"
)
endpoint = options["endpoint"]
endpoint = endpoint if endpoint.startswith("/") else f"/{endpoint}"
url = f"{base_url.rstrip('/')}{endpoint}"
curl_cmd = f'curl -H "Authorization: Bearer {raw_token}" "{url}"'
self.stdout.write("Curl:")
self.stdout.write(curl_cmd)

View File

@ -47,7 +47,7 @@ class Command(BaseCommand):
"description": model.description,
"kv": model.kv,
"data_packages_uuids": [str(p.uuid) for p in model.data_packages.all()],
"eval_packages_uuids": [str(p.uuid) for p in model.data_packages.all()],
"eval_packages_uuids": [str(p.uuid) for p in model.eval_packages.all()],
"threshold": model.threshold,
"eval_results": model.eval_results,
"multigen_eval": model.multigen_eval,

View File

@ -0,0 +1,17 @@
# Generated by Django 5.2.7 on 2026-01-19 19:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("epdb", "0014_rename_expansion_schema_setting_expansion_scheme"),
]
operations = [
migrations.AddField(
model_name="user",
name="is_reviewer",
field=models.BooleanField(default=False),
),
]

View File

@ -72,10 +72,14 @@ class User(AbstractUser):
null=True,
blank=False,
)
is_reviewer = models.BooleanField(default=False)
USERNAME_FIELD = "email"
REQUIRED_FIELDS = ["username"]
def get_name(self):
return self.username
def save(self, *args, **kwargs):
if not self.url:
self.url = self._url()
@ -166,17 +170,18 @@ class APIToken(TimeStampedModel):
return token, raw_key
@classmethod
def authenticate(cls, raw_key: str) -> Optional[User]:
def authenticate(cls, token: str, *, hashed: bool = False) -> Optional[User]:
"""
Authenticate a user using an API token.
Args:
raw_key: Raw token key
token: Raw token key or SHA-256 hash (when hashed=True)
hashed: Whether the token is already hashed
Returns:
User if token is valid, None otherwise
"""
hashed_key = hashlib.sha256(raw_key.encode()).hexdigest()
hashed_key = token if hashed else hashlib.sha256(token.encode()).hexdigest()
try:
token = cls.objects.select_related("user").get(hashed_key=hashed_key)
@ -207,7 +212,10 @@ class Group(TimeStampedModel):
)
def __str__(self):
return f"{self.name} (pk={self.pk})"
return f"{self.get_name()} (pk={self.pk})"
def get_name(self):
return self.name
def save(self, *args, **kwargs):
if not self.url:
@ -595,7 +603,7 @@ class EnviPathModel(TimeStampedModel):
res = {
"url": self.url,
"uuid": str(self.uuid),
"name": self.name,
"name": self.get_name(),
}
if include_description:
@ -608,11 +616,14 @@ class EnviPathModel(TimeStampedModel):
return self.kv.get(k, default)
return default
def get_name(self):
return self.name
class Meta:
abstract = True
def __str__(self):
return f"{self.name} (pk={self.pk})"
return f"{self.get_name()} (pk={self.pk})"
class AliasMixin(models.Model):
@ -623,7 +634,7 @@ class AliasMixin(models.Model):
@transaction.atomic
def add_alias(self, new_alias, set_as_default=False):
if set_as_default:
self.aliases.append(self.name)
self.aliases.append(self.get_name())
self.name = new_alias
if new_alias in self.aliases:
@ -764,15 +775,17 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin
logger.debug(
f"#Structures: {num_structs} - #Standardized SMILES: {len(stand_smiles)}"
)
logger.debug(f"Couldn't infer normalized structure for {self.name} - {self.url}")
logger.debug(
f"Couldn't infer normalized structure for {self.get_name()} - {self.url}"
)
raise ValueError(
f"Couldn't find nor infer normalized structure for {self.name} ({self.url})"
f"Couldn't find nor infer normalized structure for {self.get_name()} ({self.url})"
)
else:
cs = CompoundStructure.create(
self,
stand_smiles.pop(),
name="Normalized structure of {}".format(self.name),
name="Normalized structure of {}".format(self.get_name()),
description="{} (in its normalized form)".format(self.description),
normalized_structure=True,
)
@ -847,8 +860,10 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin
if name is not None:
# Clean for potential XSS
name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
if name is None or name == "":
name = f"Compound {Compound.objects.filter(package=package).count() + 1}"
c.name = name
# We have a default here only set the value if it carries some payload
@ -977,7 +992,7 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin
cs = CompoundStructure.create(
existing_normalized_compound,
structure.smiles,
name=structure.name,
name=structure.get_name(),
description=structure.description,
normalized_structure=structure.normalized_structure,
)
@ -988,13 +1003,13 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin
else:
raise ValueError(
f"Found a CompoundStructure for {default_structure_smiles} but not for {normalized_structure_smiles} in target package {target.name}"
f"Found a CompoundStructure for {default_structure_smiles} but not for {normalized_structure_smiles} in target package {target.get_name()}"
)
else:
# Here we can safely use Compound.objects.create as we won't end up in a duplicate
new_compound = Compound.objects.create(
package=target,
name=self.name,
name=self.get_name(),
description=self.description,
kv=self.kv.copy() if self.kv else {},
)
@ -1010,7 +1025,7 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin
canonical_smiles=structure.canonical_smiles,
inchikey=structure.inchikey,
normalized_structure=structure.normalized_structure,
name=structure.name,
name=structure.get_name(),
description=structure.description,
kv=structure.kv.copy() if structure.kv else {},
)
@ -1049,11 +1064,8 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin
def half_lifes(self):
hls: Dict[Scenario, List[HalfLife]] = defaultdict(list)
for n in self.related_nodes:
for scen in n.scenarios.all().order_by("name"):
for ai in scen.get_additional_information():
if isinstance(ai, HalfLife):
hls[scen].append(ai)
for cs in self.structures:
hls.update(cs.half_lifes())
return dict(hls)
@ -1103,6 +1115,7 @@ class CompoundStructure(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdenti
# Clean for potential XSS
if name is not None:
cs.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
if description is not None:
cs.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
@ -1146,6 +1159,21 @@ class CompoundStructure(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdenti
def is_default_structure(self):
return self.compound.default_structure == self
@property
def related_nodes(self):
return Node.objects.filter(node_labels__in=[self], pathway__package=self.compound.package)
def half_lifes(self):
hls: Dict[Scenario, List[HalfLife]] = defaultdict(list)
for n in self.related_nodes:
for scen in n.scenarios.all().order_by("name"):
for ai in scen.get_additional_information():
if isinstance(ai, HalfLife):
hls[scen].append(ai)
return dict(hls)
class EnzymeLink(EnviPathModel, KEGGIdentifierMixin):
rule = models.ForeignKey("Rule", on_delete=models.CASCADE, db_index=True)
@ -1217,7 +1245,7 @@ class Rule(PolymorphicModel, EnviPathModel, AliasMixin, ScenarioMixin):
if rule_type == SimpleAmbitRule:
new_rule = SimpleAmbitRule.create(
package=target,
name=self.name,
name=self.get_name(),
description=self.description,
smirks=self.smirks,
reactant_filter_smarts=self.reactant_filter_smarts,
@ -1231,7 +1259,7 @@ class Rule(PolymorphicModel, EnviPathModel, AliasMixin, ScenarioMixin):
elif rule_type == SimpleRDKitRule:
new_rule = SimpleRDKitRule.create(
package=target,
name=self.name,
name=self.get_name(),
description=self.description,
reaction_smarts=self.reaction_smarts,
)
@ -1249,7 +1277,7 @@ class Rule(PolymorphicModel, EnviPathModel, AliasMixin, ScenarioMixin):
new_rule = ParallelRule.create(
package=target,
simple_rules=new_srs,
name=self.name,
name=self.get_name(),
description=self.description,
)
@ -1353,12 +1381,13 @@ class SimpleAmbitRule(SimpleRule):
def get_rule_identifier(self) -> str:
return "simple-rule"
def apply(self, smiles):
def apply(self, smiles, *args, **kwargs):
return FormatConverter.apply(
smiles,
self.smirks,
reactant_filter_smarts=self.reactant_filter_smarts,
product_filter_smarts=self.product_filter_smarts,
**kwargs,
)
@property
@ -1388,8 +1417,8 @@ class SimpleAmbitRule(SimpleRule):
class SimpleRDKitRule(SimpleRule):
reaction_smarts = models.TextField(blank=False, null=False, verbose_name="SMIRKS")
def apply(self, smiles):
return FormatConverter.apply(smiles, self.reaction_smarts)
def apply(self, smiles, *args, **kwargs):
return FormatConverter.apply(smiles, self.reaction_smarts, **kwargs)
def _url(self):
return "{}/simple-rdkit-rule/{}".format(self.package.url, self.uuid)
@ -1410,10 +1439,10 @@ class ParallelRule(Rule):
def srs(self) -> QuerySet:
return self.simple_rules.all()
def apply(self, structure):
def apply(self, structure, *args, **kwargs):
res = list()
for simple_rule in self.srs:
res.extend(simple_rule.apply(structure))
res.extend(simple_rule.apply(structure, **kwargs))
return list(set(res))
@ -1518,11 +1547,11 @@ class SequentialRule(Rule):
def srs(self):
return self.simple_rules.all()
def apply(self, structure):
def apply(self, structure, *args, **kwargs):
# TODO determine levels or see java implementation
res = set()
for simple_rule in self.srs:
res.union(set(simple_rule.apply(structure)))
res.union(set(simple_rule.apply(structure, **kwargs)))
return res
@ -1622,9 +1651,11 @@ class Reaction(EnviPathModel, AliasMixin, ScenarioMixin, ReactionIdentifierMixin
r = Reaction()
r.package = package
# Clean for potential XSS
if name is not None and name.strip() != "":
r.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
if description is not None and name.strip() != "":
r.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
@ -1672,7 +1703,7 @@ class Reaction(EnviPathModel, AliasMixin, ScenarioMixin, ReactionIdentifierMixin
new_reaction = Reaction.create(
package=target,
name=self.name,
name=self.get_name(),
description=self.description,
educts=copied_reaction_educts,
products=copied_reaction_products,
@ -1827,8 +1858,10 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
queue.append(prod)
# We shouldn't lose or make up nodes...
assert len(nodes) == len(self.nodes)
logger.debug(f"{self.name}: Num Nodes {len(nodes)} vs. DB Nodes {len(self.nodes)}")
if len(nodes) != len(self.nodes):
logger.debug(
f"{self.get_name()}: Num Nodes {len(nodes)} vs. DB Nodes {len(self.nodes)}"
)
links = [e.d3_json() for e in self.edges]
@ -1879,6 +1912,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
"source": pseudo_idx,
"target": node_url_to_idx[target],
"app_domain": link.get("app_domain", None),
"multi_step": link["multi_step"],
}
adjusted_links.append(new_link)
@ -1895,7 +1929,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
"isIncremental": self.kv.get("mode") == "incremental",
"isPredicted": self.kv.get("mode") == "predicted",
"lastModified": self.modified.strftime("%Y-%m-%d %H:%M:%S"),
"pathwayName": self.name,
"pathwayName": self.get_name(),
"reviewStatus": "reviewed" if self.package.reviewed else "unreviewed",
"scenarios": [],
"upToDate": True,
@ -1938,14 +1972,14 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
if include_pathway_url:
row.append(n.pathway.url)
row += [cs.smiles, cs.name, n.depth]
row += [cs.smiles, cs.get_name(), n.depth]
edges = self.edges.filter(end_nodes__in=[n])
if len(edges):
for e in edges:
_row = row.copy()
_row.append(e.kv.get("probability"))
_row.append(",".join([r.name for r in e.edge_label.rules.all()]))
_row.append(",".join([r.get_name() for r in e.edge_label.rules.all()]))
_row.append(",".join([r.url for r in e.edge_label.rules.all()]))
_row.append(e.start_nodes.all()[0].default_node_label.smiles)
rows.append(_row)
@ -1976,12 +2010,14 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
if name is not None:
# Clean for potential XSS
name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
if name is None or name == "":
name = f"Pathway {Pathway.objects.filter(package=package).count() + 1}"
pw.name = name
if description is not None and description.strip() != "":
pw.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
pw.predicted = predicted
pw.save()
@ -2006,7 +2042,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
# deduplicated
new_pathway = Pathway.objects.create(
package=target,
name=self.name,
name=self.get_name(),
description=self.description,
setting=self.setting, # TODO copy settings?
kv=self.kv.copy() if self.kv else {},
@ -2036,7 +2072,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
pathway=new_pathway,
default_node_label=copied_structure,
depth=node.depth,
name=node.name,
name=node.get_name(),
description=node.description,
kv=node.kv.copy() if node.kv else {},
)
@ -2060,7 +2096,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
new_edge = Edge.objects.create(
pathway=new_pathway,
edge_label=copied_reaction,
name=edge.name,
name=edge.get_name(),
description=edge.description,
kv=edge.kv.copy() if edge.kv else {},
)
@ -2120,6 +2156,18 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin):
def _url(self):
return "{}/node/{}".format(self.pathway.url, self.uuid)
def get_name(self):
non_generic_name = True
if self.name == "no name":
non_generic_name = False
return (
self.name
if non_generic_name
else f"{self.default_node_label.name} (taken from underlying structure)"
)
def d3_json(self):
app_domain_data = self.get_app_domain_assessment_data()
@ -2132,9 +2180,9 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin):
"image_svg": IndigoUtils.mol_to_svg(
self.default_node_label.smiles, width=40, height=40
),
"name": self.default_node_label.name,
"name": self.get_name(),
"smiles": self.default_node_label.smiles,
"scenarios": [{"name": s.name, "url": s.url} for s in self.scenarios.all()],
"scenarios": [{"name": s.get_name(), "url": s.url} for s in self.scenarios.all()],
"app_domain": {
"inside_app_domain": app_domain_data["assessment"]["inside_app_domain"]
if app_domain_data
@ -2142,9 +2190,11 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin):
"uncovered_functional_groups": False,
},
"is_engineered_intermediate": self.kv.get("is_engineered_intermediate", False),
"timeseries": self.get_timeseries_data(),
}
@staticmethod
@transaction.atomic
def create(
pathway: "Pathway",
smiles: str,
@ -2178,14 +2228,24 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin):
def as_svg(self):
return IndigoUtils.mol_to_svg(self.default_node_label.smiles)
def get_timeseries_data(self):
for scenario in self.scenarios.all():
for ai in scenario.get_additional_information():
if ai.__class__.__name__ == "OECD301FTimeSeries":
return ai.model_dump(mode="json")
return None
def get_app_domain_assessment_data(self):
data = self.kv.get("app_domain_assessment", None)
if data:
rule_ids = defaultdict(list)
for e in Edge.objects.filter(start_nodes__in=[self]):
for r in e.edge_label.rules.all():
rule_ids[str(r.uuid)].append(e.simple_json())
# TODO While the Pathway is being predicted we sometimes
# TODO receive 'NoneType' object has no attribute 'rules'
if e.edge_label:
for r in e.edge_label.rules.all():
rule_ids[str(r.uuid)].append(e.simple_json())
for t in data["assessment"]["transformations"]:
if t["rule"]["uuid"] in rule_ids:
@ -2198,7 +2258,7 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin):
res = super().simple_json()
name = res.get("name", None)
if name == "no name":
res["name"] = self.default_node_label.name
res["name"] = self.default_node_label.get_name()
return res
@ -2222,17 +2282,24 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin):
def d3_json(self):
edge_json = {
"name": self.name,
"name": self.get_name(),
"id": self.url,
"url": self.url,
"image": self.url + "?image=svg",
"reaction": {"name": self.edge_label.name, "url": self.edge_label.url}
"reaction": {
"name": self.edge_label.get_name(),
"url": self.edge_label.url,
"rules": [
{"name": r.get_name(), "url": r.url} for r in self.edge_label.rules.all()
],
}
if self.edge_label
else None,
"multi_step": self.edge_label.multi_step if self.edge_label else False,
"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.name, "url": s.url} for s in self.scenarios.all()],
"scenarios": [{"name": s.get_name(), "url": s.url} for s in self.scenarios.all()],
}
for n in self.start_nodes.all():
@ -2269,6 +2336,7 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin):
return edge_json
@staticmethod
@transaction.atomic
def create(
pathway,
start_nodes: List[Node],
@ -2320,10 +2388,22 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin):
res = super().simple_json()
name = res.get("name", None)
if name == "no name":
res["name"] = self.edge_label.name
res["name"] = self.edge_label.get_name()
return res
def get_name(self):
non_generic_name = True
if self.name == "no name":
non_generic_name = False
return (
self.name
if non_generic_name
else f"{self.edge_label.name} (taken from underlying reaction)"
)
class EPModel(PolymorphicModel, EnviPathModel):
package = models.ForeignKey(
@ -2604,7 +2684,7 @@ class PackageBasedModel(EPModel):
root_compounds.append(pw.root_nodes[0].default_node_label)
else:
logger.info(
f"Skipping MG Eval of Pathway {pw.name} ({pw.uuid}) as it has no root compounds!"
f"Skipping MG Eval of Pathway {pw.get_name()} ({pw.uuid}) as it has no root compounds!"
)
# As we need a Model Instance in our setting, get a fresh copy from db, overwrite the serialized mode and
@ -2730,7 +2810,7 @@ class PackageBasedModel(EPModel):
pathways.append(pathway)
else:
logging.warning(
f"No root compound in pathway {pathway.name}, excluding from multigen evaluation"
f"No root compound in pathway {pathway.get_name()}, excluding from multigen evaluation"
)
# build lookup reaction -> {uuid1, uuid2} for overlap check
@ -3062,7 +3142,7 @@ class ApplicabilityDomain(EnviPathModel):
ad = ApplicabilityDomain()
ad.model = mlrr
# ad.uuid = mlrr.uuid
ad.name = f"AD for {mlrr.name}"
ad.name = f"AD for {mlrr.get_name()}"
ad.num_neighbours = num_neighbours
ad.reliability_threshold = reliability_threshold
ad.local_compatibilty_threshold = local_compatibility_threshold
@ -3346,7 +3426,7 @@ class EnviFormer(PackageBasedModel):
)
for smiles in smiles_list
]
logger.info(f"Submitting {canon_smiles} to {self.name}")
logger.info(f"Submitting {canon_smiles} to {self.get_name()}")
start = datetime.now()
products_list = self.model.predict_batch(canon_smiles)
end = datetime.now()
@ -3503,7 +3583,7 @@ class EnviFormer(PackageBasedModel):
root_node = p.root_nodes
if len(root_node) > 1:
logging.warning(
f"Pathway {p.name} has more than one root compound, only {root_node[0]} will be used"
f"Pathway {p.get_name()} has more than one root compound, only {root_node[0]} will be used"
)
root_node = ".".join(
[
@ -3623,7 +3703,7 @@ class EnviFormer(PackageBasedModel):
pathways.append(pathway)
else:
logging.warning(
f"No root compound in pathway {pathway.name}, excluding from multigen evaluation"
f"No root compound in pathway {pathway.get_name()}, excluding from multigen evaluation"
)
# build lookup reaction -> {uuid1, uuid2} for overlap check
@ -3751,11 +3831,21 @@ class Scenario(EnviPathModel):
return new_s
@transaction.atomic
def add_additional_information(self, data: "EnviPyModel"):
def add_additional_information(self, data: "EnviPyModel") -> str:
"""
Add additional information to this scenario.
Args:
data: EnviPyModel instance to add
Returns:
str: UUID of the created item
"""
cls_name = data.__class__.__name__
# Clean for potential XSS hidden in the additional information fields.
ai_data = json.loads(nh3.clean(data.model_dump_json()).strip())
ai_data["uuid"] = f"{uuid4()}"
generated_uuid = str(uuid4())
ai_data["uuid"] = generated_uuid
if cls_name not in self.additional_information:
self.additional_information[cls_name] = []
@ -3763,6 +3853,51 @@ class Scenario(EnviPathModel):
self.additional_information[cls_name].append(ai_data)
self.save()
return generated_uuid
@transaction.atomic
def update_additional_information(self, ai_uuid: str, data: "EnviPyModel") -> None:
"""
Update existing additional information by UUID.
Args:
ai_uuid: UUID of the item to update
data: EnviPyModel instance with new data
Raises:
ValueError: If item with given UUID not found or type mismatch
"""
found_type = None
found_idx = -1
# Find the item by UUID
for type_name, items in self.additional_information.items():
for idx, item_data in enumerate(items):
if item_data.get("uuid") == ai_uuid:
found_type = type_name
found_idx = idx
break
if found_type:
break
if found_type is None:
raise ValueError(f"Additional information with UUID {ai_uuid} not found")
# Verify the model type matches (prevent type changes)
new_type = data.__class__.__name__
if new_type != found_type:
raise ValueError(
f"Cannot change type from {found_type} to {new_type}. "
f"Delete and create a new item instead."
)
# Update the item data, preserving UUID
ai_data = json.loads(nh3.clean(data.model_dump_json()).strip())
ai_data["uuid"] = ai_uuid
self.additional_information[found_type][found_idx] = ai_data
self.save()
@transaction.atomic
def remove_additional_information(self, ai_uuid):
found_type = None
@ -3777,9 +3912,9 @@ class Scenario(EnviPathModel):
if found_type is not None and found_idx >= 0:
if len(self.additional_information[found_type]) == 1:
del self.additional_information[k]
del self.additional_information[found_type]
else:
self.additional_information[k].pop(found_idx)
self.additional_information[found_type].pop(found_idx)
self.save()
else:
raise ValueError(f"Could not find additional information with uuid {ai_uuid}")
@ -3802,7 +3937,7 @@ class Scenario(EnviPathModel):
self.save()
def get_additional_information(self):
from envipy_additional_information import NAME_MAPPING
from envipy_additional_information import registry
for k, vals in self.additional_information.items():
if k == "enzyme":
@ -3810,14 +3945,31 @@ class Scenario(EnviPathModel):
for v in vals:
# Per default additional fields are ignored
MAPPING = {c.__name__: c for c in NAME_MAPPING.values()}
inst = MAPPING[k](**v)
MAPPING = {c.__name__: c for c in registry.list_models().values()}
try:
inst = MAPPING[k](**v)
except Exception as e:
logger.error(f"Could not load additional information {k}: {e}")
if s.SENTRY_ENABLED:
from sentry_sdk import capture_exception
capture_exception(e)
# Add uuid to uniquely identify objects for manipulation
if "uuid" in v:
inst.__dict__["uuid"] = v["uuid"]
yield inst
def related_pathways(self):
scens = [self]
if self.parent is not None:
scens.append(self.parent)
return Pathway.objects.filter(
scenarios__in=scens, package__reviewed=True, package=self.package
).distinct()
class UserSettingPermission(Permission):
uuid = models.UUIDField(
@ -4024,6 +4176,6 @@ class JobLog(TimeStampedModel):
return self.task_result
def is_result_downloadable(self):
downloadable = ["batch_predict"]
downloadable = ["batch_predict", "identify_missing_rules"]
return self.job_name in downloadable

View File

@ -7,6 +7,7 @@ from uuid import uuid4
from celery import shared_task
from celery.utils.functional import LRUCache
from django.conf import settings as s
from django.core.mail import EmailMultiAlternatives
from django.utils import timezone
from epdb.logic import SPathway
@ -73,7 +74,31 @@ def predict_simple(model_pk: int, smiles: str):
@shared_task(queue="background")
def send_registration_mail(user_pk: int):
pass
u = User.objects.get(id=user_pk)
tpl = """Welcome {username}!,
Thank you for your interest in enviPath.
The public system is intended for non-commercial use only.
We will review your account details and usually activate your account within 24 hours.
Once activated, you will be notified by email.
If we have any questions, we will contact you at this email address.
Best regards,
enviPath team"""
msg = EmailMultiAlternatives(
"Your enviPath account",
tpl.format(username=u.username),
"admin@envipath.org",
[u.email],
bcc=["admin@envipath.org"],
)
msg.send(fail_silently=False)
@shared_task(bind=True, queue="model")

View File

@ -209,5 +209,4 @@ urlpatterns = [
re_path(r"^contact$", v.static_contact_support, name="contact_support"),
re_path(r"^careers$", v.static_careers, name="careers"),
re_path(r"^cite$", v.static_cite, name="cite"),
re_path(r"^legal$", v.static_legal, name="legal"),
]

View File

@ -1,22 +1,21 @@
import json
import logging
from typing import Any, Dict, List
from datetime import datetime
from typing import Any, Dict, List
import nh3
from django.conf import settings as s
from django.contrib.auth import get_user_model
from django.core.exceptions import BadRequest, PermissionDenied
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed, JsonResponse
from django.shortcuts import redirect, render
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.views.decorators.csrf import csrf_exempt
from envipy_additional_information import NAME_MAPPING
from oauth2_provider.decorators import protected_resource
from sentry_sdk import capture_exception
from utilities.chem import FormatConverter, IndigoUtils
from utilities.decorators import package_permission_required
from utilities.misc import HTMLGenerator
from .logic import (
EPDBURLParser,
@ -34,6 +33,7 @@ from .models import (
EnviFormer,
EnzymeLink,
EPModel,
ExpansionSchemeChoice,
ExternalDatabase,
ExternalIdentifier,
Group,
@ -51,7 +51,6 @@ from .models import (
SimpleAmbitRule,
User,
UserPackagePermission,
ExpansionSchemeChoice,
)
logger = logging.getLogger(__name__)
@ -238,6 +237,15 @@ def register(request):
try:
u = UserManager.create_user(username, email, password)
logger.info(f"Created user {u.username} ({u.pk})")
try:
from .tasks import send_registration_mail
send_registration_mail.delay(u.pk)
except Exception as e:
logger.error(f"Failed to send registration mail to {u.email}: {e}")
capture_exception(e)
except Exception:
context["message"] = "Registration failed! Couldn't create User Account."
return render(request, "static/login.html", context)
@ -339,7 +347,7 @@ def breadcrumbs(
{"Package": s.SERVER_URL + "/package"},
]
if first_level_object is not None:
bread.append({first_level_object.name: first_level_object.url})
bread.append({first_level_object.get_name(): first_level_object.url})
if second_level_namespace is not None:
bread.append(
@ -350,7 +358,7 @@ def breadcrumbs(
)
if second_level_object is not None:
bread.append({second_level_object.name: second_level_object.url})
bread.append({second_level_object.get_name(): second_level_object.url})
if third_level_namespace is not None:
bread.append(
@ -361,7 +369,7 @@ def breadcrumbs(
)
if third_level_object is not None:
bread.append({third_level_object.name: third_level_object.url})
bread.append({third_level_object.get_name(): third_level_object.url})
return bread
@ -462,7 +470,7 @@ def package_predict_pathway(request, package_uuid):
current_package = PackageManager.get_package_by_id(current_user, package_uuid)
context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - Predict Pathway"
context["title"] = f"enviPath - {current_package.get_name()} - Predict Pathway"
context["meta"]["current_package"] = current_package
return render(request, "predict_pathway.html", context)
@ -475,6 +483,10 @@ def packages(request):
context = get_base_context(request)
context["title"] = "enviPath - Packages"
context["meta"]["current_package"] = context["meta"]["user"].default_package
context["breadcrumbs"] = [
{"Home": s.SERVER_URL},
{"Package": s.SERVER_URL + "/package"},
]
# Context for paginated template
context["entity_type"] = "package"
@ -529,6 +541,10 @@ def compounds(request):
context = get_base_context(request)
context["title"] = "enviPath - Compounds"
context["meta"]["current_package"] = context["meta"]["user"].default_package
context["breadcrumbs"] = [
{"Home": s.SERVER_URL},
{"Compound": s.SERVER_URL + "/compound"},
]
# Context for paginated template
context["entity_type"] = "compound"
@ -759,7 +775,7 @@ def package_models(request, package_uuid):
if request.method == "GET":
context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - Models"
context["title"] = f"enviPath - {current_package.get_name()} - Models"
context["meta"]["current_package"] = current_package
context["object_type"] = "model"
@ -781,7 +797,7 @@ def package_models(request, package_uuid):
return JsonResponse(
{
"objects": [
{"name": pw.name, "url": pw.url, "reviewed": current_package.reviewed}
{"name": pw.get_name(), "url": pw.url, "reviewed": current_package.reviewed}
for pw in (
reviewed_model_qs if current_package.reviewed else unreviewed_model_qs
)
@ -880,7 +896,7 @@ def package_models(request, package_uuid):
def package_model(request, package_uuid, model_uuid):
current_user = _anonymous_or_real(request)
current_package = PackageManager.get_package_by_id(current_user, package_uuid)
current_model = EPModel.objects.get(package=current_package, uuid=model_uuid)
current_model = get_object_or_404(EPModel, package=current_package, uuid=model_uuid)
if request.method == "GET":
classify = request.GET.get("classify", False)
@ -931,7 +947,7 @@ def package_model(request, package_uuid, model_uuid):
return JsonResponse(app_domain_assessment, safe=False)
context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - {current_model.name}"
context["title"] = f"enviPath - {current_package.get_name()} - {current_model.get_name()}"
context["meta"]["current_package"] = current_package
context["object_type"] = "model"
@ -1009,7 +1025,7 @@ def package(request, package_uuid):
if request.method == "GET":
if request.GET.get("export", False) == "true":
filename = f"{current_package.name.replace(' ', '_')}_{current_package.uuid}.json"
filename = f"{current_package.get_name().replace(' ', '_')}_{current_package.uuid}.json"
pack_json = PackageManager.export_package(
current_package, include_models=False, include_external_identifiers=False
)
@ -1019,7 +1035,7 @@ def package(request, package_uuid):
return response
context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name}"
context["title"] = f"enviPath - {current_package.get_name()}"
context["meta"]["current_package"] = current_package
context["object_type"] = "package"
@ -1033,6 +1049,7 @@ def package(request, package_uuid):
"user_id", flat=True
)
)
users = users.filter(is_active=True)
group_perms = GroupPackagePermission.objects.filter(package=current_package)
groups = Group.objects.exclude(
@ -1056,7 +1073,7 @@ def package(request, package_uuid):
if current_user.default_package == current_package:
return error(
request,
f'Package "{current_package.name}" is the default and cannot be deleted!',
f'Package "{current_package.get_name()}" is the default and cannot be deleted!',
"You cannot delete the default package. If you want to delete this package you have to set another default package first.",
)
@ -1154,7 +1171,7 @@ def package_compounds(request, package_uuid):
if request.method == "GET":
context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - Compounds"
context["title"] = f"enviPath - {current_package.get_name()} - Compounds"
context["meta"]["current_package"] = current_package
context["object_type"] = "compound"
@ -1179,7 +1196,7 @@ def package_compounds(request, package_uuid):
return JsonResponse(
{
"objects": [
{"name": pw.name, "url": pw.url, "reviewed": current_package.reviewed}
{"name": pw.get_name(), "url": pw.url, "reviewed": current_package.reviewed}
for pw in (
reviewed_compound_qs
if current_package.reviewed
@ -1212,11 +1229,13 @@ def package_compounds(request, package_uuid):
def package_compound(request, package_uuid, compound_uuid):
current_user = _anonymous_or_real(request)
current_package = PackageManager.get_package_by_id(current_user, package_uuid)
current_compound = Compound.objects.get(package=current_package, uuid=compound_uuid)
current_compound = get_object_or_404(Compound, package=current_package, uuid=compound_uuid)
if request.method == "GET":
context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - {current_compound.name}"
context["title"] = (
f"enviPath - {current_package.get_name()} - {current_compound.get_name()}"
)
context["meta"]["current_package"] = current_package
context["object_type"] = "compound"
@ -1300,7 +1319,7 @@ def package_compound_structures(request, package_uuid, compound_uuid):
if request.method == "GET":
context = get_base_context(request)
context["title"] = (
f"enviPath - {current_package.name} - {current_compound.name} - Structures"
f"enviPath - {current_package.get_name()} - {current_compound.get_name()} - Structures"
)
context["meta"]["current_package"] = current_package
@ -1309,7 +1328,7 @@ def package_compound_structures(request, package_uuid, compound_uuid):
current_package, "compound", current_compound, "structure"
)
context["entity_type"] = "structure"
context["page_title"] = f"{current_compound.name} - Structures"
context["page_title"] = f"{current_compound.get_name()} - Structures"
context["api_endpoint"] = (
f"/api/v1/package/{current_package.uuid}/compound/{current_compound.uuid}/structure/"
)
@ -1346,15 +1365,23 @@ def package_compound_structures(request, package_uuid, compound_uuid):
def package_compound_structure(request, package_uuid, compound_uuid, structure_uuid):
current_user = _anonymous_or_real(request)
current_package = PackageManager.get_package_by_id(current_user, package_uuid)
current_compound = Compound.objects.get(package=current_package, uuid=compound_uuid)
current_structure = CompoundStructure.objects.get(
compound=current_compound, uuid=structure_uuid
current_compound = get_object_or_404(Compound, package=current_package, uuid=compound_uuid)
current_structure = get_object_or_404(
CompoundStructure, compound=current_compound, uuid=structure_uuid
)
if request.method == "GET":
is_image_request = request.GET.get("image")
if is_image_request:
if is_image_request == "svg":
return HttpResponse(current_structure.as_svg, content_type="image/svg+xml")
else:
return HttpResponseBadRequest("Currently only SVG as image formate supported!")
context = get_base_context(request)
context["title"] = (
f"enviPath - {current_package.name} - {current_compound.name} - {current_structure.name}"
f"enviPath - {current_package.get_name()} - {current_compound.get_name()} - {current_structure.get_name()}"
)
context["meta"]["current_package"] = current_package
@ -1460,7 +1487,7 @@ def package_rules(request, package_uuid):
if request.method == "GET":
context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - Rules"
context["title"] = f"enviPath - {current_package.get_name()} - Rules"
context["meta"]["current_package"] = current_package
context["object_type"] = "rule"
@ -1482,7 +1509,7 @@ def package_rules(request, package_uuid):
return JsonResponse(
{
"objects": [
{"name": pw.name, "url": pw.url, "reviewed": current_package.reviewed}
{"name": pw.get_name(), "url": pw.url, "reviewed": current_package.reviewed}
for pw in (
reviewed_rule_qs if current_package.reviewed else unreviewed_rule_qs
)
@ -1534,7 +1561,7 @@ def package_rules(request, package_uuid):
def package_rule(request, package_uuid, rule_uuid):
current_user = _anonymous_or_real(request)
current_package = PackageManager.get_package_by_id(current_user, package_uuid)
current_rule = Rule.objects.get(package=current_package, uuid=rule_uuid)
current_rule = get_object_or_404(Rule, package=current_package, uuid=rule_uuid)
if request.method == "GET":
context = get_base_context(request)
@ -1572,7 +1599,7 @@ def package_rule(request, package_uuid, rule_uuid):
content_type="image/svg+xml",
)
context["title"] = f"enviPath - {current_package.name} - {current_rule.name}"
context["title"] = f"enviPath - {current_package.get_name()} - {current_rule.get_name()}"
context["meta"]["current_package"] = current_package
context["object_type"] = "rule"
@ -1645,7 +1672,7 @@ def package_rule_enzymelink(request, package_uuid, rule_uuid, enzymelink_uuid):
if request.method == "GET":
context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - {current_rule.name}"
context["title"] = f"enviPath - {current_package.get_name()} - {current_rule.get_name()}"
context["meta"]["current_package"] = current_package
context["object_type"] = "enzyme"
@ -1668,7 +1695,7 @@ def package_reactions(request, package_uuid):
if request.method == "GET":
context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - Reactions"
context["title"] = f"enviPath - {current_package.get_name()} - Reactions"
context["meta"]["current_package"] = current_package
context["object_type"] = "reaction"
@ -1692,7 +1719,7 @@ def package_reactions(request, package_uuid):
return JsonResponse(
{
"objects": [
{"name": pw.name, "url": pw.url, "reviewed": current_package.reviewed}
{"name": pw.get_name(), "url": pw.url, "reviewed": current_package.reviewed}
for pw in (
reviewed_reaction_qs
if current_package.reviewed
@ -1729,11 +1756,13 @@ def package_reactions(request, package_uuid):
def package_reaction(request, package_uuid, reaction_uuid):
current_user = _anonymous_or_real(request)
current_package = PackageManager.get_package_by_id(current_user, package_uuid)
current_reaction = Reaction.objects.get(package=current_package, uuid=reaction_uuid)
current_reaction = get_object_or_404(Reaction, package=current_package, uuid=reaction_uuid)
if request.method == "GET":
context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - {current_reaction.name}"
context["title"] = (
f"enviPath - {current_package.get_name()} - {current_reaction.get_name()}"
)
context["meta"]["current_package"] = current_package
context["object_type"] = "reaction"
@ -1816,7 +1845,7 @@ def package_pathways(request, package_uuid):
if request.method == "GET":
context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - Pathways"
context["title"] = f"enviPath - {current_package.get_name()} - Pathways"
context["meta"]["current_package"] = current_package
context["object_type"] = "pathway"
@ -1838,7 +1867,7 @@ def package_pathways(request, package_uuid):
return JsonResponse(
{
"objects": [
{"name": pw.name, "url": pw.url, "reviewed": current_package.reviewed}
{"name": pw.get_name(), "url": pw.url, "reviewed": current_package.reviewed}
for pw in (
reviewed_pathway_qs
if current_package.reviewed
@ -1908,6 +1937,7 @@ def package_pathways(request, package_uuid):
limit = 1
pw.setting = prediction_setting
pw.kv.update({"status": "running"})
pw.save()
from .tasks import dispatch, predict
@ -1924,7 +1954,9 @@ def package_pathways(request, package_uuid):
def package_pathway(request, package_uuid, pathway_uuid):
current_user: User = _anonymous_or_real(request)
current_package: Package = PackageManager.get_package_by_id(current_user, package_uuid)
current_pathway: Pathway = Pathway.objects.get(package=current_package, uuid=pathway_uuid)
current_pathway: Pathway = get_object_or_404(
Pathway, package=current_package, uuid=pathway_uuid
)
if request.method == "GET":
if request.GET.get("last_modified", False):
@ -1942,7 +1974,7 @@ def package_pathway(request, package_uuid, pathway_uuid):
)
if request.GET.get("download", False) == "true":
filename = f"{current_pathway.name.replace(' ', '_')}_{current_pathway.uuid}.csv"
filename = f"{current_pathway.get_name().replace(' ', '_')}_{current_pathway.uuid}.csv"
csv_pw = current_pathway.to_csv()
response = HttpResponse(csv_pw, content_type="text/csv")
response["Content-Disposition"] = f'attachment; filename="{filename}"'
@ -1962,7 +1994,7 @@ def package_pathway(request, package_uuid, pathway_uuid):
current_user, identify_missing_rules, [current_pathway.pk], rule_package.pk
)
filename = f"{current_pathway.name.replace(' ', '_')}_{current_pathway.uuid}.csv"
filename = f"{current_pathway.get_name().replace(' ', '_')}_{current_pathway.uuid}.csv"
response = HttpResponse(res, content_type="text/csv")
response["Content-Disposition"] = f'attachment; filename="{filename}"'
@ -1985,7 +2017,7 @@ def package_pathway(request, package_uuid, pathway_uuid):
).get(uuid=pathway_uuid)
context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - {current_pathway.name}"
context["title"] = f"enviPath - {current_package.get_name()} - {current_pathway.get_name()}"
context["meta"]["current_package"] = current_package
context["object_type"] = "pathway"
@ -1997,9 +2029,9 @@ def package_pathway(request, package_uuid, pathway_uuid):
context["breadcrumbs"] = [
{"Home": s.SERVER_URL},
{"Package": s.SERVER_URL + "/package"},
{current_package.name: current_package.url},
{current_package.get_name(): current_package.url},
{"Pathway": current_package.url + "/pathway"},
{current_pathway.name: current_pathway.url},
{current_pathway.get_name(): current_pathway.url},
]
return render(request, "objects/pathway.html", context)
@ -2057,6 +2089,9 @@ def package_pathway(request, package_uuid, pathway_uuid):
if node_url:
n = current_pathway.get_node(node_url)
current_pathway.kv.update({"status": "running"})
current_pathway.save()
from .tasks import dispatch, predict
dispatch(
@ -2079,20 +2114,22 @@ def package_pathway(request, package_uuid, pathway_uuid):
def package_pathway_nodes(request, package_uuid, pathway_uuid):
current_user = _anonymous_or_real(request)
current_package = PackageManager.get_package_by_id(current_user, package_uuid)
current_pathway = Pathway.objects.get(package=current_package, uuid=pathway_uuid)
current_pathway = get_object_or_404(Pathway, package=current_package, uuid=pathway_uuid)
if request.method == "GET":
context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - {current_pathway.name} - Nodes"
context["title"] = (
f"enviPath - {current_package.get_name()} - {current_pathway.get_name()} - Nodes"
)
context["meta"]["current_package"] = current_package
context["object_type"] = "node"
context["breadcrumbs"] = [
{"Home": s.SERVER_URL},
{"Package": s.SERVER_URL + "/package"},
{current_package.name: current_package.url},
{current_package.get_name(): current_package.url},
{"Pathway": current_package.url + "/pathway"},
{current_pathway.name: current_pathway.url},
{current_pathway.get_name(): current_pathway.url},
{"Node": current_pathway.url + "/node"},
]
@ -2108,7 +2145,7 @@ def package_pathway_nodes(request, package_uuid, pathway_uuid):
return JsonResponse(
{
"objects": [
{"name": pw.name, "url": pw.url, "reviewed": current_package.reviewed}
{"name": pw.get_name(), "url": pw.url, "reviewed": current_package.reviewed}
for pw in (
reviewed_node_qs if current_package.reviewed else unreviewed_node_qs
)
@ -2138,8 +2175,8 @@ def package_pathway_nodes(request, package_uuid, pathway_uuid):
def package_pathway_node(request, package_uuid, pathway_uuid, node_uuid):
current_user = _anonymous_or_real(request)
current_package = PackageManager.get_package_by_id(current_user, package_uuid)
current_pathway = Pathway.objects.get(package=current_package, uuid=pathway_uuid)
current_node = Node.objects.get(pathway=current_pathway, uuid=node_uuid)
current_pathway = get_object_or_404(Pathway, package=current_package, uuid=pathway_uuid)
current_node = get_object_or_404(Node, pathway=current_pathway, uuid=node_uuid)
if request.method == "GET":
is_image_request = request.GET.get("image")
@ -2182,7 +2219,7 @@ def package_pathway_node(request, package_uuid, pathway_uuid, node_uuid):
return HttpResponse(svg_data, content_type="image/svg+xml")
context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - {current_pathway.name}"
context["title"] = f"enviPath - {current_package.get_name()} - {current_pathway.get_name()}"
context["meta"]["current_package"] = current_package
context["object_type"] = "pathway"
@ -2190,11 +2227,11 @@ def package_pathway_node(request, package_uuid, pathway_uuid, node_uuid):
context["breadcrumbs"] = [
{"Home": s.SERVER_URL},
{"Package": s.SERVER_URL + "/package"},
{current_package.name: current_package.url},
{current_package.get_name(): current_package.url},
{"Pathway": current_package.url + "/pathway"},
{current_pathway.name: current_pathway.url},
{current_pathway.get_name(): current_pathway.url},
{"Node": current_pathway.url + "/node"},
{current_node.name: current_node.url},
{current_node.get_name(): current_node.url},
]
context["node"] = current_node
@ -2243,20 +2280,22 @@ def package_pathway_node(request, package_uuid, pathway_uuid, node_uuid):
def package_pathway_edges(request, package_uuid, pathway_uuid):
current_user = _anonymous_or_real(request)
current_package = PackageManager.get_package_by_id(current_user, package_uuid)
current_pathway = Pathway.objects.get(package=current_package, uuid=pathway_uuid)
current_pathway = get_object_or_404(Pathway, package=current_package, uuid=pathway_uuid)
if request.method == "GET":
context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - {current_pathway.name} - Edges"
context["title"] = (
f"enviPath - {current_package.get_name()} - {current_pathway.get_name()} - Edges"
)
context["meta"]["current_package"] = current_package
context["object_type"] = "edge"
context["breadcrumbs"] = [
{"Home": s.SERVER_URL},
{"Package": s.SERVER_URL + "/package"},
{current_package.name: current_package.url},
{current_package.get_name(): current_package.url},
{"Pathway": current_package.url + "/pathway"},
{current_pathway.name: current_pathway.url},
{current_pathway.get_name(): current_pathway.url},
{"Edge": current_pathway.url + "/edge"},
]
@ -2272,7 +2311,7 @@ def package_pathway_edges(request, package_uuid, pathway_uuid):
return JsonResponse(
{
"objects": [
{"name": pw.name, "url": pw.url, "reviewed": current_package.reviewed}
{"name": pw.get_name(), "url": pw.url, "reviewed": current_package.reviewed}
for pw in (
reviewed_edge_qs if current_package.reviewed else unreviewed_edge_qs
)
@ -2312,8 +2351,8 @@ def package_pathway_edges(request, package_uuid, pathway_uuid):
def package_pathway_edge(request, package_uuid, pathway_uuid, edge_uuid):
current_user = _anonymous_or_real(request)
current_package = PackageManager.get_package_by_id(current_user, package_uuid)
current_pathway = Pathway.objects.get(package=current_package, uuid=pathway_uuid)
current_edge = Edge.objects.get(pathway=current_pathway, uuid=edge_uuid)
current_pathway = get_object_or_404(Pathway, package=current_package, uuid=pathway_uuid)
current_edge = get_object_or_404(Edge, pathway=current_pathway, uuid=edge_uuid)
if request.method == "GET":
is_image_request = request.GET.get("image")
@ -2324,7 +2363,7 @@ def package_pathway_edge(request, package_uuid, pathway_uuid, edge_uuid):
context = get_base_context(request)
context["title"] = (
f"enviPath - {current_package.name} - {current_pathway.name} - {current_edge.edge_label.name}"
f"enviPath - {current_package.get_name()} - {current_pathway.get_name()} - {current_edge.edge_label.get_name()}"
)
context["meta"]["current_package"] = current_package
@ -2377,11 +2416,11 @@ def package_scenarios(request, package_uuid):
"all", False
):
scens = Scenario.objects.filter(package=current_package).order_by("name")
res = [{"name": s_.name, "url": s_.url, "uuid": s_.uuid} for s_ in scens]
res = [{"name": s_.get_name(), "url": s_.url, "uuid": s_.uuid} for s_ in scens]
return JsonResponse(res, safe=False)
context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - Scenarios"
context["title"] = f"enviPath - {current_package.get_name()} - Scenarios"
context["meta"]["current_package"] = current_package
context["object_type"] = "scenario"
@ -2405,7 +2444,7 @@ def package_scenarios(request, package_uuid):
return JsonResponse(
{
"objects": [
{"name": pw.name, "url": pw.url, "reviewed": current_package.reviewed}
{"name": pw.get_name(), "url": pw.url, "reviewed": current_package.reviewed}
for pw in (
reviewed_scenario_qs
if current_package.reviewed
@ -2415,72 +2454,7 @@ def package_scenarios(request, package_uuid):
}
)
from envipy_additional_information import (
SEDIMENT_ADDITIONAL_INFORMATION,
SLUDGE_ADDITIONAL_INFORMATION,
SOIL_ADDITIONAL_INFORMATION,
)
context["scenario_types"] = {
"Soil Data": {
"name": "soil",
"widgets": [
HTMLGenerator.generate_html(ai, prefix=f"soil_{0}")
for ai in [x for sv in SOIL_ADDITIONAL_INFORMATION.values() for x in sv]
],
},
"Sludge Data": {
"name": "sludge",
"widgets": [
HTMLGenerator.generate_html(ai, prefix=f"sludge_{0}")
for ai in [x for sv in SLUDGE_ADDITIONAL_INFORMATION.values() for x in sv]
],
},
"Water-Sediment System Data": {
"name": "sediment",
"widgets": [
HTMLGenerator.generate_html(ai, prefix=f"sediment_{0}")
for ai in [x for sv in SEDIMENT_ADDITIONAL_INFORMATION.values() for x in sv]
],
},
}
context["sludge_additional_information"] = SLUDGE_ADDITIONAL_INFORMATION
context["soil_additional_information"] = SOIL_ADDITIONAL_INFORMATION
context["sediment_additional_information"] = SEDIMENT_ADDITIONAL_INFORMATION
return render(request, "collections/scenarios_paginated.html", context)
elif request.method == "POST":
log_post_params(request)
scenario_name = request.POST.get("scenario-name")
scenario_description = request.POST.get("scenario-description")
scenario_date_year = request.POST.get("scenario-date-year")
scenario_date_month = request.POST.get("scenario-date-month")
scenario_date_day = request.POST.get("scenario-date-day")
scenario_date = scenario_date_year
if scenario_date_month is not None and scenario_date_month.strip() != "":
scenario_date += f"-{int(scenario_date_month):02d}"
if scenario_date_day is not None and scenario_date_day.strip() != "":
scenario_date += f"-{int(scenario_date_day):02d}"
scenario_type = request.POST.get("scenario-type")
additional_information = HTMLGenerator.build_models(request.POST.dict())
additional_information = [x for sv in additional_information.values() for x in sv]
new_scen = Scenario.create(
current_package,
name=scenario_name,
description=scenario_description,
scenario_date=scenario_date,
scenario_type=scenario_type,
additional_information=additional_information,
)
return redirect(new_scen.url)
else:
return HttpResponseNotAllowed(
[
@ -2493,33 +2467,25 @@ def package_scenarios(request, package_uuid):
def package_scenario(request, package_uuid, scenario_uuid):
current_user = _anonymous_or_real(request)
current_package = PackageManager.get_package_by_id(current_user, package_uuid)
current_scenario = Scenario.objects.get(package=current_package, uuid=scenario_uuid)
current_scenario = get_object_or_404(Scenario, package=current_package, uuid=scenario_uuid)
if request.method == "GET":
context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - {current_scenario.name}"
context["title"] = (
f"enviPath - {current_package.get_name()} - {current_scenario.get_name()}"
)
context["meta"]["current_package"] = current_package
context["object_type"] = "scenario"
context["breadcrumbs"] = breadcrumbs(current_package, "scenario", current_scenario)
context["scenario"] = current_scenario
# Get scenarios that have current_scenario as a parent
context["children"] = current_scenario.scenario_set.order_by("name")
available_add_infs = []
for add_inf in NAME_MAPPING.values():
available_add_infs.append(
{
"display_name": add_inf.property_name(None),
"name": add_inf.__name__,
"widget": HTMLGenerator.generate_html(add_inf, prefix=f"{0}"),
}
)
context["available_additional_information"] = available_add_infs
context["update_widgets"] = [
HTMLGenerator.generate_html(ai, prefix=f"{i}")
for i, ai in enumerate(current_scenario.get_additional_information())
]
# Note: Modals now fetch schemas and data from API endpoints
# Keeping these for backwards compatibility if needed elsewhere
# They are no longer used by the main scenario template
return render(request, "objects/scenario.html", context)
@ -2539,30 +2505,39 @@ def package_scenario(request, package_uuid, scenario_uuid):
current_scenario.save()
return redirect(current_scenario.url)
elif hidden == "set-additional-information":
ais = HTMLGenerator.build_models(request.POST.dict())
if s.DEBUG:
logger.info(ais)
current_scenario.set_additional_information(ais)
return redirect(current_scenario.url)
# Legacy POST handler - no longer used, modals use API endpoints
return HttpResponseBadRequest(
"This endpoint is deprecated. Please use the API endpoints."
)
elif hidden == "add-additional-information":
ais = HTMLGenerator.build_models(request.POST.dict())
if len(ais.keys()) != 1:
raise ValueError(
"Only one additional information field can be added at a time."
)
ai = list(ais.values())[0][0]
if s.DEBUG:
logger.info(ais)
current_scenario.add_additional_information(ai)
return redirect(current_scenario.url)
# Legacy POST handler - no longer used, modals use API endpoints
return HttpResponseBadRequest(
"This endpoint is deprecated. Please use the API endpoints."
)
else:
return HttpResponseBadRequest()
new_scenario_name = request.POST.get("scenario-name")
if new_scenario_name is not None:
new_scenario_name = nh3.clean(new_scenario_name, tags=s.ALLOWED_HTML_TAGS).strip()
if new_scenario_name:
current_scenario.name = new_scenario_name
new_scenario_description = request.POST.get("scenario-description")
if new_scenario_description is not None:
new_scenario_description = nh3.clean(
new_scenario_description, tags=s.ALLOWED_HTML_TAGS
).strip()
if new_scenario_description:
current_scenario.description = new_scenario_description
if any([new_scenario_name, new_scenario_description]):
current_scenario.save()
return redirect(current_scenario.url)
else:
return HttpResponseBadRequest()
else:
@ -2628,9 +2603,11 @@ def user(request, user_uuid):
if is_hidden_method and request.POST["hidden"] == "request-api-token":
name = request.POST.get("name", "No Name")
valid_for = min(max(int(request.POST.get("valid-for", 90)), 1), 90)
expires_days = min(max(int(request.POST.get("valid-for", 90)), 1), 90)
token, raw_token = APIToken.create_token(request.user, name=name, valid_for=valid_for)
token, raw_token = APIToken.create_token(
request.user, name=name, expires_days=expires_days
)
return JsonResponse(
{"raw_token": raw_token, "token": {"id": token.id, "name": token.name}}
@ -2712,19 +2689,21 @@ def group(request, group_uuid):
if request.method == "GET":
context = get_base_context(request)
context["title"] = f"enviPath - {current_group.name}"
context["title"] = f"enviPath - {current_group.get_name()}"
context["object_type"] = "group"
context["breadcrumbs"] = [
{"Home": s.SERVER_URL},
{"Group": s.SERVER_URL + "/group"},
{current_group.name: current_group.url},
{current_group.get_name(): current_group.url},
]
context["group"] = current_group
context["users"] = UserManager.get_users_lp().exclude(
id__in=current_group.user_member.all()
context["users"] = (
UserManager.get_users_lp()
.exclude(id__in=current_group.user_member.all())
.filter(is_active=True)
)
context["groups"] = (
GroupManager.get_groups_lp()
@ -2778,10 +2757,17 @@ def settings(request):
context["object_type"] = "setting"
context["breadcrumbs"] = [
{"Home": s.SERVER_URL},
{"Group": s.SERVER_URL + "/setting"},
{"Setting": s.SERVER_URL + "/setting"},
]
context["objects"] = SettingManager.get_all_settings(current_user)
# Context for paginated template
context["entity_type"] = "setting"
context["api_endpoint"] = "/api/v1/settings/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "settings"
context["list_mode"] = "combined"
return render(request, "collections/settings_paginated.html", context)
return render(request, "collections/objects_list.html", context)
elif request.method == "POST":
@ -2861,7 +2847,26 @@ def settings(request):
def setting(request, setting_uuid):
pass
current_user = _anonymous_or_real(request)
current_setting = SettingManager.get_setting_by_id(current_user, setting_uuid)
if request.method == "GET":
context = get_base_context(request)
context["title"] = f"enviPath - {current_setting.get_name()}"
context["object_type"] = "setting"
context["breadcrumbs"] = [
{"Home": s.SERVER_URL},
{"Setting": s.SERVER_URL + "/setting"},
{f"{current_setting.get_name()}": current_setting.url},
]
context["setting"] = current_setting
context["current_object"] = current_setting
return render(request, "objects/setting.html", context)
else:
return HttpResponseNotAllowed(["GET"])
def jobs(request):
@ -2902,8 +2907,8 @@ def jobs(request):
target_package = PackageManager.create_package(
current_user,
f"Autogenerated Package for Pathway Engineering of {pathway_to_engineer.name}",
f"This Package was generated automatically for the engineering Task of {pathway_to_engineer.name}.",
f"Autogenerated Package for Pathway Engineering of {pathway_to_engineer.get_name()}",
f"This Package was generated automatically for the engineering Task of {pathway_to_engineer.get_name()}.",
)
from .tasks import dispatch, engineer_pathways
@ -2957,7 +2962,7 @@ def jobs(request):
"This Package was generated automatically for the batch prediction task.",
)
from .tasks import dispatch, batch_predict
from .tasks import batch_predict, dispatch
res = dispatch(
current_user,
@ -2995,6 +3000,8 @@ def job(request, job_uuid):
if job.job_name == "batch_predict":
filename = f"{job.job_name.replace(' ', '_')}_{job.task_id}.csv"
elif job.job_name == "identify_missing_rules":
filename = f"{job.job_name.replace(' ', '_')}_{job.task_id}.csv"
else:
raise BadRequest("Result is not downloadable!")

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -76,9 +76,7 @@ def migration(request):
open(s.BASE_DIR / "fixtures" / "migration_status_per_rule.json")
)
else:
BBD = Package.objects.get(
url="http://localhost:8000/package/32de3cf4-e3e6-4168-956e-32fa5ddb0ce1"
)
BBD = Package.objects.get(uuid="32de3cf4-e3e6-4168-956e-32fa5ddb0ce1")
ALL_SMILES = [
cs.smiles for cs in CompoundStructure.objects.filter(compound__package=BBD)
]
@ -147,7 +145,7 @@ def migration_detail(request, package_uuid, rule_uuid):
if request.method == "GET":
context = get_base_context(request)
BBD = Package.objects.get(name="EAWAG-BBD")
BBD = Package.objects.get(uuid="32de3cf4-e3e6-4168-956e-32fa5ddb0ce1")
STRUCTURES = CompoundStructure.objects.filter(compound__package=BBD)
rule = Rule.objects.get(package=BBD, uuid=rule_uuid)

View File

@ -8,14 +8,14 @@
"build": "tailwindcss -i static/css/input.css -o static/css/output.css --minify"
},
"devDependencies": {
"@tailwindcss/cli": "^4.1.16",
"@tailwindcss/postcss": "^4.1.16",
"daisyui": "^5.4.3",
"@tailwindcss/cli": "^4.1.18",
"@tailwindcss/postcss": "^4.1.18",
"daisyui": "^5.5.14",
"postcss": "^8.5.6",
"prettier": "^3.6.2",
"prettier": "^3.7.4",
"prettier-plugin-jinja-template": "^2.1.0",
"prettier-plugin-tailwindcss": "^0.7.1",
"tailwindcss": "^4.1.16"
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^4.1.18"
},
"keywords": [
"django",

200
pnpm-lock.yaml generated
View File

@ -9,29 +9,29 @@ importers:
.:
devDependencies:
'@tailwindcss/cli':
specifier: ^4.1.16
version: 4.1.16
specifier: ^4.1.18
version: 4.1.18
'@tailwindcss/postcss':
specifier: ^4.1.16
version: 4.1.16
specifier: ^4.1.18
version: 4.1.18
daisyui:
specifier: ^5.4.3
version: 5.4.3
specifier: ^5.5.14
version: 5.5.14
postcss:
specifier: ^8.5.6
version: 8.5.6
prettier:
specifier: ^3.6.2
version: 3.6.2
specifier: ^3.7.4
version: 3.7.4
prettier-plugin-jinja-template:
specifier: ^2.1.0
version: 2.1.0(prettier@3.6.2)
version: 2.1.0(prettier@3.7.4)
prettier-plugin-tailwindcss:
specifier: ^0.7.1
version: 0.7.1(prettier@3.6.2)
specifier: ^0.7.2
version: 0.7.2(prettier@3.7.4)
tailwindcss:
specifier: ^4.1.16
version: 4.1.16
specifier: ^4.1.18
version: 4.1.18
packages:
@ -137,69 +137,69 @@ packages:
resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==}
engines: {node: '>= 10.0.0'}
'@tailwindcss/cli@4.1.16':
resolution: {integrity: sha512-dsnANPrh2ZooHyZ/8uJhc9ecpcYtufToc21NY09NS9vF16rxPCjJ8dP7TUAtPqlUJTHSmRkN2hCdoYQIlgh4fw==}
'@tailwindcss/cli@4.1.18':
resolution: {integrity: sha512-sMZ+lZbDyxwjD2E0L7oRUjJ01Ffjtme5OtjvvnC+cV4CEDcbqzbp25TCpxHj6kWLU9+DlqJOiNgSOgctC2aZmg==}
hasBin: true
'@tailwindcss/node@4.1.16':
resolution: {integrity: sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==}
'@tailwindcss/node@4.1.18':
resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==}
'@tailwindcss/oxide-android-arm64@4.1.16':
resolution: {integrity: sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==}
'@tailwindcss/oxide-android-arm64@4.1.18':
resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@tailwindcss/oxide-darwin-arm64@4.1.16':
resolution: {integrity: sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==}
'@tailwindcss/oxide-darwin-arm64@4.1.18':
resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@tailwindcss/oxide-darwin-x64@4.1.16':
resolution: {integrity: sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==}
'@tailwindcss/oxide-darwin-x64@4.1.18':
resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@tailwindcss/oxide-freebsd-x64@4.1.16':
resolution: {integrity: sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==}
'@tailwindcss/oxide-freebsd-x64@4.1.18':
resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [freebsd]
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.16':
resolution: {integrity: sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==}
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18':
resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@tailwindcss/oxide-linux-arm64-gnu@4.1.16':
resolution: {integrity: sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==}
'@tailwindcss/oxide-linux-arm64-gnu@4.1.18':
resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tailwindcss/oxide-linux-arm64-musl@4.1.16':
resolution: {integrity: sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==}
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tailwindcss/oxide-linux-x64-gnu@4.1.16':
resolution: {integrity: sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==}
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tailwindcss/oxide-linux-x64-musl@4.1.16':
resolution: {integrity: sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==}
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tailwindcss/oxide-wasm32-wasi@4.1.16':
resolution: {integrity: sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==}
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
bundledDependencies:
@ -210,31 +210,31 @@ packages:
- '@emnapi/wasi-threads'
- tslib
'@tailwindcss/oxide-win32-arm64-msvc@4.1.16':
resolution: {integrity: sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==}
'@tailwindcss/oxide-win32-arm64-msvc@4.1.18':
resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@tailwindcss/oxide-win32-x64-msvc@4.1.16':
resolution: {integrity: sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==}
'@tailwindcss/oxide-win32-x64-msvc@4.1.18':
resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@tailwindcss/oxide@4.1.16':
resolution: {integrity: sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==}
'@tailwindcss/oxide@4.1.18':
resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==}
engines: {node: '>= 10'}
'@tailwindcss/postcss@4.1.16':
resolution: {integrity: sha512-Qn3SFGPXYQMKR/UtqS+dqvPrzEeBZHrFA92maT4zijCVggdsXnDBMsPFJo1eArX3J+O+Gi+8pV4PkqjLCNBk3A==}
'@tailwindcss/postcss@4.1.18':
resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
daisyui@5.4.3:
resolution: {integrity: sha512-dfDCJnN4utErGoWfElgdEE252FtfHV9Mxj5Dq1+JzUq3nVkluWdF3JYykP0Xy/y/yArnPXYztO1tLNCow3kjmg==}
daisyui@5.5.14:
resolution: {integrity: sha512-L47rvw7I7hK68TA97VB8Ee0woHew+/ohR6Lx6Ah/krfISOqcG4My7poNpX5Mo5/ytMxiR40fEaz6njzDi7cuSg==}
detect-libc@1.0.3:
resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
@ -245,8 +245,8 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
enhanced-resolve@5.18.3:
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
enhanced-resolve@5.18.4:
resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==}
engines: {node: '>=10.13.0'}
fill-range@7.1.1:
@ -377,8 +377,8 @@ packages:
peerDependencies:
prettier: ^3.0.0
prettier-plugin-tailwindcss@0.7.1:
resolution: {integrity: sha512-Bzv1LZcuiR1Sk02iJTS1QzlFNp/o5l2p3xkopwOrbPmtMeh3fK9rVW5M3neBQzHq+kGKj/4LGQMTNcTH4NGPtQ==}
prettier-plugin-tailwindcss@0.7.2:
resolution: {integrity: sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==}
engines: {node: '>=20.19'}
peerDependencies:
'@ianvs/prettier-plugin-sort-imports': '*'
@ -432,8 +432,8 @@ packages:
prettier-plugin-svelte:
optional: true
prettier@3.6.2:
resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==}
prettier@3.7.4:
resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==}
engines: {node: '>=14'}
hasBin: true
@ -441,8 +441,8 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
tailwindcss@4.1.16:
resolution: {integrity: sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==}
tailwindcss@4.1.18:
resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==}
tapable@2.3.0:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
@ -535,96 +535,96 @@ snapshots:
'@parcel/watcher-win32-ia32': 2.5.1
'@parcel/watcher-win32-x64': 2.5.1
'@tailwindcss/cli@4.1.16':
'@tailwindcss/cli@4.1.18':
dependencies:
'@parcel/watcher': 2.5.1
'@tailwindcss/node': 4.1.16
'@tailwindcss/oxide': 4.1.16
enhanced-resolve: 5.18.3
'@tailwindcss/node': 4.1.18
'@tailwindcss/oxide': 4.1.18
enhanced-resolve: 5.18.4
mri: 1.2.0
picocolors: 1.1.1
tailwindcss: 4.1.16
tailwindcss: 4.1.18
'@tailwindcss/node@4.1.16':
'@tailwindcss/node@4.1.18':
dependencies:
'@jridgewell/remapping': 2.3.5
enhanced-resolve: 5.18.3
enhanced-resolve: 5.18.4
jiti: 2.6.1
lightningcss: 1.30.2
magic-string: 0.30.21
source-map-js: 1.2.1
tailwindcss: 4.1.16
tailwindcss: 4.1.18
'@tailwindcss/oxide-android-arm64@4.1.16':
'@tailwindcss/oxide-android-arm64@4.1.18':
optional: true
'@tailwindcss/oxide-darwin-arm64@4.1.16':
'@tailwindcss/oxide-darwin-arm64@4.1.18':
optional: true
'@tailwindcss/oxide-darwin-x64@4.1.16':
'@tailwindcss/oxide-darwin-x64@4.1.18':
optional: true
'@tailwindcss/oxide-freebsd-x64@4.1.16':
'@tailwindcss/oxide-freebsd-x64@4.1.18':
optional: true
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.16':
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18':
optional: true
'@tailwindcss/oxide-linux-arm64-gnu@4.1.16':
'@tailwindcss/oxide-linux-arm64-gnu@4.1.18':
optional: true
'@tailwindcss/oxide-linux-arm64-musl@4.1.16':
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
optional: true
'@tailwindcss/oxide-linux-x64-gnu@4.1.16':
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
optional: true
'@tailwindcss/oxide-linux-x64-musl@4.1.16':
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
optional: true
'@tailwindcss/oxide-wasm32-wasi@4.1.16':
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
optional: true
'@tailwindcss/oxide-win32-arm64-msvc@4.1.16':
'@tailwindcss/oxide-win32-arm64-msvc@4.1.18':
optional: true
'@tailwindcss/oxide-win32-x64-msvc@4.1.16':
'@tailwindcss/oxide-win32-x64-msvc@4.1.18':
optional: true
'@tailwindcss/oxide@4.1.16':
'@tailwindcss/oxide@4.1.18':
optionalDependencies:
'@tailwindcss/oxide-android-arm64': 4.1.16
'@tailwindcss/oxide-darwin-arm64': 4.1.16
'@tailwindcss/oxide-darwin-x64': 4.1.16
'@tailwindcss/oxide-freebsd-x64': 4.1.16
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.16
'@tailwindcss/oxide-linux-arm64-gnu': 4.1.16
'@tailwindcss/oxide-linux-arm64-musl': 4.1.16
'@tailwindcss/oxide-linux-x64-gnu': 4.1.16
'@tailwindcss/oxide-linux-x64-musl': 4.1.16
'@tailwindcss/oxide-wasm32-wasi': 4.1.16
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.16
'@tailwindcss/oxide-win32-x64-msvc': 4.1.16
'@tailwindcss/oxide-android-arm64': 4.1.18
'@tailwindcss/oxide-darwin-arm64': 4.1.18
'@tailwindcss/oxide-darwin-x64': 4.1.18
'@tailwindcss/oxide-freebsd-x64': 4.1.18
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18
'@tailwindcss/oxide-linux-arm64-gnu': 4.1.18
'@tailwindcss/oxide-linux-arm64-musl': 4.1.18
'@tailwindcss/oxide-linux-x64-gnu': 4.1.18
'@tailwindcss/oxide-linux-x64-musl': 4.1.18
'@tailwindcss/oxide-wasm32-wasi': 4.1.18
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.18
'@tailwindcss/oxide-win32-x64-msvc': 4.1.18
'@tailwindcss/postcss@4.1.16':
'@tailwindcss/postcss@4.1.18':
dependencies:
'@alloc/quick-lru': 5.2.0
'@tailwindcss/node': 4.1.16
'@tailwindcss/oxide': 4.1.16
'@tailwindcss/node': 4.1.18
'@tailwindcss/oxide': 4.1.18
postcss: 8.5.6
tailwindcss: 4.1.16
tailwindcss: 4.1.18
braces@3.0.3:
dependencies:
fill-range: 7.1.1
daisyui@5.4.3: {}
daisyui@5.5.14: {}
detect-libc@1.0.3: {}
detect-libc@2.1.2: {}
enhanced-resolve@5.18.3:
enhanced-resolve@5.18.4:
dependencies:
graceful-fs: 4.2.11
tapable: 2.3.0
@ -719,19 +719,19 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
prettier-plugin-jinja-template@2.1.0(prettier@3.6.2):
prettier-plugin-jinja-template@2.1.0(prettier@3.7.4):
dependencies:
prettier: 3.6.2
prettier: 3.7.4
prettier-plugin-tailwindcss@0.7.1(prettier@3.6.2):
prettier-plugin-tailwindcss@0.7.2(prettier@3.7.4):
dependencies:
prettier: 3.6.2
prettier: 3.7.4
prettier@3.6.2: {}
prettier@3.7.4: {}
source-map-js@1.2.1: {}
tailwindcss@4.1.16: {}
tailwindcss@4.1.18: {}
tapable@2.3.0: {}

3
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,3 @@
onlyBuiltDependencies:
- '@parcel/watcher'
- '@tailwindcss/oxide'

View File

@ -19,6 +19,7 @@ dependencies = [
"envipy-plugins",
"epam-indigo>=1.30.1",
"gunicorn>=23.0.0",
"jsonref>=1.1.0",
"networkx>=3.4.2",
"psycopg2-binary>=2.9.10",
"python-dotenv>=1.1.0",
@ -35,7 +36,7 @@ dependencies = [
[tool.uv.sources]
enviformer = { git = "ssh://git@git.envipath.com/enviPath/enviformer.git", rev = "v0.1.4" }
envipy-plugins = { git = "ssh://git@git.envipath.com/enviPath/enviPy-plugins.git", rev = "v0.1.0" }
envipy-additional-information = { git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git", rev = "v0.1.7" }
envipy-additional-information = { git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git", rev = "v0.4.2" }
envipy-ambit = { git = "ssh://git@git.envipath.com/enviPath/enviPy-ambit.git" }
[project.optional-dependencies]
@ -85,8 +86,15 @@ build = { sequence = [
], help = "Build frontend assets and collect static files" }
# Database tasks
db-up = { cmd = "docker compose -f docker-compose.dev.yml up -d", help = "Start PostgreSQL database using Docker Compose" }
db-down = { cmd = "docker compose -f docker-compose.dev.yml down", help = "Stop PostgreSQL database" }
db-up = { cmd = "docker compose -p envipath -f docker-compose.dev.yml up -d", help = "Start PostgreSQL database using Docker Compose" }
db-down = { cmd = "docker compose -p envipath -f docker-compose.dev.yml down", help = "Stop PostgreSQL database" }
# Celery tasks
celery = { cmd = "celery -A envipath worker -l INFO -Q predict,model,background", help = "Start Celery worker for async task processing" }
celery-dev = { sequence = [
"db-up",
"celery",
], help = "Start database and Celery worker" }
# Frontend tasks
js-deps = { cmd = "uv run python scripts/pnpm_wrapper.py install", help = "Install frontend dependencies" }

View File

@ -11,6 +11,8 @@ import signal
import subprocess
import sys
import time
import os
import dotenv
def find_pnpm():
@ -65,6 +67,7 @@ class DevServerManager:
bufsize=1,
)
self.processes.append((process, description))
print(" ".join(command))
print(f"✓ Started {description} (PID: {process.pid})")
return process
except Exception as e:
@ -146,6 +149,7 @@ class DevServerManager:
def main():
"""Main entry point."""
dotenv.load_dotenv()
manager = DevServerManager()
manager.register_cleanup()
@ -174,9 +178,10 @@ def main():
time.sleep(1)
# Start Django dev server
port = os.environ.get("DJANGO_PORT", "8000")
django_process = manager.start_process(
["uv", "run", "python", "manage.py", "runserver"],
"Django server",
["uv", "run", "python", "manage.py", "runserver", f"0:{port}"],
f"Django server on port {port}",
shell=False,
)

View File

@ -0,0 +1,378 @@
/**
* Alpine.js Schema Renderer Component
*
* Renders forms dynamically from JSON Schema with RJSF format support.
* Supports uiSchema for widget hints, labels, help text, and field ordering.
*
* Usage:
* <div x-data="schemaRenderer({
* rjsf: { schema: {...}, uiSchema: {...}, formData: {...}, groups: [...] },
* data: { interval: { start: 20, end: 25 } },
* mode: 'view', // 'view' | 'edit'
* endpoint: '/api/v1/scenario/{uuid}/information/temperature/'
* })">
*/
document.addEventListener("alpine:init", () => {
// Global validation error store with context scoping
Alpine.store('validationErrors', {
errors: {},
// Set errors for a specific context (UUID) or globally (no context)
setErrors(errors, context = null) {
if (context) {
// Namespace all field names with context prefix
const namespacedErrors = {};
Object.entries(errors).forEach(([field, messages]) => {
const key = `${context}.${field}`;
namespacedErrors[key] = messages;
});
// Merge into existing errors (preserves other contexts)
this.errors = { ...this.errors, ...namespacedErrors };
} else {
// No context - merge as-is for backward compatibility
this.errors = { ...this.errors, ...errors };
}
},
// Clear errors for a specific context or all errors
clearErrors(context = null) {
if (context) {
// Clear only errors for this context
const newErrors = {};
const prefix = `${context}.`;
Object.keys(this.errors).forEach(key => {
if (!key.startsWith(prefix)) {
newErrors[key] = this.errors[key];
}
});
this.errors = newErrors;
} else {
// Clear all errors
this.errors = {};
}
},
// Clear a specific field, optionally within a context
clearField(fieldName, context = null) {
const key = context ? `${context}.${fieldName}` : fieldName;
if (this.errors[key]) {
delete this.errors[key];
// Trigger reactivity by creating new object
this.errors = { ...this.errors };
}
},
// Check if a field has errors, optionally within a context
hasError(fieldName, context = null) {
const key = context ? `${context}.${fieldName}` : fieldName;
return Array.isArray(this.errors[key]) && this.errors[key].length > 0;
},
// Get errors for a field, optionally within a context
getErrors(fieldName, context = null) {
const key = context ? `${context}.${fieldName}` : fieldName;
return this.errors[key] || [];
}
});
Alpine.data("schemaRenderer", (options = {}) => ({
schema: null,
uiSchema: {},
data: {},
mode: options.mode || "view", // 'view' | 'edit'
endpoint: options.endpoint || "",
loading: false,
error: null,
context: options.context || null, // UUID for items, null for single forms
debugErrors:
options.debugErrors ??
(typeof window !== "undefined" &&
window.location?.search?.includes("debugErrors=1")),
async init() {
if (options.schemaUrl) {
try {
this.loading = true;
const res = await fetch(options.schemaUrl);
if (!res.ok) {
throw new Error(`Failed to load schema: ${res.statusText}`);
}
const rjsf = await res.json();
// RJSF format: {schema, uiSchema, formData, groups}
if (!rjsf.schema) {
throw new Error("Invalid RJSF format: missing schema property");
}
this.schema = rjsf.schema;
this.uiSchema = rjsf.uiSchema || {};
this.data = options.data
? JSON.parse(JSON.stringify(options.data))
: rjsf.formData || {};
} catch (err) {
this.error = err.message;
console.error("Error loading schema:", err);
} finally {
this.loading = false;
}
} else if (options.rjsf) {
// Direct RJSF object passed
if (!options.rjsf.schema) {
throw new Error("Invalid RJSF format: missing schema property");
}
this.schema = options.rjsf.schema;
this.uiSchema = options.rjsf.uiSchema || {};
this.data = options.data
? JSON.parse(JSON.stringify(options.data))
: options.rjsf.formData || {};
}
// Initialize data from formData or options
if (!this.data || Object.keys(this.data).length === 0) {
this.data = {};
}
// Ensure all schema fields are properly initialized
if (this.schema && this.schema.properties) {
for (const [key, propSchema] of Object.entries(
this.schema.properties,
)) {
const widget = this.getWidget(key, propSchema);
if (widget === "interval") {
// Ensure interval fields are objects with start/end
if (!this.data[key] || typeof this.data[key] !== "object") {
this.data[key] = { start: null, end: null };
} else {
// Ensure start and end exist
if (this.data[key].start === undefined)
this.data[key].start = null;
if (this.data[key].end === undefined) this.data[key].end = null;
}
} else if (widget === "timeseries-table") {
// Ensure timeseries fields are arrays
if (!this.data[key] || !Array.isArray(this.data[key])) {
this.data[key] = [];
}
} else if (this.data[key] === undefined) {
// ONLY initialize if truly undefined, not just falsy
// This preserves empty strings, null, 0, false as valid values
if (propSchema.type === "boolean") {
this.data[key] = false;
} else if (
propSchema.type === "number" ||
propSchema.type === "integer"
) {
this.data[key] = null;
} else if (propSchema.enum) {
// For select fields, use null to show placeholder
this.data[key] = null;
} else {
this.data[key] = "";
}
}
// If data[key] exists (even if empty string or null), don't overwrite
}
}
// UX: Clear field errors when fields change (with context)
if (this.mode === "edit" && this.schema?.properties) {
Object.keys(this.schema.properties).forEach((key) => {
this.$watch(
`data.${key}`,
() => {
Alpine.store('validationErrors').clearField(key, this.context);
},
{ deep: true },
);
});
}
},
getWidget(fieldName, fieldSchema) {
// Defensive check: ensure fieldSchema is provided
if (!fieldSchema) return "text";
try {
// Check uiSchema first (RJSF format)
if (
this.uiSchema &&
this.uiSchema[fieldName] &&
this.uiSchema[fieldName]["ui:widget"]
) {
return this.uiSchema[fieldName]["ui:widget"];
}
// Check for interval type (object with start/end properties)
if (
fieldSchema.type === "object" &&
fieldSchema.properties &&
fieldSchema.properties.start &&
fieldSchema.properties.end
) {
return "interval";
}
// Check for measurements array type (timeseries-table widget)
if (
fieldSchema.type === "array" &&
fieldSchema.items?.properties?.timestamp &&
fieldSchema.items?.properties?.value
) {
return "timeseries-table";
}
// Infer from JSON Schema type
if (fieldSchema.enum) return "select";
if (fieldSchema.type === "number" || fieldSchema.type === "integer")
return "number";
if (fieldSchema.type === "boolean") return "checkbox";
return "text";
} catch (e) {
// Fallback to text widget if anything fails
console.warn("Error in getWidget:", e);
return "text";
}
},
getLabel(fieldName, fieldSchema) {
// Check uiSchema (RJSF format)
if (this.uiSchema[fieldName] && this.uiSchema[fieldName]["ui:label"]) {
return this.uiSchema[fieldName]["ui:label"];
}
// Default: format field name
return fieldName
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
},
getFieldOrder() {
try {
// Get ordered list of field names based on ui:order
if (!this.schema || !this.schema.properties) return [];
// Only include fields that have UI configs
const fields = Object.keys(this.schema.properties).filter(
(fieldName) => this.uiSchema && this.uiSchema[fieldName],
);
// Sort by ui:order if available
return fields.sort((a, b) => {
const orderA = this.uiSchema[a]?.["ui:order"] || "999";
const orderB = this.uiSchema[b]?.["ui:order"] || "999";
return parseInt(orderA) - parseInt(orderB);
});
} catch (e) {
// Return empty array if anything fails to prevent errors
console.warn("Error in getFieldOrder:", e);
return [];
}
},
hasTimeseriesField() {
try {
// Check if any field in the schema is a timeseries-table widget
if (!this.schema || !this.schema.properties) {
return false;
}
return Object.keys(this.schema.properties).some((fieldName) => {
const fieldSchema = this.schema.properties[fieldName];
if (!fieldSchema) return false;
return this.getWidget(fieldName, fieldSchema) === "timeseries-table";
});
} catch (e) {
// Return false if anything fails to prevent errors
console.warn("Error in hasTimeseriesField:", e);
return false;
}
},
async submit() {
if (!this.endpoint) {
console.error("No endpoint specified for submission");
return;
}
this.loading = true;
this.error = null;
try {
const csrftoken =
document.querySelector("[name=csrf-token]")?.content || "";
const res = await fetch(this.endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": csrftoken,
},
body: JSON.stringify(this.data),
});
if (!res.ok) {
let errorData;
try {
errorData = await res.json();
} catch {
errorData = { error: res.statusText };
}
// Handle validation errors (field-level)
Alpine.store('validationErrors').clearErrors();
// Try to parse structured error response
let parsedError = errorData;
// If error is a JSON string, parse it
if (
typeof errorData.error === "string" &&
errorData.error.startsWith("{")
) {
parsedError = JSON.parse(errorData.error);
}
if (parsedError.detail && Array.isArray(parsedError.detail)) {
// Pydantic validation errors format: [{loc: ['field'], msg: '...', type: '...'}]
const fieldErrors = {};
for (const err of parsedError.detail) {
const field =
err.loc && err.loc.length > 0
? err.loc[err.loc.length - 1]
: "root";
if (!fieldErrors[field]) {
fieldErrors[field] = [];
}
fieldErrors[field].push(
err.msg || err.message || "Validation error",
);
}
Alpine.store('validationErrors').setErrors(fieldErrors);
throw new Error(
"Validation failed. Please check the fields below.",
);
} else {
// General error
throw new Error(
parsedError.error ||
parsedError.detail ||
`Request failed: ${res.statusText}`,
);
}
}
// Clear errors on success
Alpine.store('validationErrors').clearErrors();
const result = await res.json();
return result;
} catch (err) {
this.error = err.message;
throw err;
} finally {
this.loading = false;
}
},
}));
});

View File

@ -0,0 +1,462 @@
/**
* Alpine.js Widget Components for Schema Forms
*
* Centralized widget component definitions for dynamic form rendering.
* Each widget receives explicit parameters instead of context object for better traceability.
*/
document.addEventListener("alpine:init", () => {
// Base widget factory with common functionality
const baseWidget = (
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context = null // NEW: context for error namespacing
) => ({
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context, // Store context for use in templates
// Field schema access
get fieldSchema() {
return this.schema?.properties?.[this.fieldName] || {};
},
// Common metadata
get label() {
// Check uiSchema first (RJSF format)
if (this.uiSchema?.[this.fieldName]?.["ui:label"]) {
return this.uiSchema[this.fieldName]["ui:label"];
}
// Fall back to schema title
if (this.fieldSchema.title) {
return this.fieldSchema.title;
}
// Default: format field name
return this.fieldName
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
},
get helpText() {
return this.fieldSchema.description || "";
},
// Field-level unit extraction from uiSchema (RJSF format)
get unit() {
return this.uiSchema?.[this.fieldName]?.["ui:unit"] || null;
},
// Mode checks
get isViewMode() {
return this.mode === "view";
},
get isEditMode() {
return this.mode === "edit";
},
});
// Text widget
Alpine.data(
"textWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() {
return this.data[this.fieldName] || "";
},
set value(v) {
this.data[this.fieldName] = v;
},
}),
);
// Textarea widget
Alpine.data(
"textareaWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() {
return this.data[this.fieldName] || "";
},
set value(v) {
this.data[this.fieldName] = v;
},
}),
);
// Number widget with unit support
Alpine.data(
"numberWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() {
return this.data[this.fieldName];
},
set value(v) {
this.data[this.fieldName] =
v === "" || v === null ? null : parseFloat(v);
},
get hasValue() {
return (
this.value !== null && this.value !== undefined && this.value !== ""
);
},
// Format value with unit for view mode
get displayValue() {
if (!this.hasValue) return "—";
return this.unit ? `${this.value} ${this.unit}` : String(this.value);
},
}),
);
// Select widget
Alpine.data(
"selectWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() {
return this.data[this.fieldName] || "";
},
set value(v) {
this.data[this.fieldName] = v;
},
get options() {
return this.fieldSchema.enum || [];
},
}),
);
// Checkbox widget
Alpine.data(
"checkboxWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get checked() {
return !!this.data[this.fieldName];
},
set checked(v) {
this.data[this.fieldName] = v;
},
}),
);
// Interval widget with unit support
Alpine.data(
"intervalWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get start() {
return this.data[this.fieldName]?.start ?? null;
},
set start(v) {
if (!this.data[this.fieldName]) this.data[this.fieldName] = {};
this.data[this.fieldName].start =
v === "" || v === null ? null : parseFloat(v);
},
get end() {
return this.data[this.fieldName]?.end ?? null;
},
set end(v) {
if (!this.data[this.fieldName]) this.data[this.fieldName] = {};
this.data[this.fieldName].end =
v === "" || v === null ? null : parseFloat(v);
},
// Format interval with unit for view mode
get displayValue() {
const s = this.start,
e = this.end;
const unitStr = this.unit ? ` ${this.unit}` : "";
if (s !== null && e !== null) return `${s} ${e}${unitStr}`;
if (s !== null) return `${s}${unitStr}`;
if (e !== null) return `${e}${unitStr}`;
return "—";
},
get isSameValue() {
return this.start !== null && this.start === this.end;
},
// Validation: start must be <= end (client-side)
get hasValidationError() {
if (this.isViewMode) return false;
const s = this.start;
const e = this.end;
// Only validate if both values are provided
if (
s !== null &&
e !== null &&
typeof s === "number" &&
typeof e === "number"
) {
return s > e;
}
return false;
},
}),
);
// PubMed link widget
Alpine.data(
"pubmedWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() {
return this.data[this.fieldName] || "";
},
set value(v) {
this.data[this.fieldName] = v;
},
get pubmedUrl() {
return this.value
? `https://pubmed.ncbi.nlm.nih.gov/${this.value}`
: null;
},
}),
);
// Compound link widget
Alpine.data(
"compoundWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() {
return this.data[this.fieldName] || "";
},
set value(v) {
this.data[this.fieldName] = v;
},
}),
);
// TimeSeries table widget
Alpine.data(
"timeseriesTableWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
chartInstance: null,
// Getter/setter for measurements array
get measurements() {
return this.data[this.fieldName] || [];
},
set measurements(v) {
this.data[this.fieldName] = v;
},
// Get description from sibling field
get description() {
return this.data?.description || "";
},
// Get method from sibling field
get method() {
return this.data?.method || "";
},
// Computed property for chart options
get chartOptions() {
return {
measurements: this.measurements,
xAxisLabel: this.data?.x_axis_label || "Time",
yAxisLabel: this.data?.y_axis_label || "Value",
xAxisUnit: this.data?.x_axis_unit || "",
yAxisUnit: this.data?.y_axis_unit || "",
};
},
// Add new measurement
addMeasurement() {
if (!this.data[this.fieldName]) {
this.data[this.fieldName] = [];
}
this.data[this.fieldName].push({
timestamp: null,
value: null,
error: null,
note: "",
});
},
// Remove measurement by index
removeMeasurement(index) {
if (
this.data[this.fieldName] &&
Array.isArray(this.data[this.fieldName])
) {
this.data[this.fieldName].splice(index, 1);
}
},
// Update specific measurement field
updateMeasurement(index, field, value) {
if (this.data[this.fieldName] && this.data[this.fieldName][index]) {
if (field === "timestamp" || field === "value" || field === "error") {
// Parse all numeric fields (timestamp is days as float)
this.data[this.fieldName][index][field] =
value === "" || value === null ? null : parseFloat(value);
} else {
// Store other fields as-is
this.data[this.fieldName][index][field] = value;
}
}
},
// Format timestamp for display (timestamp is numeric days as float)
formatTimestamp(timestamp) {
return timestamp ?? "";
},
// Sort by timestamp (numeric days)
sortByTimestamp() {
if (
this.data[this.fieldName] &&
Array.isArray(this.data[this.fieldName])
) {
this.data[this.fieldName].sort((a, b) => {
const tsA = a.timestamp ?? Infinity;
const tsB = b.timestamp ?? Infinity;
return tsA - tsB;
});
}
},
// Chart lifecycle methods (delegates to TimeSeriesChart utility)
initChart() {
if (!this.isViewMode || !window.Chart || !window.TimeSeriesChart)
return;
const canvas = this.$refs?.chartCanvas;
if (!canvas) return;
this.destroyChart();
if (this.measurements.length === 0) return;
this.chartInstance = window.TimeSeriesChart.create(
canvas,
this.chartOptions,
);
},
updateChart() {
if (!this.chartInstance || !this.isViewMode) return;
window.TimeSeriesChart.update(
this.chartInstance,
this.measurements,
this.chartOptions,
);
},
destroyChart() {
if (this.chartInstance) {
window.TimeSeriesChart.destroy(this.chartInstance);
this.chartInstance = null;
}
},
// Alpine lifecycle hooks
init() {
if (this.isViewMode && window.Chart) {
// Use $nextTick to ensure DOM is ready
this.$nextTick(() => {
this.initChart();
});
// Watch measurements array for changes and update chart
this.$watch("data." + this.fieldName, () => {
if (this.chartInstance) {
this.updateChart();
}
});
}
},
}),
);
});

View File

@ -21,6 +21,7 @@ document.addEventListener('alpine:init', () => {
Alpine.data('pathwayViewer', (config) => ({
status: config.status,
modified: config.modified,
modifiedDate: null,
statusUrl: config.statusUrl,
emptyDueToThreshold: config.emptyDueToThreshold === "True",
showUpdateNotice: false,
@ -39,6 +40,8 @@ document.addEventListener('alpine:init', () => {
},
init() {
this.modifiedDate = this.parseDate(this.modified);
if (this.status === 'running') {
this.startPolling();
}
@ -66,26 +69,39 @@ document.addEventListener('alpine:init', () => {
this.showEmptyDueToThresholdNotice = true;
}
if (data.modified > this.modified) {
if (!this.emptyDueToThreshold) {
this.showUpdateNotice = true;
this.updateMessage = this.getUpdateMessage(data.status);
}
const nextModifiedDate = this.parseDate(data.modified);
const modifiedChanged = this.hasNewerTimestamp(nextModifiedDate, this.modifiedDate);
const statusChanged = data.status !== this.status;
if ((modifiedChanged || statusChanged) && !this.emptyDueToThreshold) {
this.showUpdateNotice = true;
this.updateMessage = this.getUpdateMessage(data.status, modifiedChanged, statusChanged);
}
if (data.status !== 'running') {
this.status = data.status;
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
this.modified = data.modified;
this.modifiedDate = nextModifiedDate;
this.status = data.status;
if (data.status !== 'running' && this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
} catch (err) {
console.error('Polling error:', err);
}
},
getUpdateMessage(status) {
getUpdateMessage(status, modifiedChanged, statusChanged) {
// Prefer explicit status change messaging, otherwise fall back to modified change copy
if (statusChanged) {
if (status === 'completed') {
return 'Prediction completed. Reload the page to see the updated Pathway.';
}
if (status === 'failed') {
return 'Prediction failed. Reload the page to see the latest status.';
}
}
let msg = 'Prediction ';
if (status === 'running') {
@ -99,6 +115,18 @@ document.addEventListener('alpine:init', () => {
return msg;
},
parseDate(dateString) {
// Normalize "YYYY-MM-DD HH:mm:ss" into an ISO-compatible string to avoid locale issues
if (!dateString) return null;
return new Date(dateString.replace(' ', 'T'));
},
hasNewerTimestamp(nextDate, currentDate) {
if (!nextDate) return false;
if (!currentDate) return true;
return nextDate.getTime() > currentDate.getTime();
},
reloadPage() {
location.reload();
}

View File

@ -0,0 +1,419 @@
/**
* Unified API client for Additional Information endpoints
* Provides consistent error handling, logging, and CRUD operations
*/
window.AdditionalInformationApi = {
// Configuration
_debug: false,
/**
* Enable or disable debug logging
* @param {boolean} enabled - Whether to enable debug mode
*/
setDebug(enabled) {
this._debug = enabled;
},
/**
* Internal logging helper
* @private
*/
_log(action, data) {
if (this._debug) {
console.log(`[AdditionalInformationApi] ${action}:`, data);
}
},
//FIXME: this has the side effect of users not being able to explicitly set an empty string for a field.
/**
* Remove empty strings from payload recursively
* @param {any} value
* @returns {any}
*/
sanitizePayload(value) {
if (Array.isArray(value)) {
return value
.map((item) => this.sanitizePayload(item))
.filter((item) => item !== "");
}
if (value && typeof value === "object") {
const cleaned = {};
for (const [key, item] of Object.entries(value)) {
if (item === "") continue;
cleaned[key] = this.sanitizePayload(item);
}
return cleaned;
}
return value;
},
/**
* Get CSRF token from meta tag
* @returns {string} CSRF token
*/
getCsrfToken() {
return document.querySelector("[name=csrf-token]")?.content || "";
},
/**
* Build headers for API requests
* @private
*/
_buildHeaders(includeContentType = true) {
const headers = {
"X-CSRFToken": this.getCsrfToken(),
};
if (includeContentType) {
headers["Content-Type"] = "application/json";
}
return headers;
},
/**
* Handle API response with consistent error handling
* @private
*/
async _handleResponse(response, action) {
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
} catch {
errorData = { error: response.statusText };
}
// Try to parse the error if it's a JSON string
let parsedError = errorData;
const errorStr = errorData.detail || errorData.error;
if (typeof errorStr === "string" && errorStr.startsWith("{")) {
try {
parsedError = JSON.parse(errorStr);
} catch {
// Not JSON, use as-is
}
}
// If it's a structured validation error, throw with field errors
if (parsedError.type === "validation_error" && parsedError.field_errors) {
this._log(`${action} VALIDATION ERROR`, parsedError);
const error = new Error(parsedError.message || "Validation failed");
error.fieldErrors = parsedError.field_errors;
error.isValidationError = true;
throw error;
}
// General error
const errorMsg =
parsedError.message ||
parsedError.error ||
parsedError.detail ||
`${action} failed: ${response.statusText}`;
this._log(`${action} ERROR`, {
status: response.status,
error: errorMsg,
});
throw new Error(errorMsg);
}
const data = await response.json();
this._log(`${action} SUCCESS`, data);
return data;
},
/**
* Load all available schemas
* @returns {Promise<Object>} Object with schema definitions
*/
async loadSchemas() {
this._log("loadSchemas", "Starting...");
const response = await fetch("/api/v1/information/schema/");
return this._handleResponse(response, "loadSchemas");
},
/**
* Load additional information items for a scenario
* @param {string} scenarioUuid - UUID of the scenario
* @returns {Promise<Array>} Array of additional information items
*/
async loadItems(scenarioUuid) {
this._log("loadItems", { scenarioUuid });
const response = await fetch(
`/api/v1/scenario/${scenarioUuid}/information/`,
);
return this._handleResponse(response, "loadItems");
},
/**
* Load both schemas and items in parallel
* @param {string} scenarioUuid - UUID of the scenario
* @returns {Promise<{schemas: Object, items: Array}>}
*/
async loadSchemasAndItems(scenarioUuid) {
this._log("loadSchemasAndItems", { scenarioUuid });
const [schemas, items] = await Promise.all([
this.loadSchemas(),
this.loadItems(scenarioUuid),
]);
return { schemas, items };
},
/**
* Create new additional information for a scenario
* @param {string} scenarioUuid - UUID of the scenario
* @param {string} modelName - Name/type of the additional information model
* @param {Object} data - Data for the new item
* @returns {Promise<{status: string, uuid: string}>}
*/
async createItem(scenarioUuid, modelName, data) {
const sanitizedData = this.sanitizePayload(data);
this._log("createItem", { scenarioUuid, modelName, data: sanitizedData });
// Normalize model name to lowercase
const normalizedName = modelName.toLowerCase();
const response = await fetch(
`/api/v1/scenario/${scenarioUuid}/information/${normalizedName}/`,
{
method: "POST",
headers: this._buildHeaders(),
body: JSON.stringify(sanitizedData),
},
);
return this._handleResponse(response, "createItem");
},
/**
* Delete additional information from a scenario
* @param {string} scenarioUuid - UUID of the scenario
* @param {string} itemUuid - UUID of the item to delete
* @returns {Promise<{status: string}>}
*/
async deleteItem(scenarioUuid, itemUuid) {
this._log("deleteItem", { scenarioUuid, itemUuid });
const response = await fetch(
`/api/v1/scenario/${scenarioUuid}/information/item/${itemUuid}/`,
{
method: "DELETE",
headers: this._buildHeaders(false),
},
);
return this._handleResponse(response, "deleteItem");
},
/**
* Update existing additional information
* Tries PATCH first, falls back to delete+recreate if not supported
* @param {string} scenarioUuid - UUID of the scenario
* @param {Object} item - Item object with uuid, type, and data properties
* @returns {Promise<{status: string, uuid: string}>}
*/
async updateItem(scenarioUuid, item) {
const sanitizedData = this.sanitizePayload(item.data);
this._log("updateItem", {
scenarioUuid,
item: { ...item, data: sanitizedData },
});
const { uuid, type } = item;
// Try PATCH first (preferred method - preserves UUID)
const response = await fetch(
`/api/v1/scenario/${scenarioUuid}/information/item/${uuid}/`,
{
method: "PATCH",
headers: this._buildHeaders(),
body: JSON.stringify(sanitizedData),
},
);
if (response.status === 405) {
// PATCH not supported, fall back to delete+recreate
this._log(
"updateItem",
"PATCH not supported, falling back to delete+recreate",
);
await this.deleteItem(scenarioUuid, uuid);
return await this.createItem(scenarioUuid, type, sanitizedData);
}
return this._handleResponse(response, "updateItem");
},
/**
* Update multiple items sequentially to avoid race conditions
* @param {string} scenarioUuid - UUID of the scenario
* @param {Array<Object>} items - Array of items to update
* @returns {Promise<Array>} Array of results with success status
*/
async updateItems(scenarioUuid, items) {
this._log("updateItems", { scenarioUuid, itemCount: items.length });
const results = [];
for (const item of items) {
try {
const result = await this.updateItem(scenarioUuid, item);
results.push({
success: true,
oldUuid: item.uuid,
newUuid: result.uuid,
});
} catch (error) {
results.push({
success: false,
oldUuid: item.uuid,
error: error.message,
fieldErrors: error.fieldErrors,
isValidationError: error.isValidationError,
});
}
}
const failed = results.filter((r) => !r.success);
if (failed.length > 0) {
// If all failures are validation errors, return all validation errors for display
const validationErrors = failed.filter((f) => f.isValidationError);
if (validationErrors.length === failed.length) {
// All failures are validation errors - return all field errors by item UUID
const allFieldErrors = {};
validationErrors.forEach((ve) => {
allFieldErrors[ve.oldUuid] = ve.fieldErrors || {};
});
const error = new Error(
`${failed.length} item(s) have validation errors. Please correct them.`,
);
error.fieldErrors = allFieldErrors; // Map of UUID -> field errors
error.isValidationError = true;
error.isMultipleErrors = true; // Flag indicating multiple items have errors
throw error;
}
// Multiple failures or mixed errors - show count
throw new Error(
`Failed to update ${failed.length} item(s). Please check the form for errors.`,
);
}
return results;
},
/**
* Create a new scenario with optional additional information
* @param {string} packageUuid - UUID of the package
* @param {Object} payload - Scenario data matching ScenarioCreateSchema
* @param {string} payload.name - Scenario name (required)
* @param {string} payload.description - Scenario description (optional, default: "")
* @param {string} payload.scenario_date - Scenario date (optional, default: "No date")
* @param {string} payload.scenario_type - Scenario type (optional, default: "Not specified")
* @param {Array} payload.additional_information - Array of additional information (optional, default: [])
* @returns {Promise<{uuid, url, name, description, review_status, package}>}
*/
async createScenario(packageUuid, payload) {
this._log("createScenario", { packageUuid, payload });
const response = await fetch(`/api/v1/package/${packageUuid}/scenario/`, {
method: "POST",
headers: this._buildHeaders(),
body: JSON.stringify(payload),
});
return this._handleResponse(response, "createScenario");
},
/**
* Load all available group names
* @returns {Promise<{groups: string[]}>}
*/
async loadGroups() {
this._log("loadGroups", "Starting...");
const response = await fetch("/api/v1/information/groups/");
return this._handleResponse(response, "loadGroups");
},
/**
* Load model definitions for a specific group
* @param {string} groupName - One of 'soil', 'sludge', 'sediment'
* @returns {Promise<Object>} Object with subcategories as keys and arrays of model info
*/
async loadGroupModels(groupName) {
this._log("loadGroupModels", { groupName });
const response = await fetch(`/api/v1/information/groups/${groupName}/`);
return this._handleResponse(response, `loadGroupModels-${groupName}`);
},
/**
* Load model information for multiple groups in parallel
* @param {Array<string>} groupNames - Defaults to ['soil', 'sludge', 'sediment']
* @returns {Promise<Object>} Object with group names as keys
*/
async loadGroupsWithModels(groupNames = ["soil", "sludge", "sediment"]) {
this._log("loadGroupsWithModels", { groupNames });
const results = {};
const promises = groupNames.map(async (groupName) => {
try {
results[groupName] = await this.loadGroupModels(groupName);
} catch (err) {
this._log(`loadGroupsWithModels-${groupName} ERROR`, err);
results[groupName] = {};
}
});
await Promise.all(promises);
return results;
},
/**
* Helper to organize schemas by group based on group model information
* @param {Object} schemas - Full schema map from loadSchemas()
* @param {Object} groupModelsData - Group models data from loadGroupsWithModels()
* @returns {Object} Object with group names as keys and filtered schemas as values
*/
organizeSchemasByGroup(schemas, groupModelsData) {
this._log("organizeSchemasByGroup", {
schemaCount: Object.keys(schemas).length,
groupCount: Object.keys(groupModelsData).length,
});
const organized = {};
for (const groupName in groupModelsData) {
organized[groupName] = {};
const groupData = groupModelsData[groupName];
// Iterate through subcategories in the group
for (const subcategory in groupData) {
for (const model of groupData[subcategory]) {
// Look up schema by lowercase model name
if (schemas[model.name]) {
organized[groupName][model.name] = schemas[model.name];
}
}
}
}
return organized;
},
/**
* Convenience method that loads schemas and organizes them by group in one call
* @param {Array<string>} groupNames - Defaults to ['soil', 'sludge', 'sediment']
* @returns {Promise<{schemas, groupSchemas, groupModels}>}
*/
async loadSchemasWithGroups(groupNames = ["soil", "sludge", "sediment"]) {
this._log("loadSchemasWithGroups", { groupNames });
// Load schemas and all groups in parallel
const [schemas, groupModels] = await Promise.all([
this.loadSchemas(),
this.loadGroupsWithModels(groupNames),
]);
// Organize schemas by group
const groupSchemas = this.organizeSchemasByGroup(schemas, groupModels);
return { schemas, groupSchemas, groupModels };
},
};

View File

@ -361,6 +361,83 @@ function draw(pathway, elem) {
function node_popup(n) {
popupContent = "";
if (timeseriesViewEnabled && n.timeseries && n.timeseries.measurements) {
for (var s of n.scenarios) {
popupContent += "<a href='" + s.url + "'>" + s.name + "</a><br>";
}
popupContent += '<div style="width:100%;height:120px"><canvas id="ts-popover-canvas"></canvas></div>';
const tsMeasurements = n.timeseries.measurements;
setTimeout(() => {
const canvas = document.getElementById('ts-popover-canvas');
if (canvas && window.Chart) {
const valid = tsMeasurements
.filter(m => m.timestamp != null && m.value != null)
.map(m => ({ ...m, timestamp: typeof m.timestamp === 'number' ? m.timestamp : new Date(m.timestamp).getTime() }))
.sort((a, b) => a.timestamp - b.timestamp);
const datasets = [];
// Error band (lower + upper with fill between)
const withErrors = valid.filter(m => m.error != null && m.error > 0);
if (withErrors.length > 0) {
datasets.push({
data: withErrors.map(m => ({ x: m.timestamp, y: m.value - m.error })),
borderColor: 'rgba(59,130,246,0.3)',
backgroundColor: 'rgba(59,130,246,0.15)',
pointRadius: 0,
fill: false,
tension: 0.1,
});
datasets.push({
data: withErrors.map(m => ({ x: m.timestamp, y: m.value + m.error })),
borderColor: 'rgba(59,130,246,0.3)',
backgroundColor: 'rgba(59,130,246,0.15)',
pointRadius: 0,
fill: '-1',
tension: 0.1,
});
}
// Main value line
datasets.push({
data: valid.map(m => ({ x: m.timestamp, y: m.value })),
borderColor: 'rgb(59,130,246)',
pointRadius: 0,
tension: 0.1,
fill: false,
});
new Chart(canvas.getContext('2d'), {
type: 'line',
data: { datasets },
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { enabled: false },
},
scales: {
x: {
type: 'linear',
ticks: { font: { size: 10 } },
title: { display: false },
},
y: {
ticks: { font: { size: 10 } },
title: { display: false },
},
},
},
});
}
}, 0);
return popupContent;
}
if (n.stereo_removed) {
popupContent += "<span class='alert alert-warning alert-soft'>Removed stereochemistry for prediction</span>";
}
@ -393,7 +470,14 @@ function draw(pathway, elem) {
}
function edge_popup(e) {
popupContent = "<a href='" + e.url + "'>" + e.name + "</a><br>";
popupContent = "<a href='" + e.url + "'>" + e.name + "</a><br><br>";
if (e.reaction.rules) {
console.log(e.reaction.rules);
for (var rule of e.reaction.rules) {
popupContent += "Rule <a href='" + rule.url + "'>" + rule.name + "</a><br>";
}
}
if (e.app_domain) {
adcontent = "<p>";
@ -498,7 +582,8 @@ function draw(pathway, elem) {
.enter().append("line")
// Check if target is pseudo and draw marker only if not pseudo
.attr("class", d => d.target.pseudo ? "link_no_arrow" : "link")
.attr("marker-end", d => d.target.pseudo ? '' : 'url(#arrow)')
.attr("marker-end", d => d.target.pseudo ? '' : d.multi_step ? 'url(#doublearrow)' : 'url(#arrow)')
// add element to links array
link.each(function (d) {

View File

@ -0,0 +1,351 @@
/**
* TimeSeriesChart Utility
*
* Provides chart rendering capabilities for time series data with error bounds.
* Uses Chart.js to create interactive and static visualizations.
*
* Usage:
* const chart = window.TimeSeriesChart.create(canvas, {
* measurements: [...],
* xAxisLabel: "Time",
* yAxisLabel: "Concentration",
* xAxisUnit: "days",
* yAxisUnit: "mg/L"
* });
*
* window.TimeSeriesChart.update(chart, newMeasurements, options);
* window.TimeSeriesChart.destroy(chart);
*/
window.TimeSeriesChart = {
// === PUBLIC API ===
/**
* Create an interactive time series chart
*
* @param {HTMLCanvasElement} canvas - Canvas element to render chart on
* @param {Object} options - Chart configuration options
* @param {Array} options.measurements - Array of measurement objects with timestamp, value, error, note
* @param {string} options.xAxisLabel - Label for x-axis (default: "Time")
* @param {string} options.yAxisLabel - Label for y-axis (default: "Value")
* @param {string} options.xAxisUnit - Unit for x-axis (default: "")
* @param {string} options.yAxisUnit - Unit for y-axis (default: "")
* @returns {Chart|null} Chart.js instance or null if creation failed
*/
create(canvas, options = {}) {
if (!this._validateCanvas(canvas)) return null;
if (!window.Chart) {
console.warn("Chart.js is not loaded");
return null;
}
const ctx = canvas.getContext("2d");
if (!ctx) return null;
const chartData = this._transformData(options.measurements || [], options);
if (chartData.datasets.length === 0) {
return null; // No data to display
}
const config = this._buildConfig(chartData, options);
return new Chart(ctx, config);
},
/**
* Update an existing chart with new data
*
* @param {Chart} chartInstance - Chart.js instance to update
* @param {Array} measurements - New measurements array
* @param {Object} options - Chart configuration options
*/
update(chartInstance, measurements, options = {}) {
if (!chartInstance) return;
const chartData = this._transformData(measurements || [], options);
chartInstance.data.datasets = chartData.datasets;
chartInstance.options.scales.x.title.text = chartData.xAxisLabel;
chartInstance.options.scales.y.title.text = chartData.yAxisLabel;
chartInstance.update("none");
},
/**
* Destroy chart instance and cleanup
*
* @param {Chart} chartInstance - Chart.js instance to destroy
*/
destroy(chartInstance) {
if (chartInstance && typeof chartInstance.destroy === "function") {
chartInstance.destroy();
}
},
// === PRIVATE HELPERS ===
/**
* Transform measurements into Chart.js datasets
* @private
*/
_transformData(measurements, options) {
const preparedData = this._prepareData(measurements);
if (preparedData.length === 0) {
return { datasets: [], xAxisLabel: "Time", yAxisLabel: "Value" };
}
const xAxisLabel = options.xAxisLabel || "Time";
const yAxisLabel = options.yAxisLabel || "Value";
const xAxisUnit = options.xAxisUnit || "";
const yAxisUnit = options.yAxisUnit || "";
const datasets = [];
// Error bounds datasets FIRST (if errors exist) - renders as background
const errorDatasets = this._buildErrorDatasets(preparedData);
if (errorDatasets.length > 0) {
datasets.push(...errorDatasets);
}
// Main line dataset LAST - renders on top
datasets.push(this._buildMainDataset(preparedData, yAxisLabel));
return {
datasets: datasets,
xAxisLabel: this._formatAxisLabel(xAxisLabel, xAxisUnit),
yAxisLabel: this._formatAxisLabel(yAxisLabel, yAxisUnit),
};
},
/**
* Prepare and validate measurements data
* @private
*/
_prepareData(measurements) {
return measurements
.filter(
(m) => m.timestamp != null && m.value != null,
)
.map((m) => {
// Normalize timestamp - handle both numeric and date strings
let timestamp;
if (typeof m.timestamp === "number") {
timestamp = m.timestamp;
} else {
timestamp = new Date(m.timestamp).getTime();
}
return {
...m,
timestamp: timestamp,
};
})
.sort((a, b) => a.timestamp - b.timestamp);
},
/**
* Build main line dataset
* @private
*/
_buildMainDataset(validMeasurements, yAxisLabel) {
return {
label: yAxisLabel,
data: validMeasurements.map((m) => ({
x: m.timestamp,
y: m.value,
error: m.error || null,
})),
borderColor: "rgb(59, 130, 246)",
backgroundColor: "rgba(59, 130, 246, 0.1)",
tension: 0.1,
pointRadius: 0, // Hide individual points
pointHoverRadius: 6,
fill: false,
};
},
/**
* Build error bound datasets (upper and lower)
* @private
*/
_buildErrorDatasets(validMeasurements) {
const hasErrors = validMeasurements.some(
(m) => m.error !== null && m.error !== undefined && m.error > 0,
);
if (!hasErrors) return [];
const measurementsWithErrors = validMeasurements.filter(
(m) => m.error !== null && m.error !== undefined && m.error > 0,
);
return [
// Lower error bound - FIRST (bottom layer)
{
label: "Error (lower)",
data: measurementsWithErrors.map((m) => ({
x: m.timestamp,
y: m.value - m.error,
})),
borderColor: "rgba(59, 130, 246, 0.3)",
backgroundColor: "rgba(59, 130, 246, 0.15)",
pointRadius: 0,
fill: false,
tension: 0.1,
},
// Upper error bound - SECOND (fill back to lower)
{
label: "Error (upper)",
data: measurementsWithErrors.map((m) => ({
x: m.timestamp,
y: m.value + m.error,
})),
borderColor: "rgba(59, 130, 246, 0.3)",
backgroundColor: "rgba(59, 130, 246, 0.15)",
pointRadius: 0,
fill: "-1", // Fill back to previous dataset (lower bound)
tension: 0.1,
},
];
},
/**
* Build complete Chart.js configuration
* @private
*/
_buildConfig(chartData, options) {
return {
type: "line",
data: { datasets: chartData.datasets },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: "index",
},
plugins: {
legend: {
display: false,
},
tooltip: this._buildTooltipConfig(
options.xAxisUnit || "",
options.yAxisUnit || "",
),
},
scales: this._buildScalesConfig(
chartData.xAxisLabel,
chartData.yAxisLabel,
),
},
};
},
/**
* Build tooltip configuration with custom callbacks
* @private
*/
_buildTooltipConfig(xAxisUnit, yAxisUnit) {
return {
enabled: true,
callbacks: {
title: (contexts) => {
// Show timestamp
const context = contexts[0];
if (!context) return "Measurement";
const timestamp = context.parsed.x;
return xAxisUnit
? `Time: ${timestamp} ${xAxisUnit}`
: `Time: ${timestamp}`;
},
label: (context) => {
// Show value with unit
try {
const value = context.parsed.y;
if (value === null || value === undefined) {
return `${context.dataset.label || "Value"}: N/A`;
}
const valueStr = yAxisUnit
? `${value} ${yAxisUnit}`
: String(value);
return `${context.dataset.label || "Value"}: ${valueStr}`;
} catch (e) {
console.error("Tooltip label error:", e);
return `${context.dataset.label || "Value"}: ${context.parsed.y ?? "N/A"}`;
}
},
afterLabel: (context) => {
// Show error information
try {
const point = context.raw;
// Main line is now the last dataset (after error bounds if they exist)
const isMainDataset = context.dataset.label &&
!context.dataset.label.startsWith("Error");
if (!point || !isMainDataset) return null;
const lines = [];
// Show error if available
if (
point.error !== null &&
point.error !== undefined &&
point.error > 0
) {
const errorStr = yAxisUnit
? `±${point.error.toFixed(4)} ${yAxisUnit}`
: `±${point.error.toFixed(4)}`;
lines.push(`Error: ${errorStr}`);
}
return lines.length > 0 ? lines : null;
} catch (e) {
console.error("Tooltip afterLabel error:", e);
return null;
}
},
},
};
},
/**
* Build scales configuration
* @private
*/
_buildScalesConfig(xAxisLabel, yAxisLabel) {
return {
x: {
type: "linear",
title: {
display: true,
text: xAxisLabel || "Time",
},
},
y: {
title: {
display: true,
text: yAxisLabel || "Value",
},
},
};
},
/**
* Format axis label with unit
* @private
*/
_formatAxisLabel(label, unit) {
return unit ? `${label} (${unit})` : label;
},
/**
* Validate canvas element
* @private
*/
_validateCanvas(canvas) {
if (!canvas || !(canvas instanceof HTMLCanvasElement)) {
console.warn("Invalid canvas element provided to TimeSeriesChart");
return false;
}
return true;
},
};

View File

@ -1,4 +1,12 @@
{% if meta.can_edit %}
<li>
<a
class="button"
onclick="document.getElementById('edit_scenario_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-trash"></i> Edit Scenario</a
>
</li>
<li>
<a
class="button"

View File

@ -98,7 +98,7 @@
class="mt-6"
x-show="activeTab === 'reviewed' && !isEmpty"
x-data="remotePaginatedList({
endpoint: '{{ api_endpoint }}?review_status=true',
endpoint: '{{ api_endpoint }}?review_status=true{% if entity_type == 'scenario' %}&exclude_related=true{% endif %}',
instanceId: '{{ entity_type }}_reviewed',
isReviewed: true,
perPage: {{ per_page|default:50 }}
@ -113,7 +113,7 @@
class="mt-6"
x-show="activeTab === 'unreviewed' && !isEmpty"
x-data="remotePaginatedList({
endpoint: '{{ api_endpoint }}?review_status=false',
endpoint: '{{ api_endpoint }}?review_status=false{% if entity_type == 'scenario' %}&exclude_related=true{% endif %}',
instanceId: '{{ entity_type }}_unreviewed',
isReviewed: false,
perPage: {{ per_page|default:50 }}

View File

@ -1,7 +1,15 @@
{% extends "collections/paginated_base.html" %}
{% load static %}
{% block page_title %}Scenarios{% endblock %}
{% block action_modals %}
{# Load required scripts before modal #}
<script src="{% static 'js/alpine/components/widgets.js' %}"></script>
<script src="{% static 'js/api/additional-information.js' %}"></script>
{% include "modals/collections/new_scenario_modal.html" %}
{% endblock action_modals %}
{% block action_button %}
{% if meta.can_edit %}
<button
@ -14,10 +22,6 @@
{% endif %}
{% endblock action_button %}
{% block action_modals %}
{% include "modals/collections/new_scenario_modal.html" %}
{% endblock action_modals %}
{% block description %}
<p>
A scenario contains meta-information that can be attached to other data

View File

@ -0,0 +1,20 @@
{% extends "collections/paginated_base.html" %}
{% block page_title %}Settings{% endblock %}
{% block action_button %}
{% endblock action_button %}
{% block action_modals %}
{% endblock action_modals %}
{% block description %}
<p>A setting includes configuration parameters for pathway predictions.</p>
<a
target="_blank"
href="https://wiki.envipath.org/index.php/Setting"
class="link link-primary"
>
Learn more &gt;&gt;
</a>
{% endblock description %}

View File

@ -5,10 +5,8 @@
<nav>
<h6 class="footer-title">Services</h6>
<a class="link link-hover" href="/predict">Predict</a>
<a class="link link-hover" href="/batch-predict">Batch Predict</a>
<a class="link link-hover" href="/package">Packages</a>
{# {% if user.is_authenticated %}#}
{# <a class="link link-hover" href="/model">Your Collections</a>#}
{# {% endif %}#}
<a
href="https://wiki.envipath.org/"
target="_blank"
@ -19,10 +17,7 @@
{% endif %}
<nav>
<h6 class="footer-title">Company</h6>
<a class="link link-hover" href="/about">About us</a>
<a class="link link-hover" href="/contact">Contact us</a>
<a class="link link-hover" href="/careers">Careers</a>
<a class="link link-hover" href="/legal">Legal</a>
<a class="link link-hover" href="/about" target="_blank">About us</a>
</nav>
<nav>
<h6 class="footer-title">Legal</h6>

View File

@ -9,6 +9,11 @@
}
.spinner-slow svg {
animation: spin-slow 3s linear infinite;
width: 100%;
height: 100%;
transform-origin: center;
transform-box: fill-box;
display: block;
}
</style>
<div class="spinner-slow flex h-full w-full items-center justify-center">

View File

@ -0,0 +1,5 @@
<template x-if="{{ error_var }}">
<div class="alert alert-error mb-4">
<span x-text="{{ error_var }}"></span>
</div>
</template>

View File

@ -0,0 +1,5 @@
<template x-if="{{ loading_var }}">
<div class="flex justify-center items-center p-4">
<span class="loading loading-spinner loading-md"></span>
</div>
</template>

View File

@ -0,0 +1,146 @@
{% load static %}
<div>
<!-- Loading state -->
<template x-if="loading">
<div class="flex items-center justify-center p-4">
<span class="loading loading-spinner loading-md"></span>
</div>
</template>
<!-- Error state -->
<template x-if="error">
<div class="alert alert-error mb-4">
<span x-text="error"></span>
</div>
</template>
<!-- Schema form -->
<template x-if="schema && !loading">
<div class="space-y-4">
<!-- Title from schema -->
<template x-if="schema['x-title'] || schema.title">
<h4
class="text-lg font-semibold"
x-text="data.name || schema['x-title'] || schema.title"
></h4>
</template>
<!-- Render each field (ordered by ui:order) -->
<template x-for="fieldName in getFieldOrder()" :key="fieldName">
<div>
<!-- Text widget -->
<template
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'text'"
>
<div
x-data="textWidget(fieldName, data, schema, uiSchema, mode, debugErrors, context)"
>
{% include "components/widgets/text_widget.html" %}
</div>
</template>
<!-- Textarea widget -->
<template
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'textarea'"
>
<div
x-data="textareaWidget(fieldName, data, schema, uiSchema, mode, debugErrors, context)"
>
{% include "components/widgets/textarea_widget.html" %}
</div>
</template>
<!-- Number widget -->
<template
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'number'"
>
<div
x-data="numberWidget(fieldName, data, schema, uiSchema, mode, debugErrors, context)"
>
{% include "components/widgets/number_widget.html" %}
</div>
</template>
<!-- Select widget -->
<template
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'select'"
>
<div
x-data="selectWidget(fieldName, data, schema, uiSchema, mode, debugErrors, context)"
>
{% include "components/widgets/select_widget.html" %}
</div>
</template>
<!-- Checkbox widget -->
<template
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'checkbox'"
>
<div
x-data="checkboxWidget(fieldName, data, schema, uiSchema, mode, debugErrors, context)"
>
{% include "components/widgets/checkbox_widget.html" %}
</div>
</template>
<!-- Interval widget -->
<template
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'interval'"
>
<div
x-data="intervalWidget(fieldName, data, schema, uiSchema, mode, debugErrors, context)"
>
{% include "components/widgets/interval_widget.html" %}
</div>
</template>
<!-- PubMed link widget -->
<template
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'pubmed-link'"
>
<div
x-data="pubmedWidget(fieldName, data, schema, uiSchema, mode, debugErrors, context)"
>
{% include "components/widgets/pubmed_link_widget.html" %}
</div>
</template>
<!-- Compound link widget -->
<template
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'compound-link'"
>
<div
x-data="compoundWidget(fieldName, data, schema, uiSchema, mode, debugErrors, context)"
>
{% include "components/widgets/compound_link_widget.html" %}
</div>
</template>
<!-- TimeSeries table widget -->
<template
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'timeseries-table'"
>
<div
x-data="timeseriesTableWidget(fieldName, data, schema, uiSchema, mode, debugErrors, context)"
>
{% include "components/widgets/timeseries_table_widget.html" %}
</div>
</template>
</div>
</template>
<!-- Submit button (only in edit mode with endpoint) -->
<template x-if="mode === 'edit' && endpoint">
<div class="form-control mt-4">
<button class="btn btn-primary" @click="submit()" :disabled="loading">
<template x-if="loading">
<span class="loading loading-spinner loading-sm"></span>
</template>
<span x-text="loading ? 'Submitting...' : 'Submit'"></span>
</button>
</div>
</template>
</div>
</template>
</div>

View File

@ -0,0 +1,53 @@
{# Checkbox widget - pure HTML template #}
<div class="form-control">
<div class="flex flex-col gap-2 sm:flex-row sm:items-baseline">
<!-- Label -->
<label class="label sm:w-48 sm:shrink-0">
<span
class="label-text"
:class="{
'text-error': $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode
}"
x-text="label"
></span>
</label>
<!-- Input column -->
<div class="flex-1">
<!-- Help text -->
<template x-if="helpText">
<div class="label">
<span
class="label-text-alt text-base-content/60"
x-text="helpText"
></span>
</div>
</template>
<!-- View mode -->
<template x-if="isViewMode">
<div class="mt-1">
<span class="text-base" x-text="checked ? 'Yes' : 'No'"></span>
</div>
</template>
<!-- Edit mode -->
<template x-if="isEditMode">
<input type="checkbox" class="checkbox" x-model="checked" />
</template>
<!-- Errors -->
<template x-if="$store.validationErrors.hasError(fieldName, context)">
<div class="label">
<template
x-for="errMsg in $store.validationErrors.getErrors(fieldName, context)"
:key="errMsg"
>
<span class="label-text-alt text-error" x-text="errMsg"></span>
</template>
</div>
</template>
</div>
</div>
</div>

View File

@ -0,0 +1,69 @@
{# Compound link widget - pure HTML template #}
<div class="form-control">
<div class="flex flex-col gap-2 sm:flex-row sm:items-baseline">
<!-- Label -->
<label class="label sm:w-48 sm:shrink-0">
<span
class="label-text"
:class="{
'text-error': $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode
}"
x-text="label"
></span>
</label>
<!-- Input column -->
<div class="flex-1">
<!-- Help text -->
<template x-if="helpText">
<div class="label">
<span
class="label-text-alt text-base-content/60"
x-text="helpText"
></span>
</div>
</template>
<!-- View mode: display as link -->
<template x-if="isViewMode">
<div class="mt-1">
<template x-if="value">
<a
:href="value"
class="link link-primary break-all"
target="_blank"
x-text="value"
></a>
</template>
<template x-if="!value">
<span class="text-base-content/50"></span>
</template>
</div>
</template>
<!-- Edit mode -->
<template x-if="isEditMode">
<input
type="url"
class="input input-bordered w-full"
:class="{ 'input-error': $store.validationErrors.hasError(fieldName, context) }"
placeholder="Compound URL"
x-model="value"
/>
</template>
<!-- Errors -->
<template x-if="$store.validationErrors.hasError(fieldName, context)">
<div class="label">
<template
x-for="errMsg in $store.validationErrors.getErrors(fieldName, context)"
:key="errMsg"
>
<span class="label-text-alt text-error" x-text="errMsg"></span>
</template>
</div>
</template>
</div>
</div>
</div>

View File

@ -0,0 +1,89 @@
{# Interval widget for range inputs - pure HTML template #}
<div class="form-control">
<div class="flex flex-col gap-2 sm:flex-row sm:items-baseline">
<!-- Label -->
<label class="label sm:w-48 sm:shrink-0">
<span
class="label-text"
:class="{
'text-error': hasValidationError || $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode
}"
x-text="label"
></span>
</label>
<!-- Input column -->
<div class="flex-1">
<!-- Help text -->
<template x-if="helpText">
<div class="label">
<span
class="label-text-alt text-base-content/60"
x-text="helpText"
></span>
</div>
</template>
<!-- View mode: formatted range with unit -->
<template x-if="isViewMode">
<div class="mt-1">
<span class="text-base" x-text="start"></span>
<span class="text-base-content/60 text-xs" x-show="!isSameValue"
>to</span
>
<span class="text-base" x-text="end" x-show="!isSameValue"></span>
<template x-if="start && end && unit">
<span class="text-xs" x-text="unit"></span>
</template>
</div>
</template>
<!-- Edit mode: two inputs with shared unit badge -->
<template x-if="isEditMode">
<div class="flex items-center gap-2">
<input
type="number"
class="input input-bordered flex-1"
:class="{ 'input-error': hasValidationError || $store.validationErrors.hasError(fieldName, context) }"
placeholder="Min"
x-model="start"
/>
<span class="text-base-content/60">to</span>
<input
type="number"
class="input input-bordered flex-1"
:class="{ 'input-error': hasValidationError || $store.validationErrors.hasError(fieldName, context) }"
placeholder="Max"
x-model="end"
/>
<template x-if="unit">
<span class="badge badge-ghost badge-lg" x-text="unit"></span>
</template>
</div>
</template>
<!-- Errors -->
<template
x-if="hasValidationError || $store.validationErrors.hasError(fieldName, context)"
>
<div class="label">
<!-- Client-side validation error -->
<template x-if="hasValidationError">
<span class="label-text-alt text-error">
Start value must be less than or equal to end value
</span>
</template>
<!-- Server-side validation errors from store -->
<template
x-for="errMsg in $store.validationErrors.getErrors(fieldName, context)"
:key="errMsg"
>
<span class="label-text-alt text-error" x-text="errMsg"></span>
</template>
</div>
</template>
</div>
</div>
</div>

View File

@ -0,0 +1,69 @@
{# Number input widget - pure HTML template #}
<div class="form-control">
<div class="flex flex-col gap-2 sm:flex-row sm:items-baseline">
<!-- Label -->
<label class="label sm:w-48 sm:shrink-0">
<span
class="label-text"
:class="{
'text-error': $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode
}"
x-text="label"
></span>
</label>
<!-- Input column -->
<div class="flex-1">
<!-- Help text -->
<template x-if="helpText">
<div class="label">
<span
class="label-text-alt text-base-content/60"
x-text="helpText"
></span>
</div>
</template>
<!-- View mode: show value with unit -->
<template x-if="isViewMode">
<div class="mt-1">
<span class="text-base" x-text="value"></span>
<template x-if="value && unit">
<span class="text-xs" x-text="unit"></span>
</template>
</div>
</template>
<!-- Edit mode: input with unit suffix -->
<template x-if="isEditMode">
<div :class="unit ? 'join w-full' : ''">
<input
type="number"
:class="unit ? 'input input-bordered join-item flex-1' : 'input input-bordered w-full'"
class:input-error="$store.validationErrors.hasError(fieldName, context)"
x-model="value"
/>
<template x-if="unit">
<span
class="btn btn-ghost join-item no-animation pointer-events-none"
x-text="unit"
></span>
</template>
</div>
</template>
<!-- Errors -->
<template x-if="$store.validationErrors.hasError(fieldName, context)">
<div class="label">
<template
x-for="errMsg in $store.validationErrors.getErrors(fieldName, context)"
:key="errMsg"
>
<span class="label-text-alt text-error" x-text="errMsg"></span>
</template>
</div>
</template>
</div>
</div>
</div>

View File

@ -0,0 +1,69 @@
{# PubMed link widget - pure HTML template #}
<div class="form-control">
<div class="flex flex-col gap-2 sm:flex-row sm:items-baseline">
<!-- Label -->
<label class="label sm:w-48 sm:shrink-0">
<span
class="label-text"
:class="{
'text-error': $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode
}"
x-text="label"
></span>
</label>
<!-- Input column -->
<div class="flex-1">
<!-- Help text -->
<template x-if="helpText">
<div class="label">
<span
class="label-text-alt text-base-content/60"
x-text="helpText"
></span>
</div>
</template>
<!-- View mode: display as link -->
<template x-if="isViewMode">
<div class="mt-1">
<template x-if="value && pubmedUrl">
<a
:href="pubmedUrl"
class="link link-primary"
target="_blank"
x-text="value"
></a>
</template>
<template x-if="!value">
<span class="text-base-content/50"></span>
</template>
</div>
</template>
<!-- Edit mode -->
<template x-if="isEditMode">
<input
type="text"
class="input input-bordered w-full"
:class="{ 'input-error': $store.validationErrors.hasError(fieldName, context) }"
placeholder="PubMed ID"
x-model="value"
/>
</template>
<!-- Errors -->
<template x-if="$store.validationErrors.hasError(fieldName, context)">
<div class="label">
<template
x-for="errMsg in $store.validationErrors.getErrors(fieldName, context)"
:key="errMsg"
>
<span class="label-text-alt text-error" x-text="errMsg"></span>
</template>
</div>
</template>
</div>
</div>
</div>

View File

@ -0,0 +1,71 @@
{# Select dropdown widget - pure HTML template #}
<div class="form-control">
<div class="flex flex-col gap-2 sm:flex-row sm:items-baseline">
<!-- Label -->
<label class="label sm:w-48 sm:shrink-0">
<span
class="label-text"
:class="{
'text-error': $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode
}"
x-text="label"
></span>
</label>
<!-- Input column -->
<div class="flex-1">
<!-- Help text -->
<template x-if="helpText">
<div class="label">
<span
class="label-text-alt text-base-content/60"
x-text="helpText"
></span>
</div>
</template>
<!-- View mode -->
<template x-if="isViewMode">
<div class="mt-1">
<template x-if="value">
<span class="text-base" x-text="value"></span>
</template>
<template x-if="!value">
<span class="text-base-content/50"></span>
</template>
</div>
</template>
<!-- Edit mode -->
<template x-if="isEditMode">
<select
class="select select-bordered w-full"
:class="{ 'select-error': $store.validationErrors.hasError(fieldName, context) }"
x-model="value"
>
<option value="" :selected="!value">Select...</option>
<template x-for="opt in options" :key="opt">
<option
:value="opt"
:selected="value === opt"
x-text="opt"
></option>
</template>
</select>
</template>
<!-- Errors -->
<template x-if="$store.validationErrors.hasError(fieldName, context)">
<div class="label">
<template
x-for="errMsg in $store.validationErrors.getErrors(fieldName, context)"
:key="errMsg"
>
<span class="label-text-alt text-error" x-text="errMsg"></span>
</template>
</div>
</template>
</div>
</div>
</div>

View File

@ -0,0 +1,63 @@
{# Text input widget - pure HTML template #}
<div class="form-control">
<div class="flex flex-col gap-2 sm:flex-row sm:items-baseline">
<!-- Label -->
<label class="label sm:w-48 sm:shrink-0">
<span
class="label-text"
:class="{
'text-error': $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode
}"
x-text="label"
></span>
</label>
<!-- Input column -->
<div class="flex-1">
<!-- Help text -->
<template x-if="helpText">
<div class="label">
<span
class="label-text-alt text-base-content/60"
x-text="helpText"
></span>
</div>
</template>
<!-- View mode -->
<template x-if="isViewMode">
<div class="mt-1">
<template x-if="value">
<span class="text-base" x-text="value"></span>
</template>
<template x-if="!value">
<span class="text-base-content/50"></span>
</template>
</div>
</template>
<!-- Edit mode -->
<template x-if="isEditMode">
<input
type="text"
class="input input-bordered w-full"
:class="{ 'input-error': $store.validationErrors.hasError(fieldName, context) }"
x-model="value"
/>
</template>
<!-- Errors -->
<template x-if="$store.validationErrors.hasError(fieldName, context)">
<div class="label">
<template
x-for="errMsg in $store.validationErrors.getErrors(fieldName, context)"
:key="errMsg"
>
<span class="label-text-alt text-error" x-text="errMsg"></span>
</template>
</div>
</template>
</div>
</div>
</div>

View File

@ -0,0 +1,62 @@
{# Textarea widget - pure HTML template #}
<div class="form-control">
<div class="flex flex-col gap-2 sm:flex-row sm:items-baseline">
<!-- Label -->
<label class="label sm:w-48 sm:shrink-0">
<span
class="label-text"
:class="{
'text-error': $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode
}"
x-text="label"
></span>
</label>
<!-- Input column -->
<div class="flex-1">
<!-- Help text -->
<template x-if="helpText">
<div class="label">
<span
class="label-text-alt text-base-content/60"
x-text="helpText"
></span>
</div>
</template>
<!-- View mode -->
<template x-if="isViewMode">
<div class="mt-1">
<template x-if="value">
<p class="text-base whitespace-pre-wrap" x-text="value"></p>
</template>
<template x-if="!value">
<span class="text-base-content/50"></span>
</template>
</div>
</template>
<!-- Edit mode -->
<template x-if="isEditMode">
<textarea
class="textarea textarea-bordered w-full"
:class="{ 'textarea-error': $store.validationErrors.hasError(fieldName, context) }"
x-model="value"
></textarea>
</template>
<!-- Errors -->
<template x-if="$store.validationErrors.hasError(fieldName, context)">
<div class="label">
<template
x-for="errMsg in $store.validationErrors.getErrors(fieldName, context)"
:key="errMsg"
>
<span class="label-text-alt text-error" x-text="errMsg"></span>
</template>
</div>
</template>
</div>
</div>
</div>

View File

@ -0,0 +1,234 @@
{# TimeSeries table widget for measurement data #}
<div class="form-control">
<div class="flex flex-col gap-2">
<!-- Label -->
<label class="label">
<span
class="label-text"
:class="{
'text-error': $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode
}"
x-text="label"
></span>
</label>
<!-- Help text -->
<template x-if="helpText">
<div class="label">
<span
class="label-text-alt text-base-content/60"
x-text="helpText"
></span>
</div>
</template>
<!-- Form-level validation errors (root errors) for timeseries -->
<template x-if="$store.validationErrors.hasError('root', context)">
<div class="text-error">
<template
x-for="errMsg in $store.validationErrors.getErrors('root', context)"
:key="errMsg"
>
<span x-text="errMsg"></span>
</template>
</div>
</template>
<!-- View mode: display measurements as chart -->
<template x-if="isViewMode">
<div class="space-y-4">
<!-- Chart container -->
<template x-if="measurements.length > 0">
<div class="w-full">
<div class="h-64 w-full">
<canvas x-ref="chartCanvas"></canvas>
</div>
</div>
</template>
<template x-if="measurements.length === 0">
<div class="text-base-content/60 text-sm italic">No measurements</div>
</template>
<!-- Description and Method metadata -->
<template x-if="description || method">
<div class="space-y-2 text-sm">
<template x-if="description">
<div>
<span class="text-base-content/80 font-semibold"
>Description:</span
>
<p
class="text-base-content/70 mt-1 whitespace-pre-wrap"
x-text="description"
></p>
</div>
</template>
<template x-if="method">
<div>
<span class="text-base-content/80 font-semibold">Method:</span>
<span class="text-base-content/70 ml-2" x-text="method"></span>
</div>
</template>
</div>
</template>
</div>
</template>
<!-- Edit mode: editable table with add/remove controls -->
<template x-if="isEditMode">
<div class="space-y-2">
<!-- Measurements table -->
<div class="overflow-x-auto">
<template x-if="measurements.length > 0">
<table class="table-zebra table-sm table">
<thead>
<tr>
<th>Timestamp</th>
<th>Value</th>
<th>Error</th>
<th>Note</th>
<th class="w-12"></th>
</tr>
</thead>
<tbody>
<template
x-for="(measurement, index) in measurements"
:key="index"
>
<tr>
<td>
<input
type="number"
class="input input-bordered input-sm w-full"
:class="{ 'input-error': $store.validationErrors.hasError(fieldName, context) }"
:value="formatTimestamp(measurement.timestamp)"
@input="updateMeasurement(index, 'timestamp', $event.target.value)"
/>
</td>
<td>
<input
type="number"
class="input input-bordered input-sm w-full"
:class="{ 'input-error': $store.validationErrors.hasError(fieldName, context) }"
placeholder="Value"
:value="measurement.value"
@input="updateMeasurement(index, 'value', $event.target.value)"
/>
</td>
<td>
<input
type="number"
class="input input-bordered input-sm w-full"
placeholder="±"
:value="measurement.error"
@input="updateMeasurement(index, 'error', $event.target.value)"
/>
</td>
<td>
<input
type="text"
class="input input-bordered input-sm w-full"
placeholder="Note"
:value="measurement.note"
@input="updateMeasurement(index, 'note', $event.target.value)"
/>
</td>
<td>
<button
type="button"
class="btn btn-ghost btn-sm btn-circle"
@click="removeMeasurement(index)"
title="Remove measurement"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</td>
</tr>
</template>
</tbody>
</table>
</template>
<template x-if="measurements.length === 0">
<div class="text-base-content/60 py-2 text-sm italic">
No measurements yet. Click "Add Measurement" to start.
</div>
</template>
</div>
<!-- Action buttons -->
<div class="flex gap-2">
<button
type="button"
class="btn btn-sm btn-primary"
@click="addMeasurement()"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Add Measurement
</button>
<template x-if="measurements.length > 1">
<button
type="button"
class="btn btn-sm btn-ghost"
@click="sortByTimestamp()"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"
/>
</svg>
Sort by Timestamp
</button>
</template>
</div>
</div>
</template>
<!-- Errors -->
<template x-if="$store.validationErrors.hasError(fieldName, context)">
<div class="label">
<template
x-for="errMsg in $store.validationErrors.getErrors(fieldName, context)"
:key="errMsg"
>
<span class="label-text-alt text-error" x-text="errMsg"></span>
</template>
</div>
</template>
</div>
</div>

View File

@ -21,6 +21,10 @@
type="text/css"
/>
{# Chart.js - For timeseries charts #}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="{% static 'js/utils/timeseries-chart.js' %}"></script>
{# Alpine.js - For reactive components #}
<script
defer
@ -30,6 +34,8 @@
<script src="{% static 'js/alpine/search.js' %}"></script>
<script src="{% static 'js/alpine/pagination.js' %}"></script>
<script src="{% static 'js/alpine/pathway.js' %}"></script>
<script src="{% static 'js/alpine/components/schema-form.js' %}"></script>
<script src="{% static 'js/alpine/components/widgets.js' %}"></script>
{# Font Awesome #}
<link

View File

@ -176,8 +176,8 @@
href="#"
class="example-link hover:text-primary cursor-pointer"
title="load example"
@click.prevent="loadExample('CC(C)CC1=CC=C(C=C1)C(C)C(=O)O', $el)"
>Ibuprofen</a
@click.prevent="loadExample('COC(=O)[C@H](CC1=CC=CC=C1)NC(=O)[C@H](CC(=O)O)N', $el)"
>Aspartame</a
>
</div>
<a
@ -287,7 +287,7 @@
</p>
<div class="float-right flex flex-row gap-4">
<a href="/about" class="btn btn-ghost-neutral">Read More</a>
<a href="/about" class="btn btn-neutral">Publications</a>
<a href="/cite" class="btn btn-neutral">Publications</a>
</div>
</div>
</div>

View File

@ -4,17 +4,152 @@
id="new_scenario_modal"
class="modal"
x-data="{
...modalForm(),
isSubmitting: false,
error: null,
scenarioType: 'empty',
schemas: {},
groupSchemas: {},
loadingSchemas: false,
formData: {
name: '',
description: '',
dateYear: '',
dateMonth: '',
dateDay: '',
scenarioType: 'empty',
additionalInformation: {}
},
// Track form data from each schema renderer
schemaFormData: {},
validateYear(el) {
if (el.value && el.value.length < 4) {
el.value = new Date().getFullYear();
}
},
async init() {
try {
this.loadingSchemas = true;
// Ensure API client is available
if (!window.AdditionalInformationApi) {
throw new Error('Additional Information API client not loaded. Please refresh the page.');
}
// Use single API call to load schemas organized by groups
const { schemas, groupSchemas } =
await window.AdditionalInformationApi.loadSchemasWithGroups(['soil', 'sludge', 'sediment']);
this.schemas = schemas;
this.groupSchemas = groupSchemas;
} catch (err) {
this.error = err.message;
console.error('Error loading schemas:', err);
} finally {
this.loadingSchemas = false;
}
},
reset() {
this.isSubmitting = false;
this.error = null;
this.scenarioType = 'empty';
this.formData = {
name: '',
description: '',
dateYear: '',
dateMonth: '',
dateDay: '',
scenarioType: 'empty',
additionalInformation: {}
};
this.schemaFormData = {};
},
setSchemaFormData(schemaName, data) {
this.schemaFormData[schemaName] = data;
},
async submit() {
if (!this.formData.name || this.formData.name.trim() === '') {
this.error = 'Please enter a scenario name';
return;
}
this.isSubmitting = true;
this.error = null;
try {
// Build scenario date
let scenarioDate = this.formData.dateYear || '';
if (this.formData.dateMonth && this.formData.dateMonth.trim() !== '') {
scenarioDate += `-${parseInt(this.formData.dateMonth).toString().padStart(2, '0')}`;
if (this.formData.dateDay && this.formData.dateDay.trim() !== '') {
scenarioDate += `-${parseInt(this.formData.dateDay).toString().padStart(2, '0')}`;
}
}
if (!scenarioDate || scenarioDate.trim() === '') {
scenarioDate = 'No date';
}
// Collect additional information from schema forms
const additionalInformation = [];
const currentGroupSchemas = this.groupSchemas[this.scenarioType] || {};
for (const schemaName in this.schemaFormData) {
const data = this.schemaFormData[schemaName];
// Only include if schema belongs to current group and has data
if (currentGroupSchemas[schemaName] && data && Object.keys(data).length > 0) {
// Check if data has any non-null/non-empty values
const hasData = Object.values(data).some(val => {
if (val === null || val === undefined || val === '') return false;
if (typeof val === 'object' && val !== null) {
// For interval objects, check if start or end has value
if (val.start !== null && val.start !== undefined && val.start !== '') return true;
if (val.end !== null && val.end !== undefined && val.end !== '') return true;
return false;
}
return true;
});
if (hasData) {
additionalInformation.push({
type: schemaName,
data: data
});
}
}
}
// Build payload
const payload = {
name: this.formData.name.trim(),
description: this.formData.description ? this.formData.description.trim() : '',
scenario_date: scenarioDate,
scenario_type: this.scenarioType === 'empty' ? 'Not specified' : this.scenarioType,
additional_information: additionalInformation
};
const packageUuid = '{{ meta.current_package.uuid }}';
// Use API client for scenario creation
const result = await window.AdditionalInformationApi.createScenario(packageUuid, payload);
// Close modal and redirect to new scenario
document.getElementById('new_scenario_modal').close();
window.location.href = result.url || `{{ meta.current_package.url }}/scenario/${result.uuid}`;
} catch (err) {
this.error = err.message;
} finally {
this.isSubmitting = false;
}
}
}"
@close="reset()"
@schema-form-data-changed.window="setSchemaFormData($event.detail.schemaName, $event.detail.data)"
>
<div class="modal-box max-w-3xl">
<div class="modal-box max-w-3xl max-h-[90vh] overflow-y-auto">
<!-- Header -->
<h3 class="text-lg font-bold">New Scenario</h3>
@ -30,135 +165,212 @@
<!-- Body -->
<div class="py-4">
<form
id="new-scenario-modal-form"
accept-charset="UTF-8"
action="{{ meta.current_package.url }}/scenario"
method="post"
>
{% csrf_token %}
<div class="alert alert-info mb-4">
<span>
Please enter name, description, and date of scenario. Date should be
associated to the data, not the current date. For example, this could
reflect the publishing date of a study. You can leave all fields but
the name empty and fill them in later.
<a
target="_blank"
href="https://wiki.envipath.org/index.php/scenario"
class="link"
>wiki &gt;&gt;</a
>
</span>
</div>
<div class="alert alert-info mb-4">
<span>
Please enter name, description, and date of scenario. Date should be
associated to the data, not the current date. For example, this
could reflect the publishing date of a study. You can leave all
fields but the name empty and fill them in later.
<a
target="_blank"
href="https://wiki.envipath.org/index.php/scenario"
class="link"
>wiki &gt;&gt;</a
>
</span>
<!-- Error state -->
<template x-if="error">
<div class="alert alert-error mb-4">
<span x-text="error"></span>
</div>
</template>
<div class="form-control mb-3">
<label class="label" for="scenario-name">
<span class="label-text">Name</span>
</label>
<input
id="scenario-name"
name="scenario-name"
class="input input-bordered w-full"
placeholder="Name"
required
/>
<!-- Loading state -->
<template x-if="loadingSchemas">
<div class="flex items-center justify-center p-4">
<span class="loading loading-spinner loading-md"></span>
</div>
</template>
<div class="form-control mb-3">
<label class="label" for="scenario-description">
<span class="label-text">Description</span>
</label>
<input
id="scenario-description"
name="scenario-description"
class="input input-bordered w-full"
placeholder="Description"
/>
</div>
<div class="form-control mb-3">
<label class="label">
<span class="label-text">Date</span>
</label>
<div class="flex gap-2">
<!-- Form fields -->
<template x-if="!loadingSchemas">
<div>
<div class="form-control mb-3">
<label class="label" for="scenario-name">
<span class="label-text">Name</span>
</label>
<input
type="number"
id="dateYear"
name="scenario-date-year"
class="input input-bordered w-24"
placeholder="YYYY"
max="{% now 'Y' %}"
@blur="validateYear($el)"
/>
<input
type="number"
id="dateMonth"
name="scenario-date-month"
min="1"
max="12"
class="input input-bordered w-20"
placeholder="MM"
/>
<input
type="number"
id="dateDay"
name="scenario-date-day"
min="1"
max="31"
class="input input-bordered w-20"
placeholder="DD"
id="scenario-name"
type="text"
class="input input-bordered w-full"
placeholder="Name"
x-model="formData.name"
required
/>
</div>
</div>
<div class="form-control mb-3">
<label class="label">
<span class="label-text">Scenario Type</span>
</label>
<div role="tablist" class="tabs tabs-border">
<button
type="button"
role="tab"
class="tab"
:class="{ 'tab-active': scenarioType === 'empty' }"
@click="scenarioType = 'empty'"
>
Empty Scenario
</button>
{% for k, v in scenario_types.items %}
<div class="form-control mb-3">
<label class="label" for="scenario-description">
<span class="label-text">Description</span>
</label>
<input
id="scenario-description"
type="text"
class="input input-bordered w-full"
placeholder="Description"
x-model="formData.description"
/>
</div>
<div class="form-control mb-3">
<label class="label">
<span class="label-text">Date</span>
</label>
<div class="flex gap-2">
<input
type="number"
class="input input-bordered w-24"
placeholder="YYYY"
max="{% now 'Y' %}"
x-model="formData.dateYear"
@blur="validateYear($el)"
/>
<input
type="number"
min="1"
max="12"
class="input input-bordered w-20"
placeholder="MM"
x-model="formData.dateMonth"
/>
<input
type="number"
min="1"
max="31"
class="input input-bordered w-20"
placeholder="DD"
x-model="formData.dateDay"
/>
</div>
</div>
<div class="form-control mb-3">
<label class="label">
<span class="label-text">Scenario Type</span>
</label>
<div role="tablist" class="tabs tabs-border">
<button
type="button"
role="tab"
class="tab"
:class="{ 'tab-active': scenarioType === '{{ v.name }}' }"
@click="scenarioType = '{{ v.name }}'"
:class="{ 'tab-active': scenarioType === 'empty' }"
@click="scenarioType = 'empty'"
>
{{ k }}
Empty Scenario
</button>
{% endfor %}
<button
type="button"
role="tab"
class="tab"
:class="{ 'tab-active': scenarioType === 'soil' }"
@click="scenarioType = 'soil'"
>
Soil Data
</button>
<button
type="button"
role="tab"
class="tab"
:class="{ 'tab-active': scenarioType === 'sludge' }"
@click="scenarioType = 'sludge'"
>
Sludge Data
</button>
<button
type="button"
role="tab"
class="tab"
:class="{ 'tab-active': scenarioType === 'sediment' }"
@click="scenarioType = 'sediment'"
>
Water-Sediment System Data
</button>
</div>
</div>
<input
type="hidden"
id="scenario-type"
name="scenario-type"
x-model="scenarioType"
/>
</div>
{% for type in scenario_types.values %}
<div
id="{{ type.name }}-specific-inputs"
x-show="scenarioType === '{{ type.name }}'"
x-cloak
>
{% for widget in type.widgets %}
{{ widget|safe }}
{% endfor %}
</div>
{% endfor %}
</form>
<!-- Schema forms for each scenario type -->
<template x-if="scenarioType === 'soil'">
<div class="space-y-4 mt-4">
<template
x-for="(rjsf, schemaName) in groupSchemas.soil"
:key="schemaName"
>
<div
x-data="schemaRenderer({
rjsf: rjsf,
mode: 'edit'
})"
x-init="await init();
const currentSchemaName = schemaName;
$watch('data', (value) => {
$dispatch('schema-form-data-changed', { schemaName: currentSchemaName, data: value });
}, { deep: true })"
>
{% include "components/schema_form.html" %}
</div>
</template>
</div>
</template>
<template x-if="scenarioType === 'sludge'">
<div class="space-y-4 mt-4">
<template
x-for="(rjsf, schemaName) in groupSchemas.sludge"
:key="schemaName"
>
<div
x-data="schemaRenderer({
rjsf: rjsf,
mode: 'edit'
})"
x-init="await init();
const currentSchemaName = schemaName;
$watch('data', (value) => {
$dispatch('schema-form-data-changed', { schemaName: currentSchemaName, data: value });
}, { deep: true })"
>
{% include "components/schema_form.html" %}
</div>
</template>
</div>
</template>
<template x-if="scenarioType === 'sediment'">
<div class="space-y-4 mt-4">
<template
x-for="(rjsf, schemaName) in groupSchemas.sediment"
:key="schemaName"
>
<div
x-data="schemaRenderer({
rjsf: rjsf,
mode: 'edit'
})"
x-init="await init();
const currentSchemaName = schemaName;
$watch('data', (value) => {
$dispatch('schema-form-data-changed', { schemaName: currentSchemaName, data: value });
}, { deep: true })"
>
{% include "components/schema_form.html" %}
</div>
</template>
</div>
</template>
</div>
</template>
</div>
<!-- Footer -->
@ -174,8 +386,8 @@
<button
type="button"
class="btn btn-primary"
@click="submit('new-scenario-modal-form')"
:disabled="isSubmitting"
@click="submit()"
:disabled="isSubmitting || loadingSchemas"
>
<span x-show="!isSubmitting">Submit</span>
<span

View File

@ -6,27 +6,117 @@
x-data="{
isSubmitting: false,
selectedType: '',
schemas: {},
loadingSchemas: false,
error: null,
formData: null, // Store reference to form data
formRenderKey: 0, // Counter to force form re-render
existingTypes: [], // Track existing additional information types
// Get sorted unique schema names for dropdown, excluding already-added types
get sortedSchemaNames() {
const names = Object.keys(this.schemas);
// Remove duplicates, exclude existing types, and sort alphabetically by display title
const unique = [...new Set(names)];
const available = unique.filter(name =>
!this.existingTypes.includes(name) || this.schemas[name]?.schema?.['x-repeatable']
);
return available.sort((a, b) => {
const titleA = (this.schemas[a]?.schema?.['x-title'] || a).toLowerCase();
const titleB = (this.schemas[b]?.schema?.['x-title'] || b).toLowerCase();
return titleA.localeCompare(titleB);
});
},
async init() {
// Watch for selectedType changes
this.$watch('selectedType', (value) => {
// Reset formData when type changes and increment key to force re-render
this.formData = null;
this.formRenderKey++;
// Clear previous errors
this.error = null;
Alpine.store('validationErrors').clearErrors(); // No context - clears all
});
// Load schemas and existing items
try {
this.loadingSchemas = true;
const scenarioUuid = '{{ scenario.uuid }}';
const [schemasRes, itemsRes] = await Promise.all([
fetch('/api/v1/information/schema/'),
fetch(`/api/v1/scenario/${scenarioUuid}/information/`)
]);
if (!schemasRes.ok) throw new Error('Failed to load schemas');
if (!itemsRes.ok) throw new Error('Failed to load existing items');
this.schemas = await schemasRes.json();
const items = await itemsRes.json();
// Get unique existing types (normalize to lowercase)
this.existingTypes = [...new Set(items.map(item => item.type.toLowerCase()))];
} catch (err) {
this.error = err.message;
} finally {
this.loadingSchemas = false;
}
},
reset() {
this.isSubmitting = false;
this.selectedType = '';
this.error = null;
this.formData = null;
Alpine.store('validationErrors').clearErrors(); // No context - clears all
},
submit() {
setFormData(data) {
this.formData = data;
},
async submit() {
if (!this.selectedType) return;
const form = document.getElementById('add_' + this.selectedType + '_add-additional-information-modal-form');
if (form && form.checkValidity()) {
this.isSubmitting = true;
form.submit();
} else if (form) {
form.reportValidity();
const payload = window.AdditionalInformationApi.sanitizePayload(this.formData);
// Validate that form has data
if (!payload || Object.keys(payload).length === 0) {
this.error = 'Please fill in at least one field';
return;
}
this.isSubmitting = true;
this.error = null;
try {
const scenarioUuid = '{{ scenario.uuid }}';
await window.AdditionalInformationApi.createItem(
scenarioUuid,
this.selectedType,
payload
);
// Close modal and reload page to show new item
document.getElementById('add_additional_information_modal').close();
window.location.reload();
} catch (err) {
if (err.isValidationError && err.fieldErrors) {
// No context for add modal - simple flat errors
Alpine.store('validationErrors').setErrors(err.fieldErrors);
this.error = err.message || 'Please correct the errors in the form';
} else {
this.error = err.message || 'An error occurred. Please try again.';
}
} finally {
this.isSubmitting = false;
}
}
}"
@close="reset()"
@form-data-ready="formData = $event.detail"
>
<div class="modal-box">
<div class="modal-box max-w-2xl">
<!-- Header -->
<h3 class="text-lg font-bold">Add Additional Information</h3>
@ -42,46 +132,60 @@
<!-- Body -->
<div class="py-4">
<div class="form-control">
<label class="label" for="select-additional-information-type">
<span class="label-text">Select the type to add</span>
</label>
<select
id="select-additional-information-type"
class="select select-bordered w-full"
x-model="selectedType"
>
<option value="" selected disabled>Select the type to add</option>
{% for add_inf in available_additional_information %}
<option value="{{ add_inf.name }}">
{{ add_inf.display_name }}
</option>
{% endfor %}
</select>
</div>
{% for add_inf in available_additional_information %}
<div
class="mt-4"
x-show="selectedType === '{{ add_inf.name }}'"
x-cloak
>
<form
id="add_{{ add_inf.name }}_add-additional-information-modal-form"
accept-charset="UTF-8"
action=""
method="post"
>
{% csrf_token %}
{{ add_inf.widget|safe }}
<input
type="hidden"
name="hidden"
value="add-additional-information"
/>
</form>
<!-- Loading state -->
<template x-if="loadingSchemas">
<div class="flex items-center justify-center p-4">
<span class="loading loading-spinner loading-md"></span>
</div>
{% endfor %}
</template>
<!-- Error state -->
<template x-if="error">
<div class="alert alert-error mb-4">
<span x-text="error"></span>
</div>
</template>
<!-- Schema selection -->
<template x-if="!loadingSchemas">
<div>
<div class="form-control mb-4">
<label class="label" for="select-additional-information-type">
<span class="label-text">Select the type to add</span>
</label>
<select
id="select-additional-information-type"
class="select select-bordered w-full"
x-model="selectedType"
>
<option value="" selected disabled>Select the type to add</option>
<template x-for="name in sortedSchemaNames" :key="name">
<option
:value="name"
x-text="(schemas[name].schema && (schemas[name].schema['x-title'] || schemas[name].schema.title)) || name"
></option>
</template>
</select>
</div>
<!-- Form renderer for selected type -->
<!-- Use unique key per type to force re-render -->
<template x-for="renderKey in [formRenderKey]" :key="renderKey">
<div x-show="selectedType && schemas[selectedType]">
<div
x-data="schemaRenderer({
rjsf: schemas[selectedType],
mode: 'edit'
// No context - single form, backward compatible
})"
x-init="await init(); $dispatch('form-data-ready', data)"
>
{% include "components/schema_form.html" %}
</div>
</div>
</template>
</div>
</template>
</div>
<!-- Footer -->
@ -98,7 +202,7 @@
type="button"
class="btn btn-primary"
@click="submit()"
:disabled="isSubmitting || !selectedType"
:disabled="isSubmitting || !selectedType || loadingSchemas"
>
<span x-show="!isSubmitting">Add</span>
<span

View File

@ -93,7 +93,10 @@
>
{% csrf_token %}
<div class="flex items-center gap-2">
<span class="flex-1">{{ u.username }}</span>
<span class="flex-1"
>{{ u.username }}
{% if not u.is_active %}<i>(inactive)</i>{% endif %}</span
>
<input type="hidden" name="member" value="{{ u.url }}" />
<input type="hidden" name="action" value="remove" />
<button type="submit" class="btn btn-error btn-sm">

View File

@ -146,6 +146,7 @@
<div class="grid grid-cols-12 gap-2 items-center">
<div class="col-span-5 truncate">
{{ up.user.username }}
{% if not up.user.is_active %}<i>(inactive)</i>{% endif %}
<input
type="hidden"
name="grantee"

View File

@ -0,0 +1,91 @@
{% load static %}
<dialog
id="edit_scenario_modal"
class="modal"
x-data="modalForm()"
@close="reset()"
>
<div class="modal-box">
<!-- Header -->
<h3 class="font-bold text-lg">Edit Scenario</h3>
<!-- Close button (X) -->
<form method="dialog">
<button
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
:disabled="isSubmitting"
>
</button>
</form>
<!-- Body -->
<div class="py-4">
<form
id="edit-scenario-modal-form"
accept-charset="UTF-8"
action=""
method="post"
>
{% csrf_token %}
<div class="form-control mb-3">
<label class="label" for="scenario-name">
<span class="label-text">Name</span>
</label>
<input
id="scenario-name"
class="input input-bordered w-full"
name="scenario-name"
value="{{ scenario.name|safe }}"
required
/>
</div>
<div class="form-control mb-3">
<label class="label" for="scenario-description">
<span class="label-text">Description</span>
</label>
<input
id="scenario-description"
type="text"
class="input input-bordered w-full"
value="{{ scenario.description|safe }}"
name="scenario-description"
/>
</div>
</form>
</div>
<!-- Footer -->
<div class="modal-action">
<button
type="button"
class="btn"
onclick="this.closest('dialog').close()"
:disabled="isSubmitting"
>
Close
</button>
<button
type="button"
class="btn btn-primary"
@click="submit('edit-scenario-modal-form')"
:disabled="isSubmitting"
>
<span x-show="!isSubmitting">Update</span>
<span
x-show="isSubmitting"
class="loading loading-spinner loading-sm"
></span>
<span x-show="isSubmitting">Updating...</span>
</button>
</div>
</div>
<!-- Backdrop -->
<form method="dialog" class="modal-backdrop">
<button :disabled="isSubmitting">close</button>
</form>
</dialog>

View File

@ -3,10 +3,97 @@
<dialog
id="update_scenario_additional_information_modal"
class="modal"
x-data="modalForm()"
x-data="{
isSubmitting: false,
items: [],
schemas: {},
loading: false,
error: null,
originalItems: [], // Store original data to detect changes
modifiedUuids: new Set(), // Track which items were modified
async init() {
try {
this.loading = true;
const scenarioUuid = '{{ scenario.uuid }}';
const { items, schemas } =
await window.AdditionalInformationApi.loadSchemasAndItems(scenarioUuid);
this.schemas = schemas;
// Store deep copy of original items for comparison
this.originalItems = JSON.parse(JSON.stringify(items));
this.items = items;
} catch (err) {
this.error = err.message;
} finally {
this.loading = false;
}
},
reset() {
this.isSubmitting = false;
this.error = null;
this.modifiedUuids.clear();
Alpine.store('validationErrors').clearErrors(); // Clear all contexts
},
updateItemData(uuid, data) {
// Update the item's data in the items array
const item = this.items.find(i => i.uuid === uuid);
if (item) {
item.data = data;
// Mark this item as modified
this.modifiedUuids.add(uuid);
}
},
async submit() {
if (this.items.length === 0) {
this.error = 'No data to update';
return;
}
// Filter to only items that were actually modified
const modifiedItems = this.items.filter(item => this.modifiedUuids.has(item.uuid));
if (modifiedItems.length === 0) {
this.error = 'No changes to save';
return;
}
this.isSubmitting = true;
this.error = null;
try {
const scenarioUuid = '{{ scenario.uuid }}';
// Use the unified API client for sequential, safe updates - only modified items
await window.AdditionalInformationApi.updateItems(scenarioUuid, modifiedItems);
// Close modal and reload page
document.getElementById('update_scenario_additional_information_modal').close();
window.location.reload();
} catch (err) {
// Handle validation errors with field-level details
if (err.isValidationError && err.fieldErrors) {
this.error = err.message || 'Please correct the errors in the form';
// Backend returns errors keyed by UUID, each with field-level error arrays
// Set errors for each item with its UUID as context
Object.entries(err.fieldErrors).forEach(([uuid, fieldErrors]) => {
Alpine.store('validationErrors').setErrors(fieldErrors, uuid);
});
} else {
this.error = err.message || 'An error occurred. Please try again.';
}
} finally {
this.isSubmitting = false;
}
}
}"
@close="reset()"
@update-item-data.window="updateItemData($event.detail.uuid, $event.detail.data)"
>
<div class="modal-box">
<div class="modal-box max-h-[90vh] max-w-4xl overflow-y-auto">
<!-- Header -->
<h3 class="text-lg font-bold">Update Additional Information</h3>
@ -22,18 +109,40 @@
<!-- Body -->
<div class="py-4">
<form
id="edit-scenario-additional-information-modal-form"
accept-charset="UTF-8"
action=""
method="post"
>
{% csrf_token %}
{% for widget in update_widgets %}
{{ widget|safe }}
{% endfor %}
<input type="hidden" name="hidden" value="set-additional-information" />
</form>
<!-- Loading state -->
{% include "components/modals/loading_state.html" with loading_var="loading" %}
<!-- Error state -->
{% include "components/modals/error_state.html" with error_var="error" %}
<!-- Items list -->
<template x-if="!loading">
<div class="space-y-4">
<template x-if="items.length === 0">
<p class="text-base-content/60">
No additional information to update.
</p>
</template>
<template x-for="(item, index) in items" :key="item.uuid">
<div class="card bg-base-200 shadow-sm">
<div class="card-body p-4">
<div
x-data="schemaRenderer({
rjsf: schemas[item.type.toLowerCase()],
data: item.data,
mode: 'edit',
context: item.uuid // Pass item UUID as context for error scoping
})"
x-init="await init(); $watch('data', (value) => { $dispatch('update-item-data', { uuid: item.uuid, data: value }) }, { deep: true })"
>
{% include "components/schema_form.html" %}
</div>
</div>
</div>
</template>
</div>
</template>
</div>
<!-- Footer -->
@ -49,10 +158,17 @@
<button
type="button"
class="btn btn-primary"
@click="submit('edit-scenario-additional-information-modal-form')"
:disabled="isSubmitting"
@click="submit()"
:disabled="isSubmitting || loading || items.length === 0 || modifiedUuids.size === 0"
>
<span x-show="!isSubmitting">Update</span>
<span x-show="!isSubmitting">
<template x-if="modifiedUuids.size > 0">
<span x-text="`Update (${modifiedUuids.size})`"></span>
</template>
<template x-if="modifiedUuids.size === 0">
<span>No Changes</span>
</template>
</span>
<span
x-show="isSubmitting"
class="loading loading-spinner loading-sm"

View File

@ -85,6 +85,55 @@
</div>
{% endif %}
{% if compound_structure.half_lifes %}
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" />
<div class="collapse-title text-xl font-medium">Half-lives</div>
<div class="collapse-content">
<div class="overflow-x-auto">
<table class="table-zebra table">
<thead>
<tr>
<th>Scenario</th>
<th>Values</th>
</tr>
</thead>
<tbody>
{% for scenario, half_lifes in compound_structure.half_lifes.items %}
<tr>
<td>
<a href="{{ scenario.url }}" class="hover:bg-base-200"
>{{ scenario.name }}
<i>({{ scenario.package.name }})</i></a
>
</td>
<td>
<table class="table-zebra table">
<tbody>
<tr>
<td>Scenario Type</td>
<td>{{ scenario.scenario_type }}</td>
</tr>
<tr>
<td>Half-life (days)</td>
<td>{{ half_lifes.0.dt50 }}</td>
</tr>
<tr>
<td>Model</td>
<td>{{ half_lifes.0.model }}</td>
</tr>
</tbody>
</table>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
{% if compound_structure.scenarios.all %}
<!-- Scenarios -->
<div class="collapse-arrow bg-base-200 collapse">

View File

@ -14,7 +14,7 @@
<div class="card bg-base-100">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-2xl">{{ edge.edge_label.name }}</h2>
<h2 class="card-title text-2xl">{{ edge.get_name }}</h2>
<div id="actionsButton" class="dropdown dropdown-end hidden">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm">
<svg
@ -45,6 +45,11 @@
</ul>
</div>
</div>
<p class="mt-2">
The underlying reaction can be found
<a href="{{ edge.edge_label.url }}" class="link link-primary">here</a
>.
</p>
</div>
</div>

View File

@ -57,7 +57,8 @@
{% for um in group.user_member.all %}
<li>
<a href="{{ um.url }}" class="hover:bg-base-300"
>{{ um.username }}</a
>{{ um.username }}
{% if not um.is_active %}<i>(inactive)</i>{% endif %}</a
>
</li>
{% endfor %}

View File

@ -103,7 +103,7 @@
</ul>
</div>
</div>
{% elif job.job_name == 'batch_predict' %}
{% elif job.job_name == 'batch_predict' or job.job_name == 'identify_missing_rules' %}
<div
id="table-container"
class="overflow-x-auto overflow-y-auto max-h-96 border rounded-lg"

View File

@ -14,7 +14,7 @@
<div class="card bg-base-100">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-2xl">{{ node.name }}</h2>
<h2 class="card-title text-2xl">{{ node.get_name }}</h2>
<div id="actionsButton" class="dropdown dropdown-end hidden">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm">
<svg
@ -54,28 +54,6 @@
</div>
</div>
<!-- Description -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Description</div>
<div class="collapse-content">{{ node.description }}</div>
</div>
{% if node.aliases %}
<!-- Aliases -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Aliases</div>
<div class="collapse-content">
<ul class="menu bg-base-100 rounded-box">
{% for alias in node.aliases %}
<li><a class="hover:bg-base-200">{{ alias }}</a></li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
<!-- Image Representation -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
@ -96,6 +74,70 @@
<div class="collapse-content">{{ node.default_node_label.smiles }}</div>
</div>
{% if node.default_node_label.aliases %}
<!-- Aliases -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Aliases</div>
<div class="collapse-content">
<ul class="menu bg-base-100 rounded-box">
{% for alias in node.default_node_label.aliases %}
<li><a class="hover:bg-base-200">{{ alias }}</a></li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
{% if node.default_node_label.half_lifes %}
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" />
<div class="collapse-title text-xl font-medium">Half-lives</div>
<div class="collapse-content">
<div class="overflow-x-auto">
<table class="table-zebra table">
<thead>
<tr>
<th>Scenario</th>
<th>Values</th>
</tr>
</thead>
<tbody>
{% for scenario, half_lifes in node.default_node_label.half_lifes.items %}
<tr>
<td>
<a href="{{ scenario.url }}" class="hover:bg-base-200"
>{{ scenario.name }}
<i>({{ scenario.package.name }})</i></a
>
</td>
<td>
<table class="table-zebra table">
<tbody>
<tr>
<td>Scenario Type</td>
<td>{{ scenario.scenario_type }}</td>
</tr>
<tr>
<td>Half-life (days)</td>
<td>{{ half_lifes.0.dt50 }}</td>
</tr>
<tr>
<td>Model</td>
<td>{{ half_lifes.0.model }}</td>
</tr>
</tbody>
</table>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
{% if node.scenarios.all %}
<!-- Scenarios -->
<div class="collapse-arrow bg-base-200 collapse">

View File

@ -61,6 +61,11 @@
stroke-opacity: 0.6;
}
.has_timeseries {
stroke: #3b82f6;
stroke-width: 3px;
}
.highlighted {
stroke: red;
stroke-width: 3px;
@ -134,30 +139,30 @@
{% include "actions/objects/pathway.html" %}
</ul>
</div>
{% if pathway.setting.model.app_domain %}
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-eye"
>
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
</svg>
View
</div>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2"
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-eye"
>
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
</svg>
View
</div>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2"
>
{% if pathway.setting.model.app_domain %}
<li>
<a id="app-domain-toggle-button" class="cursor-pointer">
<svg
@ -181,9 +186,28 @@
App Domain View
</a>
</li>
</ul>
</div>
{% endif %}
{% endif %}
<li>
<a id="timeseries-toggle-button" class="cursor-pointer">
<svg
id="timeseries-icon"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
OECD 301F View
</a>
</li>
</ul>
</div>
</div>
<div class="flex-none gap-2">
<button
@ -276,7 +300,7 @@
<div
x-show="showUpdateNotice"
x-cloak
class="alert alert-info absolute right-4 bottom-4 left-4 z-10"
class="alert alert-info absolute top-4 right-4 left-4 z-10"
>
<span x-html="updateMessage"></span>
<button @click="reloadPage()" class="btn btn-primary btn-sm mt-2">
@ -306,6 +330,21 @@
>
<path d="M 0 0 L 10 5 L 0 10 z" fill="#999" />
</marker>
<marker
id="doublearrow"
viewBox="0 0 20 30"
refX="53"
refY="5"
markerWidth="18"
markerHeight="18"
orient="auto-start-reverse"
markerUnits="userSpaceOnUse"
>
<!-- first triangle -->
<path d="M 0 0 L 10 5 L 0 10 z" fill="#999" />
<!-- second triangle -->
<path d="M 10 0 L 20 5 L 10 10 Z" fill="#999" />
</marker>
<marker
id="arrow_passes_app_domain"
viewBox="0 0 10 10"
@ -400,6 +439,8 @@
<script>
// Global switch for app domain view
var appDomainViewEnabled = false;
// Global switch for timeseries view
var timeseriesViewEnabled = false;
function goFullscreen(id) {
var element = document.getElementById(id);
@ -493,6 +534,35 @@
});
}
// Timeseries toggle
const timeseriesBtn = document.getElementById("timeseries-toggle-button");
if (timeseriesBtn) {
timeseriesBtn.addEventListener("click", function () {
timeseriesViewEnabled = !timeseriesViewEnabled;
const icon = document.getElementById("timeseries-icon");
if (timeseriesViewEnabled) {
icon.innerHTML =
'<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" style="stroke:#3b82f6"/>';
nodes.forEach((x) => {
if (x.timeseries) {
d3.select(x.el)
.select("circle")
.classed("has_timeseries", true);
}
});
} else {
icon.innerHTML =
'<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>';
nodes.forEach((x) => {
d3.select(x.el).select("circle").classed("has_timeseries", false);
});
}
});
}
// Show actions button if there are actions
const actionsButton = document.getElementById("actionsButton");
const actionsList = actionsButton?.querySelector("ul");

View File

@ -1,8 +1,12 @@
{% extends "framework_modern.html" %}
{% load static %}
{% block content %}
<script src="{% static 'js/alpine/components/widgets.js' %}"></script>
<script src="{% static 'js/api/additional-information.js' %}"></script>
{% block action_modals %}
{% include "modals/objects/edit_scenario_modal.html" %}
{% include "modals/objects/add_additional_information_modal.html" %}
{% include "modals/objects/update_scenario_additional_information_modal.html" %}
{% include "modals/objects/generic_delete_modal.html" %}
@ -57,46 +61,88 @@
</div>
</div>
<!-- Additional Information Table -->
<!-- Additional Information -->
<div class="card bg-base-100">
<div class="card-body">
<h3 class="card-title mb-4 text-lg">Additional Information</h3>
<div class="overflow-x-auto">
<table class="table-zebra table">
<thead>
<tr>
<th>Property</th>
<th>Value</th>
<th>Unit</th>
{% if meta.can_edit %}
<th>Remove</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for ai in scenario.get_additional_information %}
<tr>
<td>{{ ai.property_name|safe }}</td>
<td>{{ ai.property_data|safe }}</td>
<td>{{ ai.property_unit|safe }}</td>
{% if meta.can_edit %}
<td>
<form
action="{% url 'package scenario detail' scenario.package.uuid scenario.uuid %}"
method="post"
<div
x-data="{
items: [],
schemas: {},
loading: true,
error: null,
async init() {
try {
// Use the unified API client for loading data
const { items, schemas } = await window.AdditionalInformationApi.loadSchemasAndItems('{{ scenario.uuid }}');
this.items = items;
this.schemas = schemas;
} catch (err) {
this.error = err.message;
console.error('Error loading additional information:', err);
} finally {
this.loading = false;
}
},
async deleteItem(uuid) {
if (!confirm('Are you sure you want to delete this item?')) return;
try {
// Use the unified API client for delete operations
await window.AdditionalInformationApi.deleteItem('{{ scenario.uuid }}', uuid);
// Remove from items array
this.items = this.items.filter(item => item.uuid !== uuid);
} catch (err) {
alert('Error deleting item: ' + err.message);
console.error('Error deleting item:', err);
}
}
}"
>
<!-- Loading state -->
<template x-if="loading">
<div class="flex items-center justify-center p-4">
<span class="loading loading-spinner loading-md"></span>
</div>
</template>
<!-- Error state -->
<template x-if="error">
<div class="alert alert-error mb-4">
<span x-text="error"></span>
</div>
</template>
<!-- Items list -->
<template x-if="!loading && !error">
<div class="space-y-4">
<template x-if="items.length === 0">
<p class="text-base-content/60">
No additional information available.
</p>
</template>
<template x-for="item in items" :key="item.uuid">
<div class="card bg-base-200 shadow-sm">
<div class="card-body p-4">
<div class="flex items-start justify-between">
<div
class="flex-1"
x-data="schemaRenderer({
rjsf: schemas[item.type.toLowerCase()],
data: item.data,
mode: 'view'
})"
x-init="init()"
>
{% csrf_token %}
<input
type="hidden"
name="uuid"
value="{{ ai.uuid }}"
/>
<input
type="hidden"
name="hidden"
value="delete-additional-information"
/>
<button type="submit" class="btn btn-sm btn-ghost">
{% include "components/schema_form.html" %}
</div>
{% if meta.can_edit %}
<button
class="btn btn-sm btn-ghost ml-2"
@click="deleteItem(item.uuid)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
@ -107,59 +153,155 @@
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-minus"
class="lucide lucide-trash"
>
<path d="M5 12h14" />
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
</button>
</form>
</td>
{% endif %}
</tr>
{% endfor %}
{% if meta.can_edit %}
<tr>
<td></td>
<td></td>
<td>Delete all</td>
<td>
<form
action="{% url 'package scenario detail' scenario.package.uuid scenario.uuid %}"
method="post"
>
{% csrf_token %}
<input
type="hidden"
name="hidden"
value="delete-all-additional-information"
/>
<button type="submit" class="btn btn-sm btn-ghost">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-trash"
>
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
</button>
</form>
</td>
</tr>
{% endif %}
</tbody>
</table>
{% endif %}
</div>
</div>
</div>
</template>
</div>
</template>
</div>
</div>
</div>
{% if scenario.parent %}
<div class="card bg-base-100">
<div class="card-body">
<h3 class="card-title mb-4 text-lg">
Parent Scenario Additional Information
</h3>
<div
x-data="{
items: [],
schemas: {},
loading: true,
error: null,
async init() {
try {
// Use the unified API client for loading data
const { items, schemas } = await window.AdditionalInformationApi.loadSchemasAndItems('{{ scenario.parent.uuid }}');
this.items = items;
this.schemas = schemas;
} catch (err) {
this.error = err.message;
console.error('Error loading additional information:', err);
} finally {
this.loading = false;
}
},
}"
>
<!-- Loading state -->
<template x-if="loading">
<div class="flex items-center justify-center p-4">
<span class="loading loading-spinner loading-md"></span>
</div>
</template>
<!-- Error state -->
<template x-if="error">
<div class="alert alert-error mb-4">
<span x-text="error"></span>
</div>
</template>
<!-- Items list -->
<template x-if="!loading && !error">
<div class="space-y-4">
<template x-if="items.length === 0">
<p class="text-base-content/60">
No additional information available.
</p>
</template>
<template x-for="item in items" :key="item.uuid">
<div class="card bg-base-200 shadow-sm">
<div class="card-body p-4">
<div class="flex items-start justify-between">
<div
class="flex-1"
x-data="schemaRenderer({
rjsf: schemas[item.type.toLowerCase()],
data: item.data,
mode: 'view'
})"
x-init="init()"
>
{% include "components/schema_form.html" %}
</div>
</div>
</div>
</div>
</template>
</div>
</template>
</div>
</div>
</div>
{% endif %}
<!-- Pathways -->
{% if scenario.related_pathways %}
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Pathways</div>
<div class="collapse-content">
<ul class="menu bg-base-100 rounded-box">
{% for p in scenario.related_pathways %}
<li>
<a href="{{ p.url }}" class="hover:bg-base-200"
>{{ p.name }} <i>({{ p.package.name }})</i></a
>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
<!-- Related Scenarios -->
{% if children.exists %}
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" />
<div class="collapse-title text-xl font-medium">Related Scenarios</div>
<div class="collapse-content">
<ul class="menu bg-base-100 rounded-box">
{% for s in children %}
<li>
<a href="{{ s.url }}" class="hover:bg-base-200"
>{{ s.name }} <i>({{ s.package.name }})</i></a
>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
<!-- Parent Scenarios -->
{% if scenario.parent %}
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" />
<div class="collapse-title text-xl font-medium">Parent Scenario</div>
<div class="collapse-content">
<ul class="menu bg-base-100 rounded-box">
<li>
<a href="{{ scenario.parent.url }}" class="hover:bg-base-200"
>{{ scenario.parent.name }}
<i>({{ scenario.parent.package.name }})</i></a
>
</li>
</ul>
</div>
</div>
{% endif %}
</div>
<script>

View File

@ -0,0 +1,67 @@
{% extends "framework_modern.html" %}
{% block content %}
{% block action_modals %}
{% endblock action_modals %}
<div class="space-y-2 p-4">
<!-- Header Section -->
<div class="card bg-base-100">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-2xl">{{ setting.name }}</h2>
<div id="actionsButton" class="dropdown dropdown-end hidden">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-wrench"
>
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
/>
</svg>
Actions
</div>
<ul
tabindex="-1"
class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2"
>
{% block actions %}
{% endblock %}
</ul>
</div>
</div>
</div>
</div>
<!-- The actual setting -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-content">
{% with setting_to_render=setting can_be_default=False %}
{% include "objects/setting_template.html" %}
{% endwith %}
</div>
</div>
</div>
<script>
// Show actions button if there are actions
document.addEventListener("DOMContentLoaded", function () {
const actionsButton = document.getElementById("actionsButton");
const actionsList = actionsButton?.querySelector("ul");
if (actionsList && actionsList.children.length > 0) {
actionsButton?.classList.remove("hidden");
}
});
</script>
{% endblock content %}

View File

@ -122,17 +122,27 @@
</select>
</label>
<div class="flex justify-end gap-2">
<a href="{{ meta.current_package.url }}/pathway" class="btn btn-outline"
>Cancel</a
<div class="flex h-fit w-full justify-between">
<a
href="/batch-predict"
class="link-hover text-neutral/50 self-end text-sm"
>More than one compound?</a
>
<button
type="submit"
id="predict-submit-button"
class="btn btn-primary"
>
Predict
</button>
<div class="flex justify-end gap-2">
<a
href="{{ meta.current_package.url }}/pathway"
class="btn btn-outline"
>Cancel</a
>
<button
type="submit"
id="predict-submit-button"
class="btn btn-primary"
>
Predict
</button>
</div>
</div>
</form>
</div>

View File

@ -33,23 +33,15 @@
biodegradation pathways.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">Our Mission</h2>
<p class="mb-4">
Our mission is to advance environmental science through innovative
computational tools that predict and analyze the biotransformation of
chemical compounds. We strive to provide researchers, regulators, and
industry professionals with accurate, accessible tools for
understanding environmental fate and behavior.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">What We Offer</h2>
<h2 class="mt-8 mb-4 text-2xl font-semibold">Services</h2>
<div class="mb-6 grid gap-6 md:grid-cols-2">
<div class="card bg-base-200 shadow-md">
<div class="card-body">
<h3 class="card-title text-primary">Pathway Database</h3>
<p>
Access experimentally observed biotransformation pathways and
reactions from curated scientific literature.
reactions from curated scientific literature and assessment
reports.
</p>
</div>
</div>
@ -57,8 +49,8 @@
<div class="card-body">
<h3 class="card-title text-primary">Prediction System</h3>
<p>
Use our relative reasoning models to predict likely
biotransformation pathways and products for new compounds.
Use our models to predict likely biotransformation pathways and
products for new compounds.
</p>
</div>
</div>
@ -66,8 +58,8 @@
<div class="card-body">
<h3 class="card-title text-primary">Machine Learning Models</h3>
<p>
Leverage advanced ML algorithms trained on extensive
biodegradation data for accurate predictions.
Leverage advanced Machine Learning algorithms trained on
extensive biodegradation data for accurate predictions.
</p>
</div>
</div>
@ -75,37 +67,14 @@
<div class="card-body">
<h3 class="card-title text-primary">Community Platform</h3>
<p>
Join our active community of researchers to share knowledge,
discuss findings, and collaborate.
Join our active community of researchers to access
documentation, get help, share knowledge, discuss findings, and
collaborate.
</p>
</div>
</div>
</div>
<h2 class="mt-8 mb-4 text-2xl font-semibold">Our Technology</h2>
<p class="mb-4">
enviPath employs a unique combination of rule-based and machine
learning approaches to predict biotransformation pathways:
</p>
<ul class="mb-4 list-inside list-disc space-y-2">
<li>
<strong>Relative Reasoning:</strong> Uses structural similarity to
known biotransformations
</li>
<li>
<strong>Rule-Based Systems:</strong> Applies expert-curated
transformation rules
</li>
<li>
<strong>Machine Learning:</strong> Leverages neural networks for
pattern recognition
</li>
<li>
<strong>Hybrid Models:</strong> Combines multiple approaches for
optimal accuracy
</li>
</ul>
<h2 class="mt-8 mb-4 text-2xl font-semibold">Our Partners</h2>
<p class="mb-4">
enviPath is backed by leading research institutions and collaborators:
@ -128,66 +97,41 @@
/>
</div>
<h2 class="mt-8 mb-4 text-2xl font-semibold">Our Team</h2>
<p class="mb-4">
enviPath is developed and maintained by a dedicated team of
computational chemists, environmental scientists, and software
engineers. Our interdisciplinary approach ensures that the platform
meets the needs of the scientific community while remaining accessible
and user-friendly.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">Research Impact</h2>
<p class="mb-4">
Since its inception, enviPath has contributed to numerous scientific
publications and environmental assessments. Our tools are used by:
</p>
<ul class="mb-4 list-inside list-disc space-y-2">
<li>
Academic researchers in environmental chemistry and toxicology
</li>
<li>Regulatory agencies for chemical risk assessment</li>
<li>
Chemical manufacturers for product development and safety evaluation
</li>
<li>Environmental consultants for contamination studies</li>
</ul>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
Open Science Commitment
</h2>
<p class="mb-4">
We are committed to open science principles. enviPath provides free
access to our database and prediction tools for academic research. We
actively contribute to the scientific community through publications,
open-source software, and collaboration.
</p>
<div class="card bg-primary text-primary-content mt-8">
<div class="card-body">
<h3 class="card-title">Get Involved</h3>
<p>
Join our community, contribute data, or collaborate on research
projects.
</p>
<div class="card-actions mt-4 justify-end">
<a
href="https://community.envipath.org/"
target="_blank"
class="btn btn-secondary"
>Visit Community</a
>
<a href="/contact" class="btn btn-ghost">Contact Us</a>
<div class="mb-6 grid gap-6 md:grid-cols-2">
<div class="card bg-primary text-primary-content mt-8">
<div class="card-body">
<h3 class="card-title">Get Involved</h3>
<p>
Join our community, contribute data, or collaborate on research
projects.
</p>
<div class="card-actions mt-4 justify-end">
<a
href="https://community.envipath.org/"
target="_blank"
class="btn btn-secondary"
>Visit Community</a
>
<a href="/contact" class="btn btn-ghost">Contact Us</a>
</div>
</div>
</div>
<div class="card bg-primary text-primary-content mt-8">
<div class="card-body">
<h3 class="card-title">Cite Us</h3>
<p>
To learn more about the science behind enviPath, please visit
our publications page for key publications and how to cite our
work.
</p>
<div class="card-actions mt-4 justify-end">
<a href="/cite" target="_blank" class="btn btn-secondary"
>Publications</a
>
</div>
</div>
</div>
</div>
<h2 class="mt-8 mb-4 text-2xl font-semibold">Publications</h2>
<p class="mb-4">
To learn more about the science behind enviPath, please visit our
<a href="/cite" class="link link-primary">citations page</a> for key
publications and how to cite our work.
</p>
</div>
</div>
</div>

View File

@ -73,20 +73,65 @@
<input type="checkbox" />
<div class="collapse-title text-sm font-medium">Show BibTeX</div>
<div class="collapse-content">
<pre class="overflow-x-auto text-xs"><code>@ARTICLE{Hafner2024,
title = "Advancements in biotransformation pathway prediction:
enhancements, datasets, and novel functionalities in enviPath",
author = "Hafner, Jasmin and Lorsbach, Tim and Schmidt, Sebastian and
Brydon, Liam and Dost, Katharina and Zhang, Kunyang and Fenner,
Kathrin and Wicker, J{\"o}rg",
journal = "J. Cheminform.",
publisher = "Springer Science and Business Media LLC",
volume = 16,
number = 1,
pages = "93",
month = aug,
year = 2024,
doi = "10.1186/s13321-024-00881-6"
<pre
class="overflow-x-auto text-xs"
><code>@ARTICLE{Hafner2024enviPath,
title = "Advancements in biotransformation pathway prediction:
enhancements, datasets, and novel functionalities in enviPath",
author = "Hafner, Jasmin and Lorsbach, Tim and Schmidt, Sebastian and
Brydon, Liam and Dost, Katharina and Zhang, Kunyang and Fenner,
Kathrin and Wicker, J{\"o}rg",
journal = "J. Cheminform.",
publisher = "Springer Science and Business Media LLC",
volume = 16,
number = 1,
pages = "93",
month = aug,
year = 2024,
doi = "10.1186/s13321-024-00881-6"
}</code></pre>
</div>
</div>
</div>
</div>
<div class="card bg-base-200 mb-6">
<div class="card-body">
<h3 class="mb-2 font-semibold">
enviPath The environmental contaminant biotransformation pathway
resource
</h3>
<p class="mb-4 text-sm">
<strong>Authors:</strong> Wicker, J., Lorsbach, T., Gütlein, M.,
Schmid, E., Latino, D., Kramer, S., Fenner, K.<br />
<strong>Journal:</strong> Nucleic Acids Research, 44(D1),
D502-D508<br />
<strong>Year:</strong> 2016<br />
<strong>DOI:</strong>
<a
href="https://doi.org/10.1093/nar/gkv1229"
target="_blank"
class="link link-primary"
>10.1093/nar/gkv1229</a
>
</p>
<div class="collapse-arrow bg-base-300 collapse">
<input type="checkbox" />
<div class="collapse-title text-sm font-medium">Show BibTeX</div>
<div class="collapse-content">
<pre
class="overflow-x-auto text-xs"
><code>@ARTICLE{Wicker2016enviPath,
title = "enviPath The environmental contaminant biotransformation pathway resource",
author = "Wicker, J{\"o}rg and Lorsbach, Tim and Gütlein, Martin and Schmid,
Emanuel and Latino, Diogo and Kramer, Stefan and Fenner, Kathrin",
journal = "Nucleic Acids Research",
volume = 44,
number = "D1",
pages = "D502-D508",
year = 2016,
month = jan,
doi = "10.1093/nar/gkv1229"
}</code></pre>
</div>
</div>
@ -103,57 +148,47 @@ doi = "10.1186/s13321-024-00881-6"
<div class="card bg-base-200 mb-6">
<div class="card-body">
<h3 class="mb-2 font-semibold">
Predicting Biodegradation Pathways Using a Hybrid Relative
Reasoning Model
A Hybrid Machine Learning and Knowledge Based Approach to Limit
Combinatorial Explosion in Biodegradation Prediction
</h3>
<p class="mb-4 text-sm">
<strong>Authors:</strong> Wicker, J., Fenner, K., Ellis, L.,
Kramer, S.<br />
<strong>Journal:</strong> Biotechnology and Bioengineering,
110(3), 837-846<br />
<strong>Year:</strong> 2013<br />
<strong>Authors:</strong> Wicker, J., Fenner, K., Kramer, S.<br />
<strong>Book:</strong> Computational Sustainability, 75-97<br />
<strong>Year:</strong> 2016<br />
<strong>DOI:</strong>
<a
href="https://doi.org/10.1002/bit.24744"
href="https://doi.org/10.1007/978-3-319-31858-5_5"
target="_blank"
class="link link-primary"
>10.1002/bit.24744</a
>10.1007/978-3-319-31858-5_5</a
>
</p>
<div class="collapse-arrow bg-base-300 collapse">
<input type="checkbox" />
<div class="collapse-title text-sm font-medium">Show BibTeX</div>
<div class="collapse-content">
<pre
class="overflow-x-auto text-xs"
><code>@article{wicker2013predicting,
title={Predicting biodegradation pathways using a hybrid relative reasoning model},
author={Wicker, J{\"o}rg and Fenner, Kathrin and Ellis, Lynda and Kramer, Stefan},
journal={Biotechnology and Bioengineering},
volume={110},
number={3},
pages={837--846},
year={2013},
publisher={Wiley Online Library},
doi={10.1002/bit.24744}
<pre class="overflow-x-auto text-xs"><code>@INBOOK{Wicker2016ML,
title="A Hybrid Machine Learning and Knowledge Based Approach to
Limit Combinatorial Explosion in Biodegradation Prediction",
author="Wicker, J{\"o}rg and Fenner, Kathrin and Kramer, Stefan",
editor="L{\"a}ssig, J{\"o}rg and Kersting, Kristian and Morik, Katharina",
bookTitle="Computational Sustainability",
year=2016,
publisher="Springer International Publishing",
pages="75--97",
doi="10.1007/978-3-319-31858-5_5"
}</code></pre>
</div>
</div>
</div>
</div>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
Machine Learning Models
</h2>
<h3 class="mt-6 mb-3 text-xl font-semibold">
enviPath-Transformer (Latest ML Model)
</h3>
<div class="card bg-base-200 mb-6">
<div class="card-body">
<p class="mb-2 text-sm">
If you use the Transformer-based prediction model, please cite:
</p>
<h3 class="mb-2 font-semibold">
Predictive modeling of biodegradation pathways using transformer
architectures
</h3>
<p class="mb-4 text-sm">
<strong>Authors:</strong> Brydon, L., Zhang, K., Dobbie, G.,
Taškova, K., and Wicker, J.<br />
@ -167,31 +202,118 @@ doi = "10.1186/s13321-024-00881-6"
>10.1186/s13321-025-00969-7</a
>
</p>
<div class="collapse-arrow bg-base-300 collapse">
<input type="checkbox" />
<div class="collapse-title text-sm font-medium">Show BibTeX</div>
<div class="collapse-content">
<pre
class="overflow-x-auto text-xs"
><code>@article{Brydon2025Transformer,
title = "Predictive modeling of biodegradation pathways using transformer architectures",
author = "Brydon, Liam and Zhang, Kunyang and Dobbie, Gillian and
Ta\v{s}kova, Katerina and Wicker, J{\"o}rg Simon",
journal = "Journal of Cheminformatics",
volume = 17,
number = 1,
pages = "1--16",
year = 2025,
month = feb,
doi = "10.1186/s13321-025-00969-7",
}</code></pre>
</div>
</div>
</div>
</div>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
Machine Learning Models
</h2>
<h3 class="mt-6 mb-3 text-xl font-semibold">
Relative Reasoning Models
</h3>
<p class="mb-4">
For relative reasoning and machine learning approaches, please cite:
</p>
<div class="card bg-base-200 mb-6">
<div class="card-body">
<p class="mb-2 text-sm">
For relative reasoning and machine learning approaches:
</p>
<h3 class="mb-2 font-semibold">
Predicting biodegradation products and pathways: a hybrid
knowledge- and machine learning-based approach
</h3>
<p class="mb-4 text-sm">
<strong>Authors:</strong> Fenner, K., Gao, J., Kramer, S., Ellis,
L., Wackett, L.<br />
<strong>Journal:</strong> Environmental Science & Technology,
42(15), 5761-5767<br />
<strong>Authors:</strong> Wicker, J., Fenner, K., Ellis, L.,
Wackett, L., Kramer, S.<br />
<strong>Journal:</strong> Bioinformatics, 26(6), 814-821<br />
<strong>Year:</strong> 2010<br />
<strong>DOI:</strong>
<a
href="https://doi.org/10.1093/bioinformatics/btq024"
target="_blank"
class="link link-primary"
>10.1093/bioinformatics/btq024</a
>
</p>
<div class="collapse-arrow bg-base-300 collapse">
<input type="checkbox" />
<div class="collapse-title text-sm font-medium">Show BibTeX</div>
<div class="collapse-content">
<pre class="overflow-x-auto text-xs"><code>@ARTICLE{Wicker2010,
title = "Predicting biodegradation products and pathways:
a hybrid knowledge- and machine learning-based approach",
author = "Wicker, J{\"o}rg and Fenner, Kathrin and Ellis, Lynda and Wackett, Larry and Kramer, Stefan",
journal = "Bioinformatics",
volume = 26,
number = 6,
pages = "814-821",
year = 2010,
month = jan,
doi = "10.1093/bioinformatics/btq024"
}</code></pre>
</div>
</div>
</div>
</div>
<div class="card bg-base-200 mb-6">
<div class="card-body">
<h3 class="mb-2 font-semibold">
Data-driven extraction of relative reasoning rules to limit
combinatorial explosion in biodegradation pathway prediction
</h3>
<p class="mb-4 text-sm">
<strong>Authors:</strong> Fenner, K., Junfeng, G., Kramer, S.,
Ellis, L., Wackett, L.<br />
<strong>Journal:</strong> Bioinformatics, 26(18), 2079-2085<br />
<strong>Year:</strong> 2008<br />
<strong>DOI:</strong>
<a
href="https://doi.org/10.1021/es800408g"
href="https://doi.org/10.1093/bioinformatics/btn378"
target="_blank"
class="link link-primary"
>10.1021/es800408g</a
>10.1093/bioinformatics/btn378</a
>
</p>
<div class="collapse-arrow bg-base-300 collapse">
<input type="checkbox" />
<div class="collapse-title text-sm font-medium">Show BibTeX</div>
<div class="collapse-content">
<pre
class="overflow-x-auto text-xs"
><code>@ARTICLE{Fenner2008Reason,
title = "Data-driven extraction of relative reasoning rules to
limit combinatorial explosion in biodegradation pathway prediction",
author = "Fenner, Kathrin and Gao, Junfeng and Kramer, Stefan and Ellis, Lynda and Wackett, Larry",
journal = "Bioinformatics",
volume = 24,
number = 18,
pages = "2079-2085",
year = 2008,
month = 07,
doi = "10.1093/bioinformatics/btn378"
}</code></pre>
</div>
</div>
</div>
</div>
@ -234,22 +356,13 @@ doi = "10.1186/s13321-024-00881-6"
<ul class="mb-6 list-inside list-disc space-y-2">
<li>
<a
href="https://community.envipath.org/"
href="https://community.envipath.org/t/envipath-publications/"
target="_blank"
class="link link-primary"
>
enviPath Community - Publications Section
</a>
</li>
<li>
<a
href="https://wiki.envipath.org/"
target="_blank"
class="link link-primary"
>
enviPath Documentation - References
</a>
</li>
</ul>
<div class="alert alert-success mt-8">

View File

@ -79,7 +79,7 @@
<p>Browse our comprehensive documentation and tutorials.</p>
<div class="card-actions justify-end">
<a
href="https://wiki.envipath.org/"
href="https://community.envipath.org/c/documentation"
target="_blank"
class="btn btn-neutral"
>Read Docs</a
@ -139,6 +139,18 @@
<h3 class="card-title">enviPath Ltd.</h3>
<p>Biodegradation prediction since 2015</p>
<div class="mt-4 flex gap-4">
<a
href="https://envipath.com/"
target="_blank"
class="btn btn-outline btn-sm"
>
<img
src="{% static '/images/logo-square.svg' %}"
width="24"
height="24"
/>
enviPath.com
</a>
<a
href="https://www.linkedin.com/company/envipath/"
target="_blank"
@ -208,7 +220,7 @@
Simply visit our homepage and try the prediction tool! For full
access to all features, create a free account. Check out our
<a
href="https://wiki.envipath.org/"
href="https://community.envipath.org/c/documentation"
target="_blank"
class="link link-primary"
>documentation</a

View File

@ -39,20 +39,35 @@
</li>
<li>
<strong>Analytics Cookies:</strong> Help us understand how visitors
interact with our platform
interact with our platform (where enabled)
</li>
<li>
<strong>Functional Cookies:</strong> Enable enhanced functionality
and personalization
</li>
<li>
<strong>Security Cookies:</strong> Authenticate users and prevent
fraudulent use
<strong>Security Cookies:</strong> Authenticate users and help
prevent fraudulent use
</li>
</ul>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
3. Types of Cookies We Use
3. Legal Basis and Consent
</h2>
<p class="mb-4">
Essential cookies are used because they are necessary for the Platform
to operate securely and reliably.
</p>
<p class="mb-4">
Where required by applicable law,
<strong>non-essential cookies</strong> are only set after you provide
your consent. You can withdraw or change your consent at any time
using any cookie management tools provided on the Platform (if
available) and/or through your browser settings.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
4. Types of Cookies We Use
</h2>
<div class="mb-6 overflow-x-auto">
@ -77,25 +92,30 @@
</tr>
<tr>
<td><strong>Matomo Analytics</strong></td>
<td>Track usage patterns and improve our services</td>
<td>13 months</td>
<td>Measure usage patterns and improve our services</td>
</tr>
<tr>
<td><strong>OAuth Tokens</strong></td>
<td>Authentication and authorization for API access</td>
<td>Varies</td>
<td>Varies (typically session-based or time-limited)</td>
</tr>
</tbody>
</table>
</div>
<h2 class="mt-8 mb-4 text-2xl font-semibold">4. Matomo Analytics</h2>
<h2 class="mt-8 mb-4 text-2xl font-semibold">5. Matomo Analytics</h2>
<p class="mb-4">
We use Matomo, an open-source web analytics platform, to collect
information about how visitors use enviPath. Matomo uses cookies to
collect standard internet log information and visitor behavior
patterns. The information generated by cookies about your use of the
platform is transmitted to our servers.
We use Matomo, an open-source web analytics platform, to help us
understand how visitors use enviPath. Where enabled, Matomo may use
cookies and similar technologies to collect standard internet log
information and information about visitor behavior patterns.
</p>
<p class="mb-4">
Where required by law, Matomo analytics cookies are only activated
after user consent. We configure Matomo to minimise data collection
and, where applicable, to anonymise IP addresses. Analytics data is
used solely for internal analytics, platform improvement, and system
monitoring.
</p>
<p class="mb-4">We analyze this information to:</p>
<ul class="mb-4 list-inside list-disc space-y-2">
@ -105,10 +125,14 @@
<li>Generate reports on platform usage</li>
</ul>
<h2 class="mt-8 mb-4 text-2xl font-semibold">5. Third-Party Cookies</h2>
<h2 class="mt-8 mb-4 text-2xl font-semibold">6. Third-Party Cookies</h2>
<p class="mb-4">
In addition to our own cookies, we may use various third-party cookies
to report usage statistics and provide integrated services:
Some features of the Platform may integrate third-party services.
These third parties may set their own cookies and process information
independently in accordance with their own privacy and cookie
policies. We do not control the operation of third-party cookies and
recommend reviewing the relevant third-party policies for further
information.
</p>
<ul class="mb-4 list-inside list-disc space-y-2">
<li>
@ -116,12 +140,12 @@
community.envipath.org
</li>
<li>
<strong>External CDNs:</strong> For loading libraries like jQuery
and Font Awesome
<strong>External CDNs:</strong> For loading libraries and assets
(for example, jQuery and Font Awesome)
</li>
</ul>
<h2 class="mt-8 mb-4 text-2xl font-semibold">6. Managing Cookies</h2>
<h2 class="mt-8 mb-4 text-2xl font-semibold">7. Managing Cookies</h2>
<p class="mb-4">
Most web browsers allow you to control cookies through their settings.
However, if you limit the ability of websites to set cookies, you may
@ -144,7 +168,7 @@
/>
</svg>
<span
><strong>Note:</strong> Disabling essential cookies will prevent you
><strong>Note:</strong> Disabling essential cookies may prevent you
from using certain features of enviPath.</span
>
</div>
@ -171,15 +195,15 @@
</ul>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
7. Updates to This Policy
8. Updates to This Policy
</h2>
<p class="mb-4">
We may update this Cookie Policy from time to time to reflect changes
in technology, legislation, or our operations. Please check this page
regularly for updates.
regularly for updates. Changes take effect when posted.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">8. Contact Us</h2>
<h2 class="mt-8 mb-4 text-2xl font-semibold">9. Contact Us</h2>
<p class="mb-4">
If you have questions about our use of cookies, please
<a href="/contact" class="link link-primary">contact us</a>.
@ -199,7 +223,7 @@
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span>Last updated: 2025</span>
<span>version: 1, Last updated: January 2026</span>
</div>
</div>
</div>

View File

@ -1,215 +0,0 @@
{% extends "framework_modern.html" %}
{% load static %}
{% block main_content %}
<div class="mx-auto max-w-4xl px-4 py-8">
<!-- Breadcrumbs -->
<div class="breadcrumbs mb-4 text-sm">
<ul>
<li><a href="/">Home</a></li>
<li>Legal</li>
</ul>
</div>
<!-- Main Content -->
<div class="bg-base-100 rounded-lg p-8 shadow-xl">
<h1 class="mb-6 text-4xl font-bold">Legal Information</h1>
<div class="prose max-w-none">
<p class="mb-6 text-lg">
Welcome to enviPath's legal information center. Here you can find all
our legal documents, policies, and terms that govern the use of our
platform.
</p>
<!-- Legal Documents Grid -->
<div class="mb-8 grid gap-6 md:grid-cols-2">
<!-- Terms of Use -->
<div class="card bg-base-200 shadow-md">
<div class="card-body">
<h3 class="card-title text-primary">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
Terms of Use
</h3>
<p class="mb-4 text-sm">
Our terms and conditions that govern the use of enviPath
services, including licensing, user responsibilities, and
platform usage guidelines.
</p>
<div class="card-actions justify-end">
<a href="/terms" class="btn btn-primary btn-sm">Read Terms</a>
</div>
</div>
</div>
<!-- Privacy Policy -->
<div class="card bg-base-200 shadow-md">
<div class="card-body">
<h3 class="card-title text-primary">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
Privacy Policy
</h3>
<p class="mb-4 text-sm">
How we collect, use, and protect your personal information when
you use enviPath, including data handling practices and your
privacy rights.
</p>
<div class="card-actions justify-end">
<a href="/privacy" class="btn btn-primary btn-sm"
>Read Policy</a
>
</div>
</div>
</div>
<!-- Cookie Policy -->
<div class="card bg-base-200 shadow-md">
<div class="card-body">
<h3 class="card-title text-primary">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
Cookie Policy
</h3>
<p class="mb-4 text-sm">
Information about the cookies and tracking technologies we use
on enviPath, including analytics and essential functionality
cookies.
</p>
<div class="card-actions justify-end">
<a href="/cookie-policy" class="btn btn-primary btn-sm"
>Read Policy</a
>
</div>
</div>
</div>
<!-- Citation Guidelines -->
<div class="card bg-base-200 shadow-md">
<div class="card-body">
<h3 class="card-title text-primary">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
Citation Guidelines
</h3>
<p class="mb-4 text-sm">
How to properly cite enviPath in your research publications and
academic work, including recommended citation formats and
acknowledgments.
</p>
<div class="card-actions justify-end">
<a href="/cite" class="btn btn-primary btn-sm"
>View Guidelines</a
>
</div>
</div>
</div>
</div>
<!-- Quick Access Section -->
<h2 class="mt-8 mb-4 text-2xl font-semibold">Quick Access</h2>
<div class="mb-6 grid gap-4 md:grid-cols-3">
<a href="/terms" class="btn btn-outline btn-sm w-full"
>Terms of Use</a
>
<a href="/privacy" class="btn btn-outline btn-sm w-full"
>Privacy Policy</a
>
<a href="/cookie-policy" class="btn btn-outline btn-sm w-full"
>Cookie Policy</a
>
</div>
<!-- Important Information -->
<div class="alert alert-info mt-8">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="h-6 w-6 shrink-0 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<div>
<h3 class="font-bold">Important Notice</h3>
<p class="text-sm">
By using enviPath, you agree to be bound by our Terms of Use and
Privacy Policy. We recommend reviewing these documents regularly
as they may be updated from time to time. For questions about our
legal policies, please
<a href="/contact" class="link link-primary">contact us</a>.
</p>
</div>
</div>
<!-- Contact Information -->
<div class="card bg-primary text-primary-content mt-8">
<div class="card-body">
<h3 class="card-title">Questions About Our Legal Policies?</h3>
<p>
If you have any questions or concerns about our legal documents,
please don't hesitate to reach out to us.
</p>
<div class="card-actions mt-4 justify-end">
<a href="/contact" class="btn btn-secondary">Contact Us</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock main_content %}

View File

@ -31,6 +31,32 @@
{% endblock %}
{% block content %}
<div class="card bg-base-200 mb-6 ">
<div class="card-body">
<h3 class="card-title">Welcome to the new enviPath!</h3>
<p class="mb-4 text-sm">
Rebuilt from the ground up for faster predictions, greater stability,
and powerful scalability. Explore a decade of research on a modern,
reliable platform.<br />
The old system is still accessible but will be shut down at a later
date. If you want to back up your data, download it from the
<a
href="https://legacy.envipath.org"
class="link link-primary"
target="_blank"
>old system</a
>
or contact us via the
<a
href="https://community.envipath.org/"
class="link link-primary"
target="_blank"
>community forum</a
>
for assistance.
</p>
</div>
</div>
<!-- Tab Navigation -->
<div class="border-base-300 mb-6 border-b">
<div class="flex justify-start">
@ -190,6 +216,22 @@
<input type="hidden" name="next" value="{{ next }}" />
<!-- ToS and Academic Use Notice -->
<div class="text-xs text-base-content/70 mt-2">
<p>
By clicking Sign Up, you agree to our
<a
href="{% url 'terms_of_use' %}"
target="_blank"
class="link link-primary"
>Terms of Use</a
>.
</p>
<p class="mt-1 font-semibold">
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>

View File

@ -18,153 +18,304 @@
<div class="prose max-w-none">
<p class="mb-6 text-lg">
enviPath is committed to protecting your privacy. This Privacy Policy
explains how we collect, use, disclose, and safeguard your information
when you use our platform.
explains how we collect, use, disclose, and safeguard personal
information when you use the enviPath platform and related services
(together, the "Platform").
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
1. Information We Collect
1. Who We Are (Data Controller)
</h2>
<p class="mb-4">
enviPath is operated by enviPath Limited, a legal entity established
in New Zealand, with its principal place of business in Auckland, New
Zealand ("enviPath", "we", "us", or "our").
</p>
<p class="mb-4">
For the purposes of applicable data protection laws, enviPath Limited
is the data controller responsible for personal information processed
through the Platform.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
2. Information We Collect
</h2>
<h3 class="mt-6 mb-3 text-xl font-semibold">Personal Information</h3>
<h3 class="mt-6 mb-3 text-xl font-semibold">
2.1 Personal Information You Provide
</h3>
<p class="mb-4">
We may collect personal information that you voluntarily provide when
you:
</p>
<ul class="mb-4 list-inside list-disc space-y-2">
<li>Register for an account</li>
<li>Use our prediction services</li>
<li>Submit data or pathways</li>
<li>Contact our support team</li>
<li>Participate in our community forums</li>
<li>
Participate in community forums or other community features (where
available)
</li>
</ul>
<p class="mb-4">
This information may include: name, email address, institution
affiliation, and research interests.
This may include your name, email address, institution affiliation,
and research interests (if provided).
</p>
<h3 class="mt-6 mb-3 text-xl font-semibold">Usage Data</h3>
<h3 class="mt-6 mb-3 text-xl font-semibold">
2.2 Usage and Technical Data
</h3>
<p class="mb-4">
We automatically collect certain information when you visit, use, or
navigate the platform. This includes:
</p>
<ul class="mb-4 list-inside list-disc space-y-2">
<li>IP address and browser type</li>
<li>Pages visited and time spent</li>
<li>Referring/exit pages</li>
<li>Prediction queries and results</li>
<li>Operating system and device information</li>
</ul>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
2. How We Use Your Information
</h2>
<p class="mb-4">We use the information we collect to:</p>
<ul class="mb-4 list-inside list-disc space-y-2">
<li>Provide, operate, and maintain our services</li>
<li>Improve and personalize user experience</li>
<li>Understand and analyze usage patterns</li>
<li>Develop new features and services</li>
<li>Communicate with you about updates and support</li>
<li>Prevent fraudulent activities and ensure security</li>
<li>Conduct research and analysis for scientific advancement</li>
</ul>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
3. Data Sharing and Disclosure
</h2>
<p class="mb-4">
We do not sell your personal information. We may share your
information in the following situations:
navigate the Platform. This may include:
</p>
<ul class="mb-4 list-inside list-disc space-y-2">
<li>
<strong>Academic Research:</strong> Anonymized usage data may be
used for research purposes
IP address, browser type, and device/operating system information
</li>
<li>Pages or endpoints accessed and time spent</li>
<li>Referring/exit pages or URLs (where available)</li>
<li>
Prediction queries, submitted data, and generated results, including
associated metadata, for the purposes of platform functionality,
performance monitoring, and research analysis
</li>
<li>
<strong>Legal Requirements:</strong> When required by law or to
protect our rights
</li>
<li>
<strong>Service Providers:</strong> With trusted third parties who
assist in operating our platform
</li>
<li>
<strong>Public Data:</strong> Data you explicitly mark as public
will be accessible to other users
Log and diagnostic information (such as timestamps and error logs)
</li>
</ul>
<h2 class="mt-8 mb-4 text-2xl font-semibold">4. Data Retention</h2>
<h3 class="mt-6 mb-3 text-xl font-semibold">
2.3 Cookies and Similar Technologies
</h3>
<p class="mb-4">
We retain your personal information for as long as necessary to
provide our services and fulfill the purposes outlined in this policy.
You may request deletion of your account and associated data at any
time.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
5. Analytics and Cookies
</h2>
<p class="mb-4">
We use Matomo analytics to understand how users interact with our
platform. This helps us improve our services. For more details, see
our
We use cookies and similar technologies for essential functionality
and, where applicable, analytics. For more details, see our
<a href="/cookie-policy" class="link link-primary">Cookie Policy</a>.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">6. Data Security</h2>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
3. How We Use Your Information
</h2>
<p class="mb-4">
We implement appropriate technical and organizational security
measures to protect your personal information. However, no electronic
transmission or storage is 100% secure, and we cannot guarantee
absolute security.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">7. Your Rights</h2>
<p class="mb-4">
Depending on your location, you may have the following rights:
We collect and use personal information only where necessary for
legitimate and lawful purposes connected with operation and
improvement of the Platform. These purposes include:
</p>
<ul class="mb-4 list-inside list-disc space-y-2">
<li>Access to your personal data</li>
<li>Correction of inaccurate data</li>
<li>Deletion of your data</li>
<li>Restriction of processing</li>
<li>Data portability</li>
<li>Objection to processing</li>
<li>Providing, operating, and maintaining the Platform</li>
<li>
Creating and administering user accounts, authentication, and access
control
</li>
<li>Responding to enquiries and providing support</li>
<li>
Monitoring usage patterns and improving Platform functionality,
performance, and usability
</li>
<li>
Ensuring Platform security, preventing misuse, and investigating
suspected breaches
</li>
<li>
Conducting scientific research and statistical analysis using
<strong>anonymised or aggregated data only</strong>, such that
individuals are not identifiable in published outputs
</li>
<li>
Complying with legal obligations and responding to lawful requests
</li>
</ul>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
8. Third-Party Services
4. Legal Bases for Processing (EEA, UK, and Swiss Users)
</h2>
<p class="mb-4">
Our platform may contain links to third-party websites or integrate
with external services (e.g., Discourse community forums). We are not
responsible for the privacy practices of these third parties.
Where the GDPR (or similar laws) applies, we process personal data on
the following legal bases:
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">9. Children's Privacy</h2>
<ul class="mb-4 list-inside list-disc space-y-2">
<li>
<strong>Performance of a contract</strong> (to provide access to the
Platform and its services)
</li>
<li>
<strong>Legitimate interests</strong> (such as operating the
Platform, improving functionality, conducting anonymised research,
ensuring security, and maintaining service reliability)
</li>
<li>
<strong>Compliance with legal obligations</strong> (where
applicable)
</li>
<li>
<strong>Consent</strong> (where required, for example for
non-essential cookies)
</li>
</ul>
<p class="mb-4">
enviPath is not intended for users under the age of 16. We do not
knowingly collect personal information from children. If you believe
we have collected information from a child, please contact us.
Where processing is based on consent, you may withdraw your consent at
any time. Withdrawal does not affect the lawfulness of processing
before withdrawal.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
10. Changes to This Policy
5. Data Sharing and Disclosure
</h2>
<p class="mb-4">
We may update this Privacy Policy from time to time. We will notify
you of any changes by posting the new policy on this page and updating
the "Last updated" date.
We do not sell personal information. We only share personal
information in the following limited circumstances:
</p>
<ul class="mb-4 list-inside list-disc space-y-2">
<li>
<strong>Service Providers:</strong> With trusted third-party
providers who process data on our behalf for hosting,
authentication, analytics, or technical support, subject to
confidentiality and data protection obligations
</li>
<li>
<strong>Academic and Scientific Research:</strong> Using
<strong>anonymised or aggregated</strong> data only, such that
individuals cannot be identified
</li>
<li>
<strong>Legal and Regulatory Requirements:</strong> Where disclosure
is required by law, or necessary to protect our legal rights or the
rights and safety of others
</li>
<li>
<strong>Public Contributions:</strong> Content or data that you
explicitly designate as public may be accessible to other users and
the public (depending on Platform functionality)
</li>
</ul>
<p class="mb-4">
We do not authorise third parties to use personal information for
their own independent purposes.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">11. Contact Us</h2>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
6. International Data Transfers
</h2>
<p class="mb-4">
If you have questions or concerns about this Privacy Policy, please
<a href="/contact" class="link link-primary">contact us</a>.
The Platform is operated from New Zealand. Personal information may be
processed or stored in New Zealand and other jurisdictions where we or
our service providers operate.
</p>
<p class="mb-4">
New Zealand is recognised by the European Commission as providing an
adequate level of protection for personal data. Where personal
information is transferred to jurisdictions outside the European
Economic Area, we take reasonable steps to ensure appropriate
safeguards are in place.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">7. Data Retention</h2>
<p class="mb-4">
We retain personal information only for as long as reasonably
necessary to fulfil the purposes described in this Privacy Policy,
unless a longer retention period is required or permitted by law.
</p>
<ul class="mb-4 list-inside list-disc space-y-2">
<li>
<strong>Account information:</strong> retained for the duration of
the user account
</li>
<li>
<strong>Usage and log data:</strong> retained for up to
<strong>[X] months</strong>
</li>
<li>
<strong>Backup data:</strong> retained for up to
<strong>[Y] months</strong>
</li>
</ul>
<p class="mb-4">
You may request deletion of your account and associated personal
information at any time, subject to legal or technical retention
requirements (for example, security logs and backups retained for
limited periods).
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">8. Data Security</h2>
<p class="mb-4">
We implement appropriate technical and organisational measures
designed to protect personal information from loss, unauthorised
access, misuse, alteration, or disclosure. However, no electronic
transmission or storage method is 100% secure, and we cannot guarantee
absolute security.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
9. Your Rights and Choices
</h2>
<p class="mb-4">
Depending on your location, you may have rights relating to your
personal information, including:
</p>
<ul class="mb-4 list-inside list-disc space-y-2">
<li>Request access to personal information we hold about you</li>
<li>Request correction of inaccurate or incomplete information</li>
<li>Request deletion of personal information (where applicable)</li>
<li>Object to or restrict certain processing (where applicable)</li>
<li>Request data portability (where applicable)</li>
</ul>
<p class="mb-4">
<strong>New Zealand users</strong> have rights under the Privacy Act
2020 to request access to, and correction of, personal information.
</p>
<p class="mb-4">
To exercise your rights, please contact us using the details in the
"Contact Us" section below. We may need to verify your identity before
responding.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
10. Third-Party Services
</h2>
<p class="mb-4">
The Platform may integrate or link to third-party services (for
example, community forums such as Discourse). Third-party services
operate independently and have their own privacy practices. We
encourage you to review their policies.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">11. Childrens Privacy</h2>
<p class="mb-4">
The Platform is not intended for users under the age of 16. We do not
knowingly collect personal information from children. If you believe
that we have collected personal information from a child, please
contact us so we can take appropriate steps.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
12. Changes to This Policy
</h2>
<p class="mb-4">
We may update this Privacy Policy from time to time. We will post the
updated policy on this page and update the "Last updated" date.
Changes take effect when posted.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">13. Contact Us</h2>
<p class="mb-4">
For questions, requests, or concerns about this Privacy Policy or our
handling of personal information, please contact us:
</p>
<ul class="mb-4 list-inside list-disc space-y-2">
<li>
<strong>Email:</strong>
<a href="mailto:privacy@envipath.org" class="link link-primary"
>privacy@envipath.org</a
>
</li>
<li>
<strong>Contact:</strong>
<a href="/contact" class="link link-primary">/contact</a>
</li>
<li>
<strong>Operator:</strong> enviPath Limited, Auckland, New Zealand
</li>
</ul>
<div class="alert alert-info mt-8">
<svg
@ -180,7 +331,7 @@
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span>Last updated: 2025</span>
<span>version: 1, Last updated: January 2026</span>
</div>
</div>
</div>

View File

@ -17,65 +17,363 @@
<div class="prose max-w-none">
<p class="mb-6 text-lg">
Welcome to enviPath. By accessing and using this platform, you agree
to be bound by these terms of use.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">1. Acceptance of Terms</h2>
<p class="mb-4">
By accessing and using enviPath, you accept and agree to be bound by
the terms and provision of this agreement. If you do not agree to
these terms, please do not use our services.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">2. Use License</h2>
<p class="mb-4">
Permission is granted to temporarily access the materials (information
or software) on enviPath for personal, non-commercial research and
educational use only. This is the grant of a license, not a transfer
of title.
</p>
<p class="mb-4">Under this license you may not:</p>
<ul class="mb-4 list-inside list-disc space-y-2">
<li>Modify or copy the materials</li>
<li>Use the materials for any commercial purpose</li>
<li>
Attempt to decompile or reverse engineer any software contained on
enviPath
</li>
<li>
Remove any copyright or other proprietary notations from the
materials
</li>
</ul>
<h2 class="mt-8 mb-4 text-2xl font-semibold">3. Data and Content</h2>
<p class="mb-4">
Users may submit, upload, or otherwise make available data and content
to enviPath. By doing so, you grant enviPath a license to use, modify,
publicly perform, publicly display, reproduce, and distribute such
content.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">4. Disclaimer</h2>
<p class="mb-4">
The materials on enviPath are provided on an 'as is' basis. enviPath
makes no warranties, expressed or implied, and hereby disclaims and
negates all other warranties including, without limitation, implied
warranties or conditions of merchantability, fitness for a particular
purpose, or non-infringement of intellectual property.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">5. Limitations</h2>
<p class="mb-4">
In no event shall enviPath or its suppliers be liable for any damages
(including, without limitation, damages for loss of data or profit, or
due to business interruption) arising out of the use or inability to
use the materials on enviPath.
Welcome to enviPath. enviPath is an online scientific platform
providing (a) database content about chemical and biotransformation
pathways and (b) prediction outputs derived from models and
algorithms. Outputs may be incomplete and probabilistic and are
intended for research use only.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
6. Academic Use and Citations
1. Acceptance and Agreement
</h2>
<p class="mb-4">
These Terms of Use (the <strong>&ldquo;Terms&rdquo;</strong>) govern
your access to and use of enviPath. By creating an account, obtaining
or using an API key, clicking &ldquo;I agree&rdquo; (or equivalent),
or otherwise accessing or using enviPath, you agree to be bound by
these Terms and any policies referenced in them.
</p>
<p class="mb-4">
If you do not agree, you must not access or use enviPath.
</p>
<p class="mb-4">
If you are using enviPath on behalf of an organisation, you represent
that you have authority to bind that organisation, and
&ldquo;you&rdquo; includes that organisation.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">2. Definitions</h2>
<p class="mb-4">In these Terms:</p>
<ul class="mb-4 list-inside list-disc space-y-2">
<li>
<strong>&ldquo;Operator&rdquo;</strong> means the entity operating
enviPath.
</li>
<li>
<strong>&ldquo;enviPath&rdquo;</strong> means the enviPath platform,
including its databases, software, prediction systems, APIs,
documentation, and related services.
</li>
<li>
<strong>&ldquo;Academic Research&rdquo;</strong> means research
conducted primarily to generate scholarly knowledge, where:
<ul class="mt-2 ml-6 list-disc space-y-1">
<li>
the results are intended for publication in a scholarly outlet
(journal, thesis, dissertation, preprint, or equivalent);
</li>
<li>
the research outputs, methods, and conclusions are made publicly
accessible as part of that publication (subject to lawful
confidentiality limits); and
</li>
<li>
the research is not conducted for commercial advantage, private
financial gain, or the development of proprietary products or
services.
</li>
</ul>
</li>
<li>
<strong>&ldquo;Commercial Use&rdquo;</strong> means any use of
enviPath that is not Academic Research, including (without
limitation):
<ul class="mt-2 ml-6 list-disc space-y-1">
<li>
use in connection with commercial products, services, or
consulting activities;
</li>
<li>
internal research and development within a for-profit
organisation;
</li>
<li>
regulatory submissions or compliance activities conducted on
behalf of a commercial entity;
</li>
<li>
use to support fee-based services, contract research,
commissioned research, or proprietary decision-making; or
</li>
<li>
use where outputs are not made publicly available as part of a
scholarly work (except where prevented by law or ethics).
</li>
</ul>
</li>
<li>
<strong>&ldquo;Mixed-Use&rdquo;</strong> means a use case that
includes both academic and commercial elements (for example,
industry-sponsored academic research, research conducted under
contract, or research whose results are used for internal or
commercial decision-making).
</li>
<li>
<strong>&ldquo;User Content&rdquo;</strong> means any data or
content you submit, upload, or otherwise make available to enviPath.
</li>
</ul>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
3. Eligibility, Declarations, and Account Security
</h2>
<p class="mb-4">
You must provide accurate and complete information when creating an
account or requesting API access, and keep it up to date.
</p>
<p class="mb-4">
<strong>Academic-use declaration.</strong> Where required by the
Operator during signup, API key issuance, or access renewal, you must
confirm that your intended use qualifies as Academic Research and is
not Commercial Use.
</p>
<p class="mb-4">
You are responsible for maintaining the confidentiality of your
credentials and API keys, and for all activity performed under your
account or using your API keys. You must promptly notify us of any
unauthorised use or suspected compromise.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
4. Permitted Use and Licence
</h2>
<p class="mb-4">
Subject to these Terms, the Operator grants you a limited,
non-exclusive, non-transferable, revocable licence to access and use
enviPath solely for Academic Research.
</p>
<p class="mb-4">
<strong>Commercial Use prohibited.</strong> All Commercial Use
(including Mixed-Use) is prohibited unless you have entered into a
separate written commercial licence agreement with the Operator.
</p>
<p class="mb-4">
<strong>Mixed-Use presumption.</strong> Where a use involves Mixed-Use
elements, or where results are used for internal decision-making,
regulatory purposes, or on behalf of a third party, that use is
presumed to be Commercial Use unless the Operator agrees otherwise in
writing.
</p>
<p class="mb-4">
If you are unsure whether your intended use is Academic Research, you
must contact us before using enviPath. We may request additional
information to assess your use case, and we may determine (acting
reasonably) whether a particular use is permitted.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">5. Acceptable Use</h2>
<p class="mb-4">
You must use enviPath only for lawful purposes and in accordance with
these Terms and all applicable laws.
</p>
<p class="mb-4">You must not, directly or indirectly:</p>
<ul class="mb-4 list-inside list-disc space-y-2">
<li>
Use enviPath in a manner that is unlawful, harmful, misleading, or
infringes any third-party rights;
</li>
<li>
Attempt to circumvent, probe, or test security, access controls, or
technical limitations;
</li>
<li>
Use automated systems (including bots, scrapers, crawlers, or
scripts) to access enviPath in a way that degrades performance,
bypasses controls, or exceeds reasonable usage limits or published
quotas;
</li>
<li>
Systematically extract, cache, reproduce, mirror, republish, or
redistribute substantial portions of the database or outputs,
whether manually or through automated means, even if individual
queries are permitted;
</li>
<li>
Reverse engineer, decompile, disassemble, or otherwise attempt to
derive source code, underlying models, parameters, algorithms, or
proprietary data (except to the extent expressly permitted by
non-excludable law);
</li>
<li>
Use enviPath to develop, train, validate, benchmark, or improve
competing databases, models, or commercial products without prior
written permission;
</li>
<li>
Interfere with, disrupt, or impose an unreasonable load on enviPath,
including through high-volume or abusive query patterns;
</li>
<li>
Use outputs in contexts where incorrect, incomplete, or
probabilistic results could reasonably lead to harm without
appropriate validation and domain expertise.
</li>
</ul>
<p class="mb-4">
We may publish technical documentation, rate limits, quotas, or usage
rules. Those rules form part of these Terms.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
6. Monitoring, Logging, and Privacy
</h2>
<p class="mb-4">
We may monitor, log, and analyse usage of enviPath (including API
calls, request metadata, and account activity) to operate the service,
ensure security, prevent abuse, enforce these Terms, comply with legal
obligations, and improve performance and reliability.
</p>
<p class="mb-4">
Our collection, use, storage, and disclosure of personal information
is governed by our Privacy Policy, which forms part of these Terms.
Please review our
<a href="/privacy" class="link link-primary">Privacy Policy</a>.
</p>
<p class="mb-4">
If you provide personal information about others in User Content, you
must ensure you have the right to do so and that those individuals
have been informed as required by applicable privacy laws.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
7. User-Submitted Content
</h2>
<p class="mb-4">
Users may submit, upload, or otherwise make available User Content to
enviPath. You retain ownership of your User Content.
</p>
<p class="mb-4">
By submitting User Content, you grant the Operator a non-exclusive,
worldwide, royalty-free licence to use, host, store, reproduce,
process, adapt (including to normalise formats), and analyse that User
Content solely to operate, maintain, secure, support, improve, and
validate enviPath and its scientific outputs (including model
validation and quality assurance).
</p>
<p class="mb-4">
For the avoidance of doubt, any enhancements, modifications, or
improvements to enviPath (including its models, algorithms, and
scientific outputs) that result from or are informed by User Content
may be implemented and made available across all licence types and
access tiers offered by the Operator for enviPath, including
commercial licences, provided that such use remains within and forms
part of enviPath.
</p>
<p class="mb-4">
The Operator does not sell, licence, or otherwise disclose User
Content itself to other users or licensees. Access by commercial or
other licensees is limited to enviPath and any improvements thereto,
and does not include access to the underlying User Content.
</p>
<p class="mb-4">
You represent and warrant that: (a) you have all necessary rights and
consents to submit the User Content; and (b) submitting and using the
User Content as contemplated by these Terms does not infringe any
third-party rights or breach any obligation of confidence.
</p>
<p class="mb-4">
You must not submit User Content that is unlawful, harmful,
misleading, or infringes any third-party rights.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
8. Intellectual Property
</h2>
<p class="mb-4">
enviPath and all associated intellectual property rights (including
software, databases, compilations, documentation, models, algorithms,
and trademarks) are owned by or licenced to the Operator and its
licensors.
</p>
<p class="mb-4">
Except for the limited licence granted in these Terms, no rights are
granted to you. You must not remove or alter any proprietary notices.
</p>
<p class="mb-4">
Where third-party datasets, publications, or open-source components
are used, additional licence terms may apply. To the extent there is a
conflict, those third-party terms prevail for the relevant component.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
9. Scientific Nature of Outputs
</h2>
<p class="mb-4">
enviPath provides data-driven predictions, transformations, and
scientific information that may be incomplete, probabilistic, or
subject to error. Outputs are provided for research and informational
purposes only and must be independently verified by suitably qualified
users before being relied upon.
</p>
<p class="mb-4">
You acknowledge enviPath is not a substitute for experimental
validation, regulatory assessment, or professional judgment.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">10. Disclaimer</h2>
<p class="mb-4">
To the maximum extent permitted by law, enviPath is provided on an
&ldquo;as is&rdquo; and &ldquo;as available&rdquo; basis. The Operator
disclaims all warranties, representations, and conditions (express or
implied), including implied warranties or conditions of
merchantability, fitness for a particular purpose, accuracy,
availability, and non-infringement.
</p>
<p class="mb-4">
We do not warrant that enviPath will be uninterrupted, timely, secure,
or error-free, or that defects will be corrected.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
11. Limitation of Liability
</h2>
<p class="mb-4">
To the maximum extent permitted by law, the Operator (and its
contributors, suppliers, partners, and licensors) excludes all
liability for any loss or damage arising out of or in connection with
enviPath, whether in contract, tort (including negligence), equity,
breach of statutory duty, or otherwise.
</p>
<p class="mb-4">
To the maximum extent permitted by law, the Operator will not be
liable for any indirect, consequential, special, incidental, or
exemplary damages (including loss of profits, loss of revenue, loss of
data, loss of goodwill, or business interruption), even if we have
been advised of the possibility of such loss.
</p>
<p class="mb-4">
Where liability cannot be excluded, the Operators total aggregate
liability to you for all claims arising out of or in connection with
these Terms or enviPath is limited to the greater of: (a) NZD 100; and
(b) any fees paid by you to the Operator for enviPath in the 12 months
immediately preceding the event giving rise to the claim.
</p>
<p class="mb-4">
Nothing in these Terms limits any liability that cannot lawfully be
excluded.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
12. New Zealand Consumer Law
</h2>
<p class="mb-4">
Nothing in these Terms limits any rights you may have under the
Consumer Guarantees Act 1993 (the <strong>&ldquo;CGA&rdquo;</strong>)
where those rights cannot lawfully be excluded.
</p>
<p class="mb-4">
If you acquire or use enviPath <strong>in trade</strong> (as defined
in the CGA), you warrant that the services are acquired for business
purposes. You agree that, to the extent permitted by law, the CGA does
not apply, and the parties agree it is fair and reasonable to contract
out of the CGA for that supply.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
13. Academic Use and Citations
</h2>
<p class="mb-4">
If you use enviPath in your research, we request that you cite our
@ -84,29 +382,79 @@
details.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">7. License Information</h2>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
14. Enforcement, Suspension, and Termination
</h2>
<p class="mb-4">
For detailed license information about enviPath data and software,
We may suspend, restrict, throttle, or terminate your access to
enviPath (including disabling API keys) where we reasonably believe
you have violated these Terms, exceeded usage limits, engaged in
abusive or high-volume access, attempted scraping or model extraction,
or otherwise used enviPath inconsistently with its intended purpose.
</p>
<p class="mb-4">
Where practicable, we will provide notice and an opportunity to remedy
before taking action. However, we may take immediate action without
notice where we reasonably consider it necessary to protect enviPath,
other users, data integrity, security, service availability, or our
legal interests.
</p>
<p class="mb-4">
Termination does not affect any rights or obligations that by their
nature should survive termination, including intellectual property,
disclaimers, limitations of liability, and governing law.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
15. Licence Information and Third-Party Terms
</h2>
<p class="mb-4">
For detailed licence information about enviPath data and software,
please refer to the
<a
href="https://community.envipath.org/t/envipath-license/109"
target="_blank"
rel="noopener noreferrer"
class="link link-primary"
>
enviPath License documentation </a
>.
enviPath licence documentation </a
>. Where third-party licences apply, you agree to comply with them.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">8. Modifications</h2>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
16. Changes to These Terms
</h2>
<p class="mb-4">
enviPath may revise these terms of use at any time without notice. By
using this platform, you are agreeing to be bound by the then current
version of these terms of use.
We may update these Terms from time to time. The updated Terms will be
posted on this page with a revised &ldquo;Last updated&rdquo; date.
Material changes may be notified through the platform (for example, at
login or via the API dashboard). Your continued use after the
effective date of an update constitutes acceptance of the updated
Terms.
</p>
<p class="mb-4">
For certain changes, we may require you to actively re-accept the
Terms (for example, when obtaining or renewing API keys).
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">9. Contact Information</h2>
<h2 class="mt-8 mb-4 text-2xl font-semibold">
17. Governing Law and Jurisdiction
</h2>
<p class="mb-4">
If you have any questions about these Terms of Use, please
These Terms are governed by and construed in accordance with the laws
of New Zealand. To the extent permitted by law, you submit to the
exclusive jurisdiction of the courts of New Zealand for any dispute
arising out of or in connection with these Terms or enviPath.
</p>
<p class="mb-4">
Mandatory consumer protection laws in your jurisdiction may still
apply where they cannot be excluded.
</p>
<h2 class="mt-8 mb-4 text-2xl font-semibold">18. Contact</h2>
<p class="mb-4">
If you have questions about these Terms, or wish to request a
commercial licence, please
<a href="/contact" class="link link-primary">contact us</a>.
</p>
@ -124,7 +472,7 @@
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span>Last updated: 2025</span>
<span>version: 1, Last updated: January 2026</span>
</div>
</div>
</div>

View File

@ -37,7 +37,7 @@ class RuleApplicationTest(TestCase):
def tearDown(self):
super().tearDown()
print(f"\nTotal errors {self.total_errors}")
# print(f"\nTotal errors {self.total_errors}")
@staticmethod
def normalize_smiles(smiles):

View File

@ -2,14 +2,12 @@ import logging
import re
from abc import ABC
from collections import defaultdict
from typing import List, Optional, Dict, TYPE_CHECKING, Union
from typing import TYPE_CHECKING, Dict, List, Optional, Union
from indigo import Indigo, IndigoException, IndigoObject
from indigo.renderer import IndigoRenderer
from rdkit import Chem, rdBase
from rdkit.Chem import MACCSkeys, Descriptors, rdFingerprintGenerator
from rdkit.Chem import rdchem
from rdkit.Chem import rdChemReactions
from rdkit.Chem import Descriptors, MACCSkeys, rdchem, rdChemReactions, rdFingerprintGenerator
from rdkit.Chem.Draw import rdMolDraw2D
from rdkit.Chem.MolStandardize import rdMolStandardize
from rdkit.Chem.rdmolops import GetMolFrags
@ -335,9 +333,14 @@ class FormatConverter(object):
# Inplace
if preprocess_smiles:
# from rdkit.Chem.rdmolops import AROMATICITY_RDKIT
# Chem.SetAromaticity(mol, AROMATICITY_RDKIT)
Chem.SanitizeMol(mol)
mol = Chem.AddHs(mol)
# for std in BASIC:
# mol = std.standardize(mol)
# Check if reactant_filter_smarts matches and we shouldn't apply the rule
if reactant_filter_smarts and FormatConverter.smarts_matches(
mol, reactant_filter_smarts
@ -376,29 +379,6 @@ class FormatConverter(object):
prods.append(p)
# if kekulize:
# # from rdkit.Chem import MolStandardize
# #
# # # Attempt re-sanitization via standardizer
# # cleaner = MolStandardize.rdMolStandardize.Cleanup()
# # mol = cleaner.cleanup(product)
# # # Fixes
# # # [2025-01-30 23:00:50] ERROR chem - Sanitizing and converting failed:
# # # non-ring atom 3 marked aromatic
# # # But does not improve overall performance
# # # for a in product.GetAtoms():
# # # if (not a.IsInRing()) and a.GetIsAromatic():
# # # a.SetIsAromatic(False)
# # #
# # # for b in product.GetBonds():
# # # if (not b.IsInRing()) and b.GetIsAromatic():
# # # b.SetIsAromatic(False)
# # for atom in product.GetAtoms():
# # atom.SetIsAromatic(False)
# # for bond in product.GetBonds():
# # bond.SetIsAromatic(False)
# Chem.Kekulize(product)
except ValueError as e:
logger.error(f"Sanitizing and converting failed:\n{e}")
continue
@ -524,8 +504,8 @@ class Standardizer(ABC):
def __init__(self, name):
self.name = name
def standardize(self, smiles: str) -> str:
return FormatConverter.normalize(smiles)
def standardize(self, mol: rdchem.Mol) -> rdchem.Mol:
return mol
class RuleStandardizer(Standardizer):
@ -533,18 +513,20 @@ class RuleStandardizer(Standardizer):
super().__init__(name)
self.smirks = smirks
def standardize(self, smiles: str) -> str:
standardized_smiles = list(set(FormatConverter.apply(smiles, self.smirks)))
def standardize(self, mol: rdchem.Mol) -> rdchem.Mol:
rxn = rdChemReactions.ReactionFromSmarts(self.smirks)
sites = rxn.RunReactants((mol,))
if len(standardized_smiles) > 1:
logger.warning(f"{self.smirks} generated more than 1 compound {standardized_smiles}")
print(f"{self.smirks} generated more than 1 compound {standardized_smiles}")
standardized_smiles = standardized_smiles[:1]
if len(sites) == 1:
sites = sites[0]
if standardized_smiles:
smiles = standardized_smiles[0]
if len(sites) > 1:
logger.warning(f"{self.smirks} generated more than 1 compound {sites}")
print(f"{self.smirks} generated more than 1 compound {sites}")
return super().standardize(smiles)
mol = sites[0]
return mol
class RegExStandardizer(Standardizer):
@ -552,19 +534,20 @@ class RegExStandardizer(Standardizer):
super().__init__(name)
self.replacements = replacements
def standardize(self, smiles: str) -> str:
smi = smiles
mod_smi = smiles
for k, v in self.replacements.items():
mod_smi = smi.replace(k, v)
while mod_smi != smi:
mod_smi = smi
for k, v in self.replacements.items():
smi = smi.replace(k, v)
return super().standardize(smi)
def standardize(self, mol: rdchem.Mol) -> rdchem.Mol:
# smi = smiles
# mod_smi = smiles
#
# for k, v in self.replacements.items():
# mod_smi = smi.replace(k, v)
#
# while mod_smi != smi:
# mod_smi = smi
# for k, v in self.replacements.items():
# smi = smi.replace(k, v)
#
# return super().standardize(smi)
raise ValueError("Not implemented yet!")
FLATTEN = [RegExStandardizer("Remove Stereo", {"@": ""})]

Some files were not shown because too many files have changed in this diff Show More