[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:
2026-01-31 00:44:03 +13:00
committed by jebus
parent 9f63a9d4de
commit d80dfb5ee3
42 changed files with 3732 additions and 609 deletions

View 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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ from ninja import Router
from ninja_extra.pagination import paginate
import logging
from ..dal import get_user_packages_qs
from ..dal import get_user_packages_for_read
from ..pagination import EnhancedPageNumberPagination
from ..schemas import PackageOutSchema, SelfReviewStatusFilter
@ -23,5 +23,5 @@ def list_all_packages(request):
"""
user = request.user
qs = get_user_packages_qs(user)
qs = get_user_packages_for_read(user)
return qs.order_by("name").all()

View File

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

View File

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

View File

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

View File

@ -1,12 +1,22 @@
from django.conf import settings as s
from ninja import Router
from django.db import IntegrityError, OperationalError, DatabaseError
from ninja import Router, Body
from ninja.errors import HttpError
from ninja_extra.pagination import paginate
from uuid import UUID
from pydantic import ValidationError
import logging
import json
from epdb.models import Scenario
from epdb.logic import PackageManager
from epdb.views import _anonymous_or_real
from ..pagination import EnhancedPageNumberPagination
from ..schemas import ReviewStatusFilter, ScenarioOutSchema
from ..dal import get_user_entities_qs, get_package_scoped_entities_qs
from ..schemas import ReviewStatusFilter, ScenarioOutSchema, ScenarioCreateSchema
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()
@ -19,7 +29,7 @@ router = Router()
)
def list_all_scenarios(request):
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(
@ -33,4 +43,82 @@ def list_all_scenarios(request):
)
def list_package_scenarios(request, package_uuid: UUID):
user = request.user
return get_package_scoped_entities_qs(Scenario, package_uuid, user).order_by("name").all()
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

View File

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