forked from enviPath/enviPy
Compare commits
17 Commits
6499a0c659
...
58ab5b33e3
| Author | SHA1 | Date | |
|---|---|---|---|
| 58ab5b33e3 | |||
| 73f0202267 | |||
| 27c5bad9c5 | |||
| 5789f20e7f | |||
| c0cfdb9255 | |||
| 5da8dbc191 | |||
| dc18b73e08 | |||
| d80dfb5ee3 | |||
| 9f63a9d4de | |||
| 5565b9cb9e | |||
| ab0b5a5186 | |||
| f905bf21cf | |||
| 1fd993927c | |||
| 2a2fe4f147 | |||
| 5f5ae76182 | |||
| 1c2f70b3b9 | |||
| 54f8302104 |
@ -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
|
||||
|
||||
56
README.md
56
README.md
@ -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
|
||||
```
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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")
|
||||
|
||||
1
epapi/tests/utils/__init__.py
Normal file
1
epapi/tests/utils/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for epapi utility modules."""
|
||||
218
epapi/tests/utils/test_validation_errors.py
Normal file
218
epapi/tests/utils/test_validation_errors.py
Normal 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)
|
||||
448
epapi/tests/v1/test_additional_information.py
Normal file
448
epapi/tests/v1/test_additional_information.py
Normal 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)
|
||||
@ -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):
|
||||
|
||||
@ -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()
|
||||
|
||||
301
epapi/tests/v1/test_scenario_creation.py
Normal file
301
epapi/tests/v1/test_scenario_creation.py
Normal 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"])
|
||||
114
epapi/tests/v1/test_schema_generation.py
Normal file
114
epapi/tests/v1/test_schema_generation.py
Normal 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}"
|
||||
)
|
||||
94
epapi/tests/v1/test_token_auth.py
Normal file
94
epapi/tests/v1/test_token_auth.py
Normal 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
0
epapi/utils/__init__.py
Normal file
181
epapi/utils/schema_transformers.py
Normal file
181
epapi/utils/schema_transformers.py
Normal 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,
|
||||
}
|
||||
82
epapi/utils/validation_errors.py
Normal file
82
epapi/utils/validation_errors.py
Normal 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))
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
174
epapi/v1/endpoints/additional_information.py
Normal file
174
epapi/v1/endpoints/additional_information.py
Normal 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
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
23
epapi/v1/endpoints/settings.py
Normal file
23
epapi/v1/endpoints/settings.py
Normal 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)
|
||||
@ -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()
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"]
|
||||
|
||||
|
||||
14
epdb/api.py
14
epdb/api.py
@ -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):
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
92
epdb/management/commands/create_api_token.py
Normal file
92
epdb/management/commands/create_api_token.py
Normal 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)
|
||||
@ -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,
|
||||
|
||||
17
epdb/migrations/0015_user_is_reviewer.py
Normal file
17
epdb/migrations/0015_user_is_reviewer.py
Normal 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),
|
||||
),
|
||||
]
|
||||
274
epdb/models.py
274
epdb/models.py
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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"),
|
||||
]
|
||||
|
||||
367
epdb/views.py
367
epdb/views.py
@ -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
@ -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)
|
||||
|
||||
|
||||
12
package.json
12
package.json
@ -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
200
pnpm-lock.yaml
generated
@ -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
3
pnpm-workspace.yaml
Normal file
@ -0,0 +1,3 @@
|
||||
onlyBuiltDependencies:
|
||||
- '@parcel/watcher'
|
||||
- '@tailwindcss/oxide'
|
||||
@ -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" }
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
378
static/js/alpine/components/schema-form.js
Normal file
378
static/js/alpine/components/schema-form.js
Normal 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;
|
||||
}
|
||||
},
|
||||
}));
|
||||
});
|
||||
462
static/js/alpine/components/widgets.js
Normal file
462
static/js/alpine/components/widgets.js
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
@ -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();
|
||||
}
|
||||
|
||||
419
static/js/api/additional-information.js
Normal file
419
static/js/api/additional-information.js
Normal 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 };
|
||||
},
|
||||
};
|
||||
@ -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) {
|
||||
|
||||
351
static/js/utils/timeseries-chart.js
Normal file
351
static/js/utils/timeseries-chart.js
Normal 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;
|
||||
},
|
||||
};
|
||||
@ -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"
|
||||
|
||||
@ -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 }}
|
||||
|
||||
@ -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
|
||||
|
||||
20
templates/collections/settings_paginated.html
Normal file
20
templates/collections/settings_paginated.html
Normal 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 >>
|
||||
</a>
|
||||
{% endblock description %}
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
5
templates/components/modals/error_state.html
Normal file
5
templates/components/modals/error_state.html
Normal 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>
|
||||
5
templates/components/modals/loading_state.html
Normal file
5
templates/components/modals/loading_state.html
Normal 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>
|
||||
146
templates/components/schema_form.html
Normal file
146
templates/components/schema_form.html
Normal 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>
|
||||
53
templates/components/widgets/checkbox_widget.html
Normal file
53
templates/components/widgets/checkbox_widget.html
Normal 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>
|
||||
69
templates/components/widgets/compound_link_widget.html
Normal file
69
templates/components/widgets/compound_link_widget.html
Normal 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>
|
||||
89
templates/components/widgets/interval_widget.html
Normal file
89
templates/components/widgets/interval_widget.html
Normal 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>
|
||||
69
templates/components/widgets/number_widget.html
Normal file
69
templates/components/widgets/number_widget.html
Normal 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>
|
||||
69
templates/components/widgets/pubmed_link_widget.html
Normal file
69
templates/components/widgets/pubmed_link_widget.html
Normal 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>
|
||||
71
templates/components/widgets/select_widget.html
Normal file
71
templates/components/widgets/select_widget.html
Normal 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>
|
||||
63
templates/components/widgets/text_widget.html
Normal file
63
templates/components/widgets/text_widget.html
Normal 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>
|
||||
62
templates/components/widgets/textarea_widget.html
Normal file
62
templates/components/widgets/textarea_widget.html
Normal 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>
|
||||
234
templates/components/widgets/timeseries_table_widget.html
Normal file
234
templates/components/widgets/timeseries_table_widget.html
Normal 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>
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 >></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 >></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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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"
|
||||
|
||||
91
templates/modals/objects/edit_scenario_modal.html
Normal file
91
templates/modals/objects/edit_scenario_modal.html
Normal 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>
|
||||
@ -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"
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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>
|
||||
|
||||
67
templates/objects/setting.html
Normal file
67
templates/objects/setting.html
Normal 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 %}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 %}
|
||||
@ -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>
|
||||
|
||||
@ -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. Children’s 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>
|
||||
|
||||
@ -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>“Terms”</strong>) govern
|
||||
your access to and use of enviPath. By creating an account, obtaining
|
||||
or using an API key, clicking “I agree” (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
|
||||
“you” 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>“Operator”</strong> means the entity operating
|
||||
enviPath.
|
||||
</li>
|
||||
<li>
|
||||
<strong>“enviPath”</strong> means the enviPath platform,
|
||||
including its databases, software, prediction systems, APIs,
|
||||
documentation, and related services.
|
||||
</li>
|
||||
<li>
|
||||
<strong>“Academic Research”</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>“Commercial Use”</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>“Mixed-Use”</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>“User Content”</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
|
||||
“as is” and “as available” 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 Operator’s 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>“CGA”</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 “Last updated” 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>
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
Reference in New Issue
Block a user