forked from enviPath/enviPy
[Feature] Dynamic additional information rendering in frontend (#282)
This implements a version of #274, relying on Pydantics built in JSON schema and JSON rendering. Requires additional UI tagging in the ai model repo but will remove HTML tags. Example scenario with filled information: 5882df9c-dae1-4d80-a40e-db4724271456/scenario/3a4d395a-6a6d-4154-8ce3-ced667fceec0 Reviewed-on: enviPath/enviPy#282 Co-authored-by: Tobias O <tobias.olenyi@envipath.com> Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
This commit is contained in:
420
epapi/tests/v1/test_additional_information.py
Normal file
420
epapi/tests/v1/test_additional_information.py
Normal file
@ -0,0 +1,420 @@
|
|||||||
|
"""
|
||||||
|
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_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_unauthenticated_access_returns_401(self):
|
||||||
|
"""Test that unauthenticated requests return 401."""
|
||||||
|
# Don't log in
|
||||||
|
response = self.client.get(f"/api/v1/scenario/{self.scenario.uuid}/information/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
|
||||||
|
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_anonymous_user_denied(self):
|
||||||
|
"""Anonymous users cannot access private scenarios"""
|
||||||
|
# Ensure scenario package is not reviewed (private)
|
||||||
|
self.scenario.package.reviewed = False
|
||||||
|
self.scenario.package.save()
|
||||||
|
|
||||||
|
# Unauthenticated users get 401 from the auth layer
|
||||||
|
response = self.client.get(f"/api/v1/scenario/{self.scenario.uuid}/information/")
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
|
||||||
|
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)
|
||||||
325
epapi/tests/v1/test_scenario_creation.py
Normal file
325
epapi/tests/v1/test_scenario_creation.py
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
"""
|
||||||
|
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": "SomeValidType",
|
||||||
|
"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 400 for validation errors
|
||||||
|
self.assertIn(response.status_code, [400, 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_with_additional_information(self):
|
||||||
|
"""Test creating scenario with valid additional information models."""
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
# This test will succeed if the registry has valid models
|
||||||
|
# For now, test with empty additional_information
|
||||||
|
payload = {
|
||||||
|
"name": "Scenario with AI",
|
||||||
|
"description": "Test with additional information",
|
||||||
|
"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"], "Scenario with AI")
|
||||||
|
|
||||||
|
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": "SomeType",
|
||||||
|
"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 400 for validation errors
|
||||||
|
self.assertIn(response.status_code, [400, 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}"
|
||||||
|
)
|
||||||
0
epapi/utils/__init__.py
Normal file
0
epapi/utils/__init__.py
Normal file
175
epapi/utils/schema_transformers.py
Normal file
175
epapi/utils/schema_transformers.py
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
"""
|
||||||
|
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:
|
||||||
|
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 "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,
|
||||||
|
}
|
||||||
@ -1,12 +1,12 @@
|
|||||||
from django.db.models import Model
|
from django.db.models import Model
|
||||||
from epdb.logic import PackageManager
|
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 uuid import UUID
|
||||||
|
|
||||||
from .errors import EPAPINotFoundError, EPAPIPermissionDeniedError
|
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.
|
Get compound by UUID with permission check.
|
||||||
"""
|
"""
|
||||||
@ -23,7 +23,7 @@ def get_compound_or_error(user, compound_uuid: UUID):
|
|||||||
return compound
|
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.
|
Get package by UUID with permission check.
|
||||||
"""
|
"""
|
||||||
@ -41,14 +41,40 @@ def get_package_or_error(user, package_uuid: UUID):
|
|||||||
return package
|
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."""
|
"""Get all packages readable by the user."""
|
||||||
if not user or user.is_anonymous:
|
if not user or user.is_anonymous:
|
||||||
return PackageManager.get_reviewed_packages()
|
return PackageManager.get_reviewed_packages()
|
||||||
return PackageManager.get_all_readable_packages(user, include_reviewed=True)
|
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."""
|
"""Build queryset for reviewed package entities."""
|
||||||
|
|
||||||
if not user or user.is_anonymous:
|
if not user or user.is_anonymous:
|
||||||
@ -60,16 +86,14 @@ def get_user_entities_qs(model_class: Model, user: User | None):
|
|||||||
return qs
|
return qs
|
||||||
|
|
||||||
|
|
||||||
def get_package_scoped_entities_qs(
|
def get_package_entities_for_read(model_class: Model, package_uuid: UUID, user: User | None = None):
|
||||||
model_class: Model, package_uuid: UUID, user: User | None = None
|
|
||||||
):
|
|
||||||
"""Build queryset for specific package entities."""
|
"""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")
|
qs = model_class.objects.filter(package=package).select_related("package")
|
||||||
return qs
|
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)."""
|
"""Build queryset for structures accessible to the user (via compound->package)."""
|
||||||
|
|
||||||
if not user or user.is_anonymous:
|
if not user or user.is_anonymous:
|
||||||
@ -83,13 +107,13 @@ def get_user_structures_qs(user: User | None):
|
|||||||
return qs
|
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
|
package_uuid: UUID, compound_uuid: UUID, user: User | None = None
|
||||||
):
|
):
|
||||||
"""Build queryset for specific package compound structures."""
|
"""Build queryset for specific package compound structures."""
|
||||||
|
|
||||||
get_package_or_error(user, package_uuid)
|
get_package_for_read(user, package_uuid)
|
||||||
compound = get_compound_or_error(user, compound_uuid)
|
compound = get_compound_for_read(user, compound_uuid)
|
||||||
|
|
||||||
qs = CompoundStructure.objects.filter(compound=compound).select_related("compound__package")
|
qs = CompoundStructure.objects.filter(compound=compound).select_related("compound__package")
|
||||||
return qs
|
return qs
|
||||||
|
|||||||
237
epapi/v1/endpoints/additional_information.py
Normal file
237
epapi/v1/endpoints/additional_information.py
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
from ninja import Router, Body
|
||||||
|
from ninja.errors import HttpError
|
||||||
|
from uuid import UUID
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from pydantic_core import ErrorDetails
|
||||||
|
from typing import Dict, Any
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
|
||||||
|
from envipy_additional_information import registry
|
||||||
|
from envipy_additional_information.groups import GroupEnum
|
||||||
|
from epapi.utils.schema_transformers import build_rjsf_output
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _format_validation_error(error: ErrorDetails) -> str:
|
||||||
|
"""Format a Pydantic validation error into a user-friendly message."""
|
||||||
|
msg = error["msg"] or "Invalid value"
|
||||||
|
error_type = error["type"] or ""
|
||||||
|
|
||||||
|
# Handle common validation types with friendly messages
|
||||||
|
if error_type == "enum":
|
||||||
|
expected = error["ctx"]["expected"] if error["ctx"] else ""
|
||||||
|
return f"Please select a valid option{': ' + expected if expected else ''}"
|
||||||
|
elif error_type == "missing":
|
||||||
|
return "This field is required"
|
||||||
|
elif error_type in ("string_type", "int_type", "float_type"):
|
||||||
|
type_name = error_type.replace("_type", "")
|
||||||
|
return f"Please enter a valid {type_name}"
|
||||||
|
elif error_type == "value_error":
|
||||||
|
# Use the message as-is for value errors
|
||||||
|
return msg
|
||||||
|
else:
|
||||||
|
# Default: use the message from Pydantic but clean it up
|
||||||
|
return msg.replace("Input should be ", "Please enter ").replace("Value error, ", "")
|
||||||
|
|
||||||
|
|
||||||
|
@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:
|
||||||
|
# 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))
|
||||||
|
|
||||||
|
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:
|
||||||
|
# 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))
|
||||||
|
|
||||||
|
# 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 epdb.models import Compound
|
||||||
from ..pagination import EnhancedPageNumberPagination
|
from ..pagination import EnhancedPageNumberPagination
|
||||||
from ..schemas import CompoundOutSchema, ReviewStatusFilter
|
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()
|
router = Router()
|
||||||
|
|
||||||
@ -21,7 +21,7 @@ def list_all_compounds(request):
|
|||||||
"""
|
"""
|
||||||
List all compounds from reviewed packages.
|
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(
|
@router.get(
|
||||||
@ -38,4 +38,4 @@ def list_package_compounds(request, package_uuid: UUID):
|
|||||||
List all compounds for a specific package.
|
List all compounds for a specific package.
|
||||||
"""
|
"""
|
||||||
user = request.user
|
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 epdb.models import EPModel
|
||||||
from ..pagination import EnhancedPageNumberPagination
|
from ..pagination import EnhancedPageNumberPagination
|
||||||
from ..schemas import ModelOutSchema, ReviewStatusFilter
|
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()
|
router = Router()
|
||||||
|
|
||||||
@ -21,7 +21,7 @@ def list_all_models(request):
|
|||||||
"""
|
"""
|
||||||
List all models from reviewed packages.
|
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(
|
@router.get(
|
||||||
@ -38,4 +38,4 @@ def list_package_models(request, package_uuid: UUID):
|
|||||||
List all models for a specific package.
|
List all models for a specific package.
|
||||||
"""
|
"""
|
||||||
user = request.user
|
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,7 @@ from ninja import Router
|
|||||||
from ninja_extra.pagination import paginate
|
from ninja_extra.pagination import paginate
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from ..dal import get_user_packages_qs
|
from ..dal import get_user_packages_for_read
|
||||||
from ..pagination import EnhancedPageNumberPagination
|
from ..pagination import EnhancedPageNumberPagination
|
||||||
from ..schemas import PackageOutSchema, SelfReviewStatusFilter
|
from ..schemas import PackageOutSchema, SelfReviewStatusFilter
|
||||||
|
|
||||||
@ -23,5 +23,5 @@ def list_all_packages(request):
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
user = request.user
|
user = request.user
|
||||||
qs = get_user_packages_qs(user)
|
qs = get_user_packages_for_read(user)
|
||||||
return qs.order_by("name").all()
|
return qs.order_by("name").all()
|
||||||
|
|||||||
@ -6,7 +6,7 @@ from uuid import UUID
|
|||||||
from epdb.models import Pathway
|
from epdb.models import Pathway
|
||||||
from ..pagination import EnhancedPageNumberPagination
|
from ..pagination import EnhancedPageNumberPagination
|
||||||
from ..schemas import PathwayOutSchema, ReviewStatusFilter
|
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()
|
router = Router()
|
||||||
|
|
||||||
@ -22,7 +22,7 @@ def list_all_pathways(request):
|
|||||||
List all pathways from reviewed packages.
|
List all pathways from reviewed packages.
|
||||||
"""
|
"""
|
||||||
user = request.user
|
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(
|
@router.get(
|
||||||
@ -39,4 +39,4 @@ def list_package_pathways(request, package_uuid: UUID):
|
|||||||
List all pathways for a specific package.
|
List all pathways for a specific package.
|
||||||
"""
|
"""
|
||||||
user = request.user
|
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 epdb.models import Reaction
|
||||||
from ..pagination import EnhancedPageNumberPagination
|
from ..pagination import EnhancedPageNumberPagination
|
||||||
from ..schemas import ReactionOutSchema, ReviewStatusFilter
|
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()
|
router = Router()
|
||||||
|
|
||||||
@ -22,7 +22,7 @@ def list_all_reactions(request):
|
|||||||
List all reactions from reviewed packages.
|
List all reactions from reviewed packages.
|
||||||
"""
|
"""
|
||||||
user = request.user
|
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(
|
@router.get(
|
||||||
@ -39,4 +39,4 @@ def list_package_reactions(request, package_uuid: UUID):
|
|||||||
List all reactions for a specific package.
|
List all reactions for a specific package.
|
||||||
"""
|
"""
|
||||||
user = request.user
|
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 epdb.models import Rule
|
||||||
from ..pagination import EnhancedPageNumberPagination
|
from ..pagination import EnhancedPageNumberPagination
|
||||||
from ..schemas import ReviewStatusFilter, RuleOutSchema
|
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()
|
router = Router()
|
||||||
|
|
||||||
@ -22,7 +22,7 @@ def list_all_rules(request):
|
|||||||
List all rules from reviewed packages.
|
List all rules from reviewed packages.
|
||||||
"""
|
"""
|
||||||
user = request.user
|
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(
|
@router.get(
|
||||||
@ -39,4 +39,4 @@ def list_package_rules(request, package_uuid: UUID):
|
|||||||
List all rules for a specific package.
|
List all rules for a specific package.
|
||||||
"""
|
"""
|
||||||
user = request.user
|
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,22 @@
|
|||||||
from django.conf import settings as s
|
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 ninja_extra.pagination import paginate
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
from pydantic import ValidationError
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
|
||||||
from epdb.models import Scenario
|
from epdb.models import Scenario
|
||||||
|
from epdb.logic import PackageManager
|
||||||
|
from epdb.views import _anonymous_or_real
|
||||||
from ..pagination import EnhancedPageNumberPagination
|
from ..pagination import EnhancedPageNumberPagination
|
||||||
from ..schemas import ReviewStatusFilter, ScenarioOutSchema
|
from ..schemas import ReviewStatusFilter, ScenarioOutSchema, ScenarioCreateSchema
|
||||||
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
|
||||||
|
from envipy_additional_information import registry
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
@ -19,7 +29,7 @@ router = Router()
|
|||||||
)
|
)
|
||||||
def list_all_scenarios(request):
|
def list_all_scenarios(request):
|
||||||
user = request.user
|
user = request.user
|
||||||
return get_user_entities_qs(Scenario, user).order_by("name").all()
|
return get_user_entities_for_read(Scenario, user).order_by("name").all()
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@ -33,4 +43,82 @@ def list_all_scenarios(request):
|
|||||||
)
|
)
|
||||||
def list_package_scenarios(request, package_uuid: UUID):
|
def list_package_scenarios(request, package_uuid: UUID):
|
||||||
user = request.user
|
user = request.user
|
||||||
return get_package_scoped_entities_qs(Scenario, package_uuid, user).order_by("name").all()
|
return get_package_entities_for_read(Scenario, package_uuid, user).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
|
||||||
|
|||||||
@ -6,8 +6,8 @@ from uuid import UUID
|
|||||||
from ..pagination import EnhancedPageNumberPagination
|
from ..pagination import EnhancedPageNumberPagination
|
||||||
from ..schemas import CompoundStructureOutSchema, StructureReviewStatusFilter
|
from ..schemas import CompoundStructureOutSchema, StructureReviewStatusFilter
|
||||||
from ..dal import (
|
from ..dal import (
|
||||||
get_user_structures_qs,
|
get_user_structure_for_read,
|
||||||
get_package_compound_scoped_structure_qs,
|
get_package_compound_structure_for_read,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
@ -26,7 +26,7 @@ def list_all_structures(request):
|
|||||||
List all structures from all packages.
|
List all structures from all packages.
|
||||||
"""
|
"""
|
||||||
user = request.user
|
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(
|
@router.get(
|
||||||
@ -44,7 +44,7 @@ def list_package_structures(request, package_uuid: UUID, compound_uuid: UUID):
|
|||||||
"""
|
"""
|
||||||
user = request.user
|
user = request.user
|
||||||
return (
|
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")
|
.order_by("name")
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|||||||
@ -3,15 +3,16 @@ from ninja.security import SessionAuth
|
|||||||
|
|
||||||
from .auth import BearerTokenAuth
|
from .auth import BearerTokenAuth
|
||||||
from .endpoints import (
|
from .endpoints import (
|
||||||
compounds,
|
|
||||||
models,
|
|
||||||
packages,
|
packages,
|
||||||
pathways,
|
|
||||||
reactions,
|
|
||||||
rules,
|
|
||||||
scenarios,
|
scenarios,
|
||||||
settings,
|
compounds,
|
||||||
|
rules,
|
||||||
|
reactions,
|
||||||
|
pathways,
|
||||||
|
models,
|
||||||
structure,
|
structure,
|
||||||
|
additional_information,
|
||||||
|
settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Main router with authentication
|
# Main router with authentication
|
||||||
@ -31,4 +32,5 @@ router.add_router("", reactions.router)
|
|||||||
router.add_router("", pathways.router)
|
router.add_router("", pathways.router)
|
||||||
router.add_router("", models.router)
|
router.add_router("", models.router)
|
||||||
router.add_router("", structure.router)
|
router.add_router("", structure.router)
|
||||||
|
router.add_router("", additional_information.router)
|
||||||
router.add_router("", settings.router)
|
router.add_router("", settings.router)
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
from ninja import FilterSchema, FilterLookup, Schema
|
from ninja import FilterSchema, FilterLookup, Schema
|
||||||
from typing import Annotated, Optional
|
from typing import Annotated, Optional, List, Dict, Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
@ -51,6 +51,23 @@ class ScenarioOutSchema(PackageEntityOutSchema):
|
|||||||
pass
|
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):
|
class CompoundOutSchema(PackageEntityOutSchema):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@ -3822,11 +3822,21 @@ class Scenario(EnviPathModel):
|
|||||||
return new_s
|
return new_s
|
||||||
|
|
||||||
@transaction.atomic
|
@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__
|
cls_name = data.__class__.__name__
|
||||||
# Clean for potential XSS hidden in the additional information fields.
|
# Clean for potential XSS hidden in the additional information fields.
|
||||||
ai_data = json.loads(nh3.clean(data.model_dump_json()).strip())
|
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:
|
if cls_name not in self.additional_information:
|
||||||
self.additional_information[cls_name] = []
|
self.additional_information[cls_name] = []
|
||||||
@ -3834,6 +3844,51 @@ class Scenario(EnviPathModel):
|
|||||||
self.additional_information[cls_name].append(ai_data)
|
self.additional_information[cls_name].append(ai_data)
|
||||||
self.save()
|
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
|
@transaction.atomic
|
||||||
def remove_additional_information(self, ai_uuid):
|
def remove_additional_information(self, ai_uuid):
|
||||||
found_type = None
|
found_type = None
|
||||||
@ -3848,9 +3903,9 @@ class Scenario(EnviPathModel):
|
|||||||
|
|
||||||
if found_type is not None and found_idx >= 0:
|
if found_type is not None and found_idx >= 0:
|
||||||
if len(self.additional_information[found_type]) == 1:
|
if len(self.additional_information[found_type]) == 1:
|
||||||
del self.additional_information[k]
|
del self.additional_information[found_type]
|
||||||
else:
|
else:
|
||||||
self.additional_information[k].pop(found_idx)
|
self.additional_information[found_type].pop(found_idx)
|
||||||
self.save()
|
self.save()
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Could not find additional information with uuid {ai_uuid}")
|
raise ValueError(f"Could not find additional information with uuid {ai_uuid}")
|
||||||
@ -3873,7 +3928,7 @@ class Scenario(EnviPathModel):
|
|||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
def get_additional_information(self):
|
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():
|
for k, vals in self.additional_information.items():
|
||||||
if k == "enzyme":
|
if k == "enzyme":
|
||||||
@ -3881,7 +3936,7 @@ class Scenario(EnviPathModel):
|
|||||||
|
|
||||||
for v in vals:
|
for v in vals:
|
||||||
# Per default additional fields are ignored
|
# Per default additional fields are ignored
|
||||||
MAPPING = {c.__name__: c for c in NAME_MAPPING.values()}
|
MAPPING = {c.__name__: c for c in registry.list_models().values()}
|
||||||
inst = MAPPING[k](**v)
|
inst = MAPPING[k](**v)
|
||||||
# Add uuid to uniquely identify objects for manipulation
|
# Add uuid to uniquely identify objects for manipulation
|
||||||
if "uuid" in v:
|
if "uuid" in v:
|
||||||
|
|||||||
114
epdb/views.py
114
epdb/views.py
@ -11,13 +11,11 @@ from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotAll
|
|||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from envipy_additional_information import NAME_MAPPING
|
|
||||||
from oauth2_provider.decorators import protected_resource
|
from oauth2_provider.decorators import protected_resource
|
||||||
from sentry_sdk import capture_exception
|
from sentry_sdk import capture_exception
|
||||||
|
|
||||||
from utilities.chem import FormatConverter, IndigoUtils
|
from utilities.chem import FormatConverter, IndigoUtils
|
||||||
from utilities.decorators import package_permission_required
|
from utilities.decorators import package_permission_required
|
||||||
from utilities.misc import HTMLGenerator
|
|
||||||
|
|
||||||
from .logic import (
|
from .logic import (
|
||||||
EPDBURLParser,
|
EPDBURLParser,
|
||||||
@ -2455,72 +2453,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)
|
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:
|
else:
|
||||||
return HttpResponseNotAllowed(
|
return HttpResponseNotAllowed(
|
||||||
[
|
[
|
||||||
@ -2547,21 +2480,9 @@ def package_scenario(request, package_uuid, scenario_uuid):
|
|||||||
|
|
||||||
context["scenario"] = current_scenario
|
context["scenario"] = current_scenario
|
||||||
|
|
||||||
available_add_infs = []
|
# Note: Modals now fetch schemas and data from API endpoints
|
||||||
for add_inf in NAME_MAPPING.values():
|
# Keeping these for backwards compatibility if needed elsewhere
|
||||||
available_add_infs.append(
|
# They are no longer used by the main scenario template
|
||||||
{
|
|
||||||
"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())
|
|
||||||
]
|
|
||||||
|
|
||||||
return render(request, "objects/scenario.html", context)
|
return render(request, "objects/scenario.html", context)
|
||||||
|
|
||||||
@ -2581,28 +2502,15 @@ def package_scenario(request, package_uuid, scenario_uuid):
|
|||||||
current_scenario.save()
|
current_scenario.save()
|
||||||
return redirect(current_scenario.url)
|
return redirect(current_scenario.url)
|
||||||
elif hidden == "set-additional-information":
|
elif hidden == "set-additional-information":
|
||||||
ais = HTMLGenerator.build_models(request.POST.dict())
|
# Legacy POST handler - no longer used, modals use API endpoints
|
||||||
|
return HttpResponseBadRequest(
|
||||||
if s.DEBUG:
|
"This endpoint is deprecated. Please use the API endpoints."
|
||||||
logger.info(ais)
|
)
|
||||||
|
elif hidden == "add-additional-information":
|
||||||
current_scenario.set_additional_information(ais)
|
# Legacy POST handler - no longer used, modals use API endpoints
|
||||||
return redirect(current_scenario.url)
|
return HttpResponseBadRequest(
|
||||||
elif hidden == "add-additional-information":
|
"This endpoint is deprecated. Please use the API endpoints."
|
||||||
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)
|
|
||||||
else:
|
else:
|
||||||
return HttpResponseBadRequest()
|
return HttpResponseBadRequest()
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ dependencies = [
|
|||||||
"envipy-plugins",
|
"envipy-plugins",
|
||||||
"epam-indigo>=1.30.1",
|
"epam-indigo>=1.30.1",
|
||||||
"gunicorn>=23.0.0",
|
"gunicorn>=23.0.0",
|
||||||
|
"jsonref>=1.1.0",
|
||||||
"networkx>=3.4.2",
|
"networkx>=3.4.2",
|
||||||
"psycopg2-binary>=2.9.10",
|
"psycopg2-binary>=2.9.10",
|
||||||
"python-dotenv>=1.1.0",
|
"python-dotenv>=1.1.0",
|
||||||
@ -35,7 +36,7 @@ dependencies = [
|
|||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
enviformer = { git = "ssh://git@git.envipath.com/enviPath/enviformer.git", rev = "v0.1.4" }
|
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-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.2.0" }
|
||||||
envipy-ambit = { git = "ssh://git@git.envipath.com/enviPath/enviPy-ambit.git" }
|
envipy-ambit = { git = "ssh://git@git.envipath.com/enviPath/enviPy-ambit.git" }
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
253
static/js/alpine/components/schema-form.js
Normal file
253
static/js/alpine/components/schema-form.js
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
/**
|
||||||
|
* 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', () => {
|
||||||
|
Alpine.data('schemaRenderer', (options = {}) => ({
|
||||||
|
schema: null,
|
||||||
|
uiSchema: {},
|
||||||
|
data: {},
|
||||||
|
mode: options.mode || 'view', // 'view' | 'edit'
|
||||||
|
endpoint: options.endpoint || '',
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
fieldErrors: {}, // Server-side field-level errors
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
// Listen for field error events from parent modal
|
||||||
|
window.addEventListener('set-field-errors', (e) => {
|
||||||
|
// Apply to all forms (used by add modal which has only one form)
|
||||||
|
this.fieldErrors = e.detail || {};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for field error events targeted to a specific item (for update modal)
|
||||||
|
window.addEventListener('set-field-errors-for-item', (e) => {
|
||||||
|
// Only update if this form matches the UUID
|
||||||
|
const itemData = options.data || {};
|
||||||
|
if (itemData.uuid === e.detail?.uuid) {
|
||||||
|
this.fieldErrors = e.detail.fieldErrors || {};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getWidget(fieldName, fieldSchema) {
|
||||||
|
// Check uiSchema first (RJSF format)
|
||||||
|
if (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';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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';
|
||||||
|
},
|
||||||
|
|
||||||
|
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());
|
||||||
|
},
|
||||||
|
|
||||||
|
getHelp(fieldName) {
|
||||||
|
// Get help text from uiSchema
|
||||||
|
if (this.uiSchema[fieldName] && this.uiSchema[fieldName]['ui:help']) {
|
||||||
|
return this.uiSchema[fieldName]['ui:help'];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
getPlaceholder(fieldName) {
|
||||||
|
// Get placeholder from uiSchema
|
||||||
|
if (this.uiSchema[fieldName] && this.uiSchema[fieldName]['ui:placeholder']) {
|
||||||
|
return this.uiSchema[fieldName]['ui:placeholder'];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
getFieldOrder() {
|
||||||
|
// Get ordered list of field names based on ui:order
|
||||||
|
if (!this.schema || !this.schema.properties) return [];
|
||||||
|
|
||||||
|
const fields = Object.keys(this.schema.properties);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
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)
|
||||||
|
this.fieldErrors = {};
|
||||||
|
|
||||||
|
// 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: '...'}]
|
||||||
|
for (const err of parsedError.detail) {
|
||||||
|
const field = err.loc && err.loc.length > 0 ? err.loc[err.loc.length - 1] : 'root';
|
||||||
|
if (!this.fieldErrors[field]) {
|
||||||
|
this.fieldErrors[field] = [];
|
||||||
|
}
|
||||||
|
this.fieldErrors[field].push(err.msg || err.message || 'Validation error');
|
||||||
|
}
|
||||||
|
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
|
||||||
|
this.fieldErrors = {};
|
||||||
|
|
||||||
|
const result = await res.json();
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err.message;
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
186
static/js/alpine/components/widgets.js
Normal file
186
static/js/alpine/components/widgets.js
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* 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, fieldErrors, mode) => ({
|
||||||
|
fieldName,
|
||||||
|
data,
|
||||||
|
schema,
|
||||||
|
uiSchema,
|
||||||
|
fieldErrors,
|
||||||
|
mode,
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
get hasError() {
|
||||||
|
return !!this.fieldErrors?.[this.fieldName];
|
||||||
|
},
|
||||||
|
get errors() {
|
||||||
|
return this.fieldErrors?.[this.fieldName] || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
// Mode checks
|
||||||
|
get isViewMode() { return this.mode === 'view'; },
|
||||||
|
get isEditMode() { return this.mode === 'edit'; },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Text widget
|
||||||
|
Alpine.data('textWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({
|
||||||
|
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode),
|
||||||
|
|
||||||
|
get value() { return this.data[this.fieldName] || ''; },
|
||||||
|
set value(v) { this.data[this.fieldName] = v; },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Textarea widget
|
||||||
|
Alpine.data('textareaWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({
|
||||||
|
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode),
|
||||||
|
|
||||||
|
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, fieldErrors, mode) => ({
|
||||||
|
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode),
|
||||||
|
|
||||||
|
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, fieldErrors, mode) => ({
|
||||||
|
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode),
|
||||||
|
|
||||||
|
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, fieldErrors, mode) => ({
|
||||||
|
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode),
|
||||||
|
|
||||||
|
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, fieldErrors, mode) => ({
|
||||||
|
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode),
|
||||||
|
|
||||||
|
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
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Override hasError to include validation error
|
||||||
|
get hasError() {
|
||||||
|
return this.hasValidationError || !!this.fieldErrors?.[this.fieldName];
|
||||||
|
},
|
||||||
|
|
||||||
|
// Override errors to include validation error message
|
||||||
|
get errors() {
|
||||||
|
const serverErrors = this.fieldErrors?.[this.fieldName] || [];
|
||||||
|
const validationErrors = this.hasValidationError
|
||||||
|
? ['Start value must be less than or equal to end value']
|
||||||
|
: [];
|
||||||
|
return [...validationErrors, ...serverErrors];
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// PubMed link widget
|
||||||
|
Alpine.data('pubmedWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({
|
||||||
|
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode),
|
||||||
|
|
||||||
|
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, fieldErrors, mode) => ({
|
||||||
|
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode),
|
||||||
|
|
||||||
|
get value() { return this.data[this.fieldName] || ''; },
|
||||||
|
set value(v) { this.data[this.fieldName] = v; },
|
||||||
|
}));
|
||||||
|
});
|
||||||
394
static/js/api/additional-information.js
Normal file
394
static/js/api/additional-information.js
Normal file
@ -0,0 +1,394 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
if (typeof errorData.error === 'string' && errorData.error.startsWith('{')) {
|
||||||
|
try {
|
||||||
|
parsedError = JSON.parse(errorData.error);
|
||||||
|
} 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, throw a validation error
|
||||||
|
const validationErrors = failed.filter(f => f.isValidationError);
|
||||||
|
if (validationErrors.length === failed.length && failed.length === 1) {
|
||||||
|
// Single validation error - preserve field errors for display
|
||||||
|
const error = new Error(failed[0].error);
|
||||||
|
error.fieldErrors = failed[0].fieldErrors;
|
||||||
|
error.isValidationError = true;
|
||||||
|
error.itemUuid = failed[0].oldUuid;
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -1,7 +1,15 @@
|
|||||||
{% extends "collections/paginated_base.html" %}
|
{% extends "collections/paginated_base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
{% block page_title %}Scenarios{% endblock %}
|
{% 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 %}
|
{% block action_button %}
|
||||||
{% if meta.can_edit %}
|
{% if meta.can_edit %}
|
||||||
<button
|
<button
|
||||||
@ -14,10 +22,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock action_button %}
|
{% endblock action_button %}
|
||||||
|
|
||||||
{% block action_modals %}
|
|
||||||
{% include "modals/collections/new_scenario_modal.html" %}
|
|
||||||
{% endblock action_modals %}
|
|
||||||
|
|
||||||
{% block description %}
|
{% block description %}
|
||||||
<p>
|
<p>
|
||||||
A scenario contains meta-information that can be attached to other data
|
A scenario contains meta-information that can be attached to other data
|
||||||
|
|||||||
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>
|
||||||
135
templates/components/schema_form.html
Normal file
135
templates/components/schema_form.html
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
{% 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="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, fieldErrors, mode)"
|
||||||
|
>
|
||||||
|
{% 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, fieldErrors, mode)"
|
||||||
|
>
|
||||||
|
{% 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, fieldErrors, mode)"
|
||||||
|
>
|
||||||
|
{% 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, fieldErrors, mode)"
|
||||||
|
>
|
||||||
|
{% 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, fieldErrors, mode)"
|
||||||
|
>
|
||||||
|
{% 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, fieldErrors, mode)"
|
||||||
|
>
|
||||||
|
{% 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, fieldErrors, mode)"
|
||||||
|
>
|
||||||
|
{% 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, fieldErrors, mode)"
|
||||||
|
>
|
||||||
|
{% include "components/widgets/compound_link_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>
|
||||||
50
templates/components/widgets/checkbox_widget.html
Normal file
50
templates/components/widgets/checkbox_widget.html
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
{# 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': hasError,
|
||||||
|
'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="hasError">
|
||||||
|
<div class="label">
|
||||||
|
<template x-for="errMsg in errors" :key="errMsg">
|
||||||
|
<span class="label-text-alt text-error" x-text="errMsg"></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
66
templates/components/widgets/compound_link_widget.html
Normal file
66
templates/components/widgets/compound_link_widget.html
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
{# 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': hasError,
|
||||||
|
'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': hasError }"
|
||||||
|
placeholder="Compound URL"
|
||||||
|
x-model="value"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Errors -->
|
||||||
|
<template x-if="hasError">
|
||||||
|
<div class="label">
|
||||||
|
<template x-for="errMsg in errors" :key="errMsg">
|
||||||
|
<span class="label-text-alt text-error" x-text="errMsg"></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
76
templates/components/widgets/interval_widget.html
Normal file
76
templates/components/widgets/interval_widget.html
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
{# 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': hasError,
|
||||||
|
'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 gap-2 items-center">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="input input-bordered flex-1"
|
||||||
|
:class="{ 'input-error': hasError }"
|
||||||
|
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': hasError }"
|
||||||
|
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="hasError">
|
||||||
|
<div class="label">
|
||||||
|
<template x-for="errMsg in errors" :key="errMsg">
|
||||||
|
<span class="label-text-alt text-error" x-text="errMsg"></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
66
templates/components/widgets/number_widget.html
Normal file
66
templates/components/widgets/number_widget.html
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
{# 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': hasError,
|
||||||
|
'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="hasError"
|
||||||
|
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="hasError">
|
||||||
|
<div class="label">
|
||||||
|
<template x-for="errMsg in errors" :key="errMsg">
|
||||||
|
<span class="label-text-alt text-error" x-text="errMsg"></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
66
templates/components/widgets/pubmed_link_widget.html
Normal file
66
templates/components/widgets/pubmed_link_widget.html
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
{# 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': hasError,
|
||||||
|
'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': hasError }"
|
||||||
|
placeholder="PubMed ID"
|
||||||
|
x-model="value"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Errors -->
|
||||||
|
<template x-if="hasError">
|
||||||
|
<div class="label">
|
||||||
|
<template x-for="errMsg in errors" :key="errMsg">
|
||||||
|
<span class="label-text-alt text-error" x-text="errMsg"></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
68
templates/components/widgets/select_widget.html
Normal file
68
templates/components/widgets/select_widget.html
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
{# 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': hasError,
|
||||||
|
'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': hasError }"
|
||||||
|
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="hasError">
|
||||||
|
<div class="label">
|
||||||
|
<template x-for="errMsg in errors" :key="errMsg">
|
||||||
|
<span class="label-text-alt text-error" x-text="errMsg"></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
60
templates/components/widgets/text_widget.html
Normal file
60
templates/components/widgets/text_widget.html
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
{# 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': hasError,
|
||||||
|
'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': hasError }"
|
||||||
|
x-model="value"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Errors -->
|
||||||
|
<template x-if="hasError">
|
||||||
|
<div class="label">
|
||||||
|
<template x-for="errMsg in errors" :key="errMsg">
|
||||||
|
<span class="label-text-alt text-error" x-text="errMsg"></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
59
templates/components/widgets/textarea_widget.html
Normal file
59
templates/components/widgets/textarea_widget.html
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
{# 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': hasError,
|
||||||
|
'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': hasError }"
|
||||||
|
x-model="value"
|
||||||
|
></textarea>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Errors -->
|
||||||
|
<template x-if="hasError">
|
||||||
|
<div class="label">
|
||||||
|
<template x-for="errMsg in errors" :key="errMsg">
|
||||||
|
<span class="label-text-alt text-error" x-text="errMsg"></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -30,6 +30,7 @@
|
|||||||
<script src="{% static 'js/alpine/search.js' %}"></script>
|
<script src="{% static 'js/alpine/search.js' %}"></script>
|
||||||
<script src="{% static 'js/alpine/pagination.js' %}"></script>
|
<script src="{% static 'js/alpine/pagination.js' %}"></script>
|
||||||
<script src="{% static 'js/alpine/pathway.js' %}"></script>
|
<script src="{% static 'js/alpine/pathway.js' %}"></script>
|
||||||
|
<script src="{% static 'js/alpine/components/schema-form.js' %}"></script>
|
||||||
|
|
||||||
{# Font Awesome #}
|
{# Font Awesome #}
|
||||||
<link
|
<link
|
||||||
|
|||||||
@ -4,17 +4,152 @@
|
|||||||
id="new_scenario_modal"
|
id="new_scenario_modal"
|
||||||
class="modal"
|
class="modal"
|
||||||
x-data="{
|
x-data="{
|
||||||
...modalForm(),
|
isSubmitting: false,
|
||||||
|
error: null,
|
||||||
scenarioType: 'empty',
|
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) {
|
validateYear(el) {
|
||||||
if (el.value && el.value.length < 4) {
|
if (el.value && el.value.length < 4) {
|
||||||
el.value = new Date().getFullYear();
|
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()"
|
@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 -->
|
<!-- Header -->
|
||||||
<h3 class="text-lg font-bold">New Scenario</h3>
|
<h3 class="text-lg font-bold">New Scenario</h3>
|
||||||
|
|
||||||
@ -30,20 +165,12 @@
|
|||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<div class="py-4">
|
<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">
|
<div class="alert alert-info mb-4">
|
||||||
<span>
|
<span>
|
||||||
Please enter name, description, and date of scenario. Date should be
|
Please enter name, description, and date of scenario. Date should be
|
||||||
associated to the data, not the current date. For example, this
|
associated to the data, not the current date. For example, this could
|
||||||
could reflect the publishing date of a study. You can leave all
|
reflect the publishing date of a study. You can leave all fields but
|
||||||
fields but the name empty and fill them in later.
|
the name empty and fill them in later.
|
||||||
<a
|
<a
|
||||||
target="_blank"
|
target="_blank"
|
||||||
href="https://wiki.envipath.org/index.php/scenario"
|
href="https://wiki.envipath.org/index.php/scenario"
|
||||||
@ -53,15 +180,33 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<template x-if="error">
|
||||||
|
<div class="alert alert-error mb-4">
|
||||||
|
<span x-text="error"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- Form fields -->
|
||||||
|
<template x-if="!loadingSchemas">
|
||||||
|
<div>
|
||||||
<div class="form-control mb-3">
|
<div class="form-control mb-3">
|
||||||
<label class="label" for="scenario-name">
|
<label class="label" for="scenario-name">
|
||||||
<span class="label-text">Name</span>
|
<span class="label-text">Name</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="scenario-name"
|
id="scenario-name"
|
||||||
name="scenario-name"
|
type="text"
|
||||||
class="input input-bordered w-full"
|
class="input input-bordered w-full"
|
||||||
placeholder="Name"
|
placeholder="Name"
|
||||||
|
x-model="formData.name"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -72,9 +217,10 @@
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="scenario-description"
|
id="scenario-description"
|
||||||
name="scenario-description"
|
type="text"
|
||||||
class="input input-bordered w-full"
|
class="input input-bordered w-full"
|
||||||
placeholder="Description"
|
placeholder="Description"
|
||||||
|
x-model="formData.description"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -85,30 +231,27 @@
|
|||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
id="dateYear"
|
|
||||||
name="scenario-date-year"
|
|
||||||
class="input input-bordered w-24"
|
class="input input-bordered w-24"
|
||||||
placeholder="YYYY"
|
placeholder="YYYY"
|
||||||
max="{% now 'Y' %}"
|
max="{% now 'Y' %}"
|
||||||
|
x-model="formData.dateYear"
|
||||||
@blur="validateYear($el)"
|
@blur="validateYear($el)"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
id="dateMonth"
|
|
||||||
name="scenario-date-month"
|
|
||||||
min="1"
|
min="1"
|
||||||
max="12"
|
max="12"
|
||||||
class="input input-bordered w-20"
|
class="input input-bordered w-20"
|
||||||
placeholder="MM"
|
placeholder="MM"
|
||||||
|
x-model="formData.dateMonth"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
id="dateDay"
|
|
||||||
name="scenario-date-day"
|
|
||||||
min="1"
|
min="1"
|
||||||
max="31"
|
max="31"
|
||||||
class="input input-bordered w-20"
|
class="input input-bordered w-20"
|
||||||
placeholder="DD"
|
placeholder="DD"
|
||||||
|
x-model="formData.dateDay"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -127,38 +270,107 @@
|
|||||||
>
|
>
|
||||||
Empty Scenario
|
Empty Scenario
|
||||||
</button>
|
</button>
|
||||||
{% for k, v in scenario_types.items %}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
class="tab"
|
class="tab"
|
||||||
:class="{ 'tab-active': scenarioType === '{{ v.name }}' }"
|
:class="{ 'tab-active': scenarioType === 'soil' }"
|
||||||
@click="scenarioType = '{{ v.name }}'"
|
@click="scenarioType = 'soil'"
|
||||||
>
|
>
|
||||||
{{ k }}
|
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>
|
</button>
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
id="scenario-type"
|
|
||||||
name="scenario-type"
|
|
||||||
x-model="scenarioType"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% for type in scenario_types.values %}
|
<!-- Schema forms for each scenario type -->
|
||||||
<div
|
<template x-if="scenarioType === 'soil'">
|
||||||
id="{{ type.name }}-specific-inputs"
|
<div class="space-y-4 mt-4">
|
||||||
x-show="scenarioType === '{{ type.name }}'"
|
<template
|
||||||
x-cloak
|
x-for="(rjsf, schemaName) in groupSchemas.soil"
|
||||||
|
:key="schemaName"
|
||||||
>
|
>
|
||||||
{% for widget in type.widgets %}
|
<div
|
||||||
{{ widget|safe }}
|
x-data="schemaRenderer({
|
||||||
{% endfor %}
|
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>
|
</div>
|
||||||
{% endfor %}
|
</template>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
@ -174,8 +386,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
@click="submit('new-scenario-modal-form')"
|
@click="submit()"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting || loadingSchemas"
|
||||||
>
|
>
|
||||||
<span x-show="!isSubmitting">Submit</span>
|
<span x-show="!isSubmitting">Submit</span>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@ -6,27 +6,110 @@
|
|||||||
x-data="{
|
x-data="{
|
||||||
isSubmitting: false,
|
isSubmitting: false,
|
||||||
selectedType: '',
|
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));
|
||||||
|
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++;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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() {
|
reset() {
|
||||||
this.isSubmitting = false;
|
this.isSubmitting = false;
|
||||||
this.selectedType = '';
|
this.selectedType = '';
|
||||||
|
this.error = null;
|
||||||
|
this.formData = null;
|
||||||
},
|
},
|
||||||
|
|
||||||
submit() {
|
setFormData(data) {
|
||||||
|
this.formData = data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async submit() {
|
||||||
if (!this.selectedType) return;
|
if (!this.selectedType) return;
|
||||||
|
|
||||||
const form = document.getElementById('add_' + this.selectedType + '_add-additional-information-modal-form');
|
const payload = window.AdditionalInformationApi.sanitizePayload(this.formData);
|
||||||
if (form && form.checkValidity()) {
|
|
||||||
|
// 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.isSubmitting = true;
|
||||||
form.submit();
|
this.error = null;
|
||||||
} else if (form) {
|
|
||||||
form.reportValidity();
|
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) {
|
||||||
|
window.dispatchEvent(new CustomEvent('set-field-errors', {
|
||||||
|
detail: err.fieldErrors
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
this.error = err.message;
|
||||||
|
} finally {
|
||||||
|
this.isSubmitting = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}"
|
}"
|
||||||
@close="reset()"
|
@close="reset()"
|
||||||
|
@form-data-ready="formData = $event.detail"
|
||||||
>
|
>
|
||||||
<div class="modal-box">
|
<div class="modal-box max-w-2xl">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<h3 class="text-lg font-bold">Add Additional Information</h3>
|
<h3 class="text-lg font-bold">Add Additional Information</h3>
|
||||||
|
|
||||||
@ -42,7 +125,24 @@
|
|||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<div class="py-4">
|
<div class="py-4">
|
||||||
<div class="form-control">
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- 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">
|
<label class="label" for="select-additional-information-type">
|
||||||
<span class="label-text">Select the type to add</span>
|
<span class="label-text">Select the type to add</span>
|
||||||
</label>
|
</label>
|
||||||
@ -52,36 +152,32 @@
|
|||||||
x-model="selectedType"
|
x-model="selectedType"
|
||||||
>
|
>
|
||||||
<option value="" selected disabled>Select the type to add</option>
|
<option value="" selected disabled>Select the type to add</option>
|
||||||
{% for add_inf in available_additional_information %}
|
<template x-for="name in sortedSchemaNames" :key="name">
|
||||||
<option value="{{ add_inf.name }}">
|
<option
|
||||||
{{ add_inf.display_name }}
|
:value="name"
|
||||||
</option>
|
x-text="(schemas[name].schema && schemas[name].schema['x-title']) || name"
|
||||||
{% endfor %}
|
></option>
|
||||||
|
</template>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% for add_inf in available_additional_information %}
|
<!-- 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
|
<div
|
||||||
class="mt-4"
|
x-data="schemaRenderer({
|
||||||
x-show="selectedType === '{{ add_inf.name }}'"
|
rjsf: schemas[selectedType],
|
||||||
x-cloak
|
mode: 'edit'
|
||||||
|
})"
|
||||||
|
x-init="await init(); $dispatch('form-data-ready', data)"
|
||||||
>
|
>
|
||||||
<form
|
{% include "components/schema_form.html" %}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
@ -98,7 +194,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
@click="submit()"
|
@click="submit()"
|
||||||
:disabled="isSubmitting || !selectedType"
|
:disabled="isSubmitting || !selectedType || loadingSchemas"
|
||||||
>
|
>
|
||||||
<span x-show="!isSubmitting">Add</span>
|
<span x-show="!isSubmitting">Add</span>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@ -3,10 +3,99 @@
|
|||||||
<dialog
|
<dialog
|
||||||
id="update_scenario_additional_information_modal"
|
id="update_scenario_additional_information_modal"
|
||||||
class="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.items = items;
|
||||||
|
this.schemas = schemas;
|
||||||
|
// Store deep copy of original items for comparison
|
||||||
|
this.originalItems = JSON.parse(JSON.stringify(items));
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err.message;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.isSubmitting = false;
|
||||||
|
this.error = null;
|
||||||
|
this.modifiedUuids.clear();
|
||||||
|
},
|
||||||
|
|
||||||
|
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;
|
||||||
|
// Dispatch event to set field errors in the specific form
|
||||||
|
if (err.itemUuid) {
|
||||||
|
window.dispatchEvent(new CustomEvent('set-field-errors-for-item', {
|
||||||
|
detail: {
|
||||||
|
uuid: err.itemUuid,
|
||||||
|
fieldErrors: err.fieldErrors
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.error = err.message;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.isSubmitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"
|
||||||
@close="reset()"
|
@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 -->
|
<!-- Header -->
|
||||||
<h3 class="text-lg font-bold">Update Additional Information</h3>
|
<h3 class="text-lg font-bold">Update Additional Information</h3>
|
||||||
|
|
||||||
@ -22,18 +111,39 @@
|
|||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<div class="py-4">
|
<div class="py-4">
|
||||||
<form
|
<!-- Loading state -->
|
||||||
id="edit-scenario-additional-information-modal-form"
|
{% include "components/modals/loading_state.html" with loading_var="loading" %}
|
||||||
accept-charset="UTF-8"
|
|
||||||
action=""
|
<!-- Error state -->
|
||||||
method="post"
|
{% 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'
|
||||||
|
})"
|
||||||
|
x-init="await init(); $watch('data', (value) => { $dispatch('update-item-data', { uuid: item.uuid, data: value }) }, { deep: true })"
|
||||||
>
|
>
|
||||||
{% csrf_token %}
|
{% include "components/schema_form.html" %}
|
||||||
{% for widget in update_widgets %}
|
</div>
|
||||||
{{ widget|safe }}
|
</div>
|
||||||
{% endfor %}
|
</div>
|
||||||
<input type="hidden" name="hidden" value="set-additional-information" />
|
</template>
|
||||||
</form>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
@ -49,10 +159,17 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
@click="submit('edit-scenario-additional-information-modal-form')"
|
@click="submit()"
|
||||||
:disabled="isSubmitting"
|
: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
|
<span
|
||||||
x-show="isSubmitting"
|
x-show="isSubmitting"
|
||||||
class="loading loading-spinner loading-sm"
|
class="loading loading-spinner loading-sm"
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
{% extends "framework_modern.html" %}
|
{% extends "framework_modern.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<script src="{% static 'js/alpine/components/widgets.js' %}"></script>
|
||||||
|
<script src="{% static 'js/api/additional-information.js' %}"></script>
|
||||||
|
|
||||||
{% block action_modals %}
|
{% block action_modals %}
|
||||||
{% include "modals/objects/edit_scenario_modal.html" %}
|
{% include "modals/objects/edit_scenario_modal.html" %}
|
||||||
@ -58,83 +61,88 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Additional Information Table -->
|
<!-- Additional Information -->
|
||||||
<div class="card bg-base-100">
|
<div class="card bg-base-100">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h3 class="card-title mb-4 text-lg">Additional Information</h3>
|
<h3 class="card-title mb-4 text-lg">Additional Information</h3>
|
||||||
<div class="overflow-x-auto">
|
<div
|
||||||
<table class="table-zebra table">
|
x-data="{
|
||||||
<thead>
|
items: [],
|
||||||
<tr>
|
schemas: {},
|
||||||
<th>Property</th>
|
loading: true,
|
||||||
<th>Value</th>
|
error: null,
|
||||||
<th>Unit</th>
|
async init() {
|
||||||
{% if meta.can_edit %}
|
try {
|
||||||
<th>Remove</th>
|
// Use the unified API client for loading data
|
||||||
{% endif %}
|
const { items, schemas } = await window.AdditionalInformationApi.loadSchemasAndItems('{{ scenario.uuid }}');
|
||||||
</tr>
|
this.items = items;
|
||||||
</thead>
|
this.schemas = schemas;
|
||||||
<tbody>
|
} catch (err) {
|
||||||
{% for ai in scenario.get_additional_information %}
|
this.error = err.message;
|
||||||
<tr>
|
console.error('Error loading additional information:', err);
|
||||||
<td>{{ ai.property_name|safe }}</td>
|
} finally {
|
||||||
<td>{{ ai.property_data|safe }}</td>
|
this.loading = false;
|
||||||
<td>{{ ai.property_unit|safe }}</td>
|
}
|
||||||
{% if meta.can_edit %}
|
},
|
||||||
<td>
|
async deleteItem(uuid) {
|
||||||
<form
|
if (!confirm('Are you sure you want to delete this item?')) return;
|
||||||
action="{% url 'package scenario detail' scenario.package.uuid scenario.uuid %}"
|
|
||||||
method="post"
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
{% csrf_token %}
|
<!-- Loading state -->
|
||||||
<input
|
<template x-if="loading">
|
||||||
type="hidden"
|
<div class="flex items-center justify-center p-4">
|
||||||
name="uuid"
|
<span class="loading loading-spinner loading-md"></span>
|
||||||
value="{{ ai.uuid }}"
|
</div>
|
||||||
/>
|
</template>
|
||||||
<input
|
|
||||||
type="hidden"
|
<!-- Error state -->
|
||||||
name="hidden"
|
<template x-if="error">
|
||||||
value="delete-additional-information"
|
<div class="alert alert-error mb-4">
|
||||||
/>
|
<span x-text="error"></span>
|
||||||
<button type="submit" class="btn btn-sm btn-ghost">
|
</div>
|
||||||
<svg
|
</template>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="16"
|
<!-- Items list -->
|
||||||
height="16"
|
<template x-if="!loading && !error">
|
||||||
viewBox="0 0 24 24"
|
<div class="space-y-4">
|
||||||
fill="none"
|
<template x-if="items.length === 0">
|
||||||
stroke="currentColor"
|
<p class="text-base-content/60">
|
||||||
stroke-width="2"
|
No additional information available.
|
||||||
stroke-linecap="round"
|
</p>
|
||||||
stroke-linejoin="round"
|
</template>
|
||||||
class="lucide lucide-minus"
|
|
||||||
|
<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()"
|
||||||
>
|
>
|
||||||
<path d="M5 12h14" />
|
{% include "components/schema_form.html" %}
|
||||||
</svg>
|
</div>
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
{% endif %}
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
{% if meta.can_edit %}
|
{% if meta.can_edit %}
|
||||||
<tr>
|
<button
|
||||||
<td></td>
|
class="btn btn-sm btn-ghost ml-2"
|
||||||
<td></td>
|
@click="deleteItem(item.uuid)"
|
||||||
<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
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="16"
|
width="16"
|
||||||
@ -152,12 +160,13 @@
|
|||||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,20 +1,15 @@
|
|||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import html
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
|
||||||
from types import NoneType
|
|
||||||
from typing import Any, Dict, List, TYPE_CHECKING
|
from typing import Any, Dict, List, TYPE_CHECKING
|
||||||
|
|
||||||
from django.conf import settings as s
|
from django.conf import settings as s
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from envipy_additional_information import NAME_MAPPING, EnviPyModel, Interval
|
|
||||||
from pydantic import BaseModel, HttpUrl
|
|
||||||
|
|
||||||
from epdb.models import (
|
from epdb.models import (
|
||||||
Compound,
|
Compound,
|
||||||
@ -49,183 +44,6 @@ if TYPE_CHECKING:
|
|||||||
from epdb.logic import SPathway
|
from epdb.logic import SPathway
|
||||||
|
|
||||||
|
|
||||||
class HTMLGenerator:
|
|
||||||
registry = {x.__name__: x for x in NAME_MAPPING.values()}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def generate_html(additional_information: "EnviPyModel", prefix="") -> str:
|
|
||||||
from typing import Union, get_args, get_origin
|
|
||||||
|
|
||||||
if isinstance(additional_information, type):
|
|
||||||
clz_name = additional_information.__name__
|
|
||||||
else:
|
|
||||||
clz_name = additional_information.__class__.__name__
|
|
||||||
|
|
||||||
widget = f'<h4 class="h4 font-semibold mt-2 mb-1">{clz_name}</h4>'
|
|
||||||
|
|
||||||
if hasattr(additional_information, "uuid"):
|
|
||||||
uuid = additional_information.uuid
|
|
||||||
widget += f'<input type="hidden" name="{clz_name}__{prefix}__uuid" value="{uuid}">'
|
|
||||||
|
|
||||||
for name, field in additional_information.model_fields.items():
|
|
||||||
value = getattr(additional_information, name, None)
|
|
||||||
full_name = f"{clz_name}__{prefix}__{name}"
|
|
||||||
annotation = field.annotation
|
|
||||||
base_type = get_origin(annotation) or annotation
|
|
||||||
|
|
||||||
# Optional[Interval[float]] alias for Union[X, None]
|
|
||||||
if base_type is Union:
|
|
||||||
for arg in get_args(annotation):
|
|
||||||
if arg is not NoneType:
|
|
||||||
field_type = arg
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
field_type = base_type
|
|
||||||
|
|
||||||
is_interval_float = (
|
|
||||||
field_type == Interval[float]
|
|
||||||
or str(field_type) == str(Interval[float])
|
|
||||||
or "Interval[float]" in str(field_type)
|
|
||||||
)
|
|
||||||
|
|
||||||
if is_interval_float:
|
|
||||||
label_text_start = " ".join([x.capitalize() for x in name.split("_")]) + " Start"
|
|
||||||
label_text_end = " ".join([x.capitalize() for x in name.split("_")]) + " End"
|
|
||||||
widget += f"""
|
|
||||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label" for="{full_name}__start">
|
|
||||||
<span class="label-text">{label_text_start}</span>
|
|
||||||
</label>
|
|
||||||
<input type="number" class="input input-bordered w-full" id="{full_name}__start" name="{full_name}__start" value="{value.start if value else ""}">
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label" for="{full_name}__end">
|
|
||||||
<span class="label-text">{label_text_end}</span>
|
|
||||||
</label>
|
|
||||||
<input type="number" class="input input-bordered w-full" id="{full_name}__end" name="{full_name}__end" value="{value.end if value else ""}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
elif issubclass(field_type, Enum):
|
|
||||||
options: str = ""
|
|
||||||
for e in field_type:
|
|
||||||
options += f'<option value="{e.value}" {"selected" if e == value else ""}>{html.escape(e.name)}</option>'
|
|
||||||
|
|
||||||
label_text = " ".join([x.capitalize() for x in name.split("_")])
|
|
||||||
widget += f"""
|
|
||||||
<div class="form-control mb-4">
|
|
||||||
<label class="label" for="{full_name}">
|
|
||||||
<span class="label-text">{label_text}</span>
|
|
||||||
</label>
|
|
||||||
<select class="select select-bordered w-full" id="{full_name}" name="{full_name}">
|
|
||||||
<option value="" disabled selected>Select {label_text}</option>
|
|
||||||
{options}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
else:
|
|
||||||
if field_type is str or field_type is HttpUrl:
|
|
||||||
input_type = "text"
|
|
||||||
elif field_type is float or field_type is int:
|
|
||||||
input_type = "number"
|
|
||||||
elif field_type is bool:
|
|
||||||
input_type = "checkbox"
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Could not parse field type {field_type} for {name}")
|
|
||||||
|
|
||||||
value_to_use = value if value and field_type is not bool else ""
|
|
||||||
label_text = " ".join([x.capitalize() for x in name.split("_")])
|
|
||||||
|
|
||||||
if field_type is bool:
|
|
||||||
widget += f"""
|
|
||||||
<div class="form-control mb-4">
|
|
||||||
<label class="label cursor-pointer">
|
|
||||||
<span class="label-text">{label_text}</span>
|
|
||||||
<input type="checkbox" class="checkbox" id="{full_name}" name="{full_name}" {"checked" if value else ""}>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
else:
|
|
||||||
widget += f"""
|
|
||||||
<div class="form-control mb-4">
|
|
||||||
<label class="label" for="{full_name}">
|
|
||||||
<span class="label-text">{label_text}</span>
|
|
||||||
</label>
|
|
||||||
<input type="{input_type}" class="input input-bordered w-full" id="{full_name}" name="{full_name}" value="{value_to_use}">
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
|
|
||||||
return widget
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def build_models(params) -> Dict[str, List["EnviPyModel"]]:
|
|
||||||
def has_non_none(d):
|
|
||||||
"""
|
|
||||||
Recursively checks if any value in a (possibly nested) dict is not None.
|
|
||||||
"""
|
|
||||||
for value in d.values():
|
|
||||||
if isinstance(value, dict):
|
|
||||||
if has_non_none(value): # recursive check
|
|
||||||
return True
|
|
||||||
elif value is not None:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
"""
|
|
||||||
Build Pydantic model instances from flattened HTML parameters.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
params: dict of {param_name: value}, e.g. form data
|
|
||||||
model_registry: mapping of class names (strings) to Pydantic model classes
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: {ClassName: [list of model instances]}
|
|
||||||
"""
|
|
||||||
grouped: Dict[str, Dict[str, Dict[str, Any]]] = {}
|
|
||||||
|
|
||||||
# Step 1: group fields by ClassName and Number
|
|
||||||
for key, value in params.items():
|
|
||||||
if value == "":
|
|
||||||
value = None
|
|
||||||
|
|
||||||
parts = key.split("__")
|
|
||||||
if len(parts) < 3:
|
|
||||||
continue # skip invalid keys
|
|
||||||
|
|
||||||
class_name, number, *field_parts = parts
|
|
||||||
grouped.setdefault(class_name, {}).setdefault(number, {})
|
|
||||||
|
|
||||||
# handle nested fields like interval__start
|
|
||||||
target = grouped[class_name][number]
|
|
||||||
current = target
|
|
||||||
for p in field_parts[:-1]:
|
|
||||||
current = current.setdefault(p, {})
|
|
||||||
current[field_parts[-1]] = value
|
|
||||||
|
|
||||||
# Step 2: instantiate Pydantic models
|
|
||||||
instances: Dict[str, List[BaseModel]] = defaultdict(list)
|
|
||||||
for class_name, number_dict in grouped.items():
|
|
||||||
model_cls = HTMLGenerator.registry.get(class_name)
|
|
||||||
|
|
||||||
if not model_cls:
|
|
||||||
logger.info(f"Could not find model class for {class_name}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
for number, fields in number_dict.items():
|
|
||||||
if not has_non_none(fields):
|
|
||||||
print(f"Skipping empty {class_name} {number} {fields}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
uuid = fields.pop("uuid", None)
|
|
||||||
instance = model_cls(**fields)
|
|
||||||
if uuid:
|
|
||||||
instance.__dict__["uuid"] = uuid
|
|
||||||
instances[class_name].append(instance)
|
|
||||||
|
|
||||||
return instances
|
|
||||||
|
|
||||||
|
|
||||||
class PackageExporter:
|
class PackageExporter:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|||||||
17
uv.lock
generated
17
uv.lock
generated
@ -670,6 +670,7 @@ dependencies = [
|
|||||||
{ name = "envipy-plugins" },
|
{ name = "envipy-plugins" },
|
||||||
{ name = "epam-indigo" },
|
{ name = "epam-indigo" },
|
||||||
{ name = "gunicorn" },
|
{ name = "gunicorn" },
|
||||||
|
{ name = "jsonref" },
|
||||||
{ name = "networkx" },
|
{ name = "networkx" },
|
||||||
{ name = "nh3" },
|
{ name = "nh3" },
|
||||||
{ name = "polars" },
|
{ name = "polars" },
|
||||||
@ -711,11 +712,12 @@ requires-dist = [
|
|||||||
{ name = "django-polymorphic", specifier = ">=4.1.0" },
|
{ name = "django-polymorphic", specifier = ">=4.1.0" },
|
||||||
{ name = "django-stubs", marker = "extra == 'dev'", specifier = ">=5.2.4" },
|
{ name = "django-stubs", marker = "extra == 'dev'", specifier = ">=5.2.4" },
|
||||||
{ name = "enviformer", git = "ssh://git@git.envipath.com/enviPath/enviformer.git?rev=v0.1.4" },
|
{ name = "enviformer", git = "ssh://git@git.envipath.com/enviPath/enviformer.git?rev=v0.1.4" },
|
||||||
{ name = "envipy-additional-information", git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git?rev=v0.1.7" },
|
{ name = "envipy-additional-information", git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git?rev=v0.2.0" },
|
||||||
{ name = "envipy-ambit", git = "ssh://git@git.envipath.com/enviPath/enviPy-ambit.git" },
|
{ name = "envipy-ambit", git = "ssh://git@git.envipath.com/enviPath/enviPy-ambit.git" },
|
||||||
{ name = "envipy-plugins", git = "ssh://git@git.envipath.com/enviPath/enviPy-plugins.git?rev=v0.1.0" },
|
{ name = "envipy-plugins", git = "ssh://git@git.envipath.com/enviPath/enviPy-plugins.git?rev=v0.1.0" },
|
||||||
{ name = "epam-indigo", specifier = ">=1.30.1" },
|
{ name = "epam-indigo", specifier = ">=1.30.1" },
|
||||||
{ name = "gunicorn", specifier = ">=23.0.0" },
|
{ name = "gunicorn", specifier = ">=23.0.0" },
|
||||||
|
{ name = "jsonref", specifier = ">=1.1.0" },
|
||||||
{ name = "msal", marker = "extra == 'ms-login'", specifier = ">=1.33.0" },
|
{ name = "msal", marker = "extra == 'ms-login'", specifier = ">=1.33.0" },
|
||||||
{ name = "networkx", specifier = ">=3.4.2" },
|
{ name = "networkx", specifier = ">=3.4.2" },
|
||||||
{ name = "nh3", specifier = "==0.3.2" },
|
{ name = "nh3", specifier = "==0.3.2" },
|
||||||
@ -739,8 +741,8 @@ provides-extras = ["ms-login", "dev"]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "envipy-additional-information"
|
name = "envipy-additional-information"
|
||||||
version = "0.1.7"
|
version = "0.2.0"
|
||||||
source = { git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git?rev=v0.1.7#d02a5d5e6a931e6565ea86127813acf7e4b33a30" }
|
source = { git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git?rev=v0.2.0#02e3ae64b2ff42a5d2b723a76727c8f6755c9a90" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
]
|
]
|
||||||
@ -1015,6 +1017,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/72/68/84050ed2679e2d2ee2030a60d3e5a59a29d0533cd03fd8d9891e36592b0f/jpype1-1.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1a3814e4f65d67e36bdb03b8851d5ece8d7a408aa3a24251ea0609bb8fba77dd", size = 497229, upload-time = "2025-07-07T13:50:57.842Z" },
|
{ url = "https://files.pythonhosted.org/packages/72/68/84050ed2679e2d2ee2030a60d3e5a59a29d0533cd03fd8d9891e36592b0f/jpype1-1.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1a3814e4f65d67e36bdb03b8851d5ece8d7a408aa3a24251ea0609bb8fba77dd", size = 497229, upload-time = "2025-07-07T13:50:57.842Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jsonref"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jwcrypto"
|
name = "jwcrypto"
|
||||||
version = "1.5.6"
|
version = "1.5.6"
|
||||||
|
|||||||
Reference in New Issue
Block a user