[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

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

View File

@ -0,0 +1,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()
)

View File

@ -3,15 +3,16 @@ from ninja.security import SessionAuth
from .auth import BearerTokenAuth
from .endpoints import (
compounds,
models,
packages,
pathways,
reactions,
rules,
scenarios,
settings,
compounds,
rules,
reactions,
pathways,
models,
structure,
additional_information,
settings,
)
# Main router with authentication
@ -31,4 +32,5 @@ router.add_router("", reactions.router)
router.add_router("", pathways.router)
router.add_router("", models.router)
router.add_router("", structure.router)
router.add_router("", additional_information.router)
router.add_router("", settings.router)

View File

@ -1,5 +1,5 @@
from ninja import FilterSchema, FilterLookup, Schema
from typing import Annotated, Optional
from typing import Annotated, Optional, List, Dict, Any
from uuid import UUID
@ -51,6 +51,23 @@ class ScenarioOutSchema(PackageEntityOutSchema):
pass
class AdditionalInformationItemSchema(Schema):
"""Schema for additional information item in scenario creation."""
type: str
data: Dict[str, Any]
class ScenarioCreateSchema(Schema):
"""Schema for creating a new scenario."""
name: str
description: str = ""
scenario_date: str = "No date"
scenario_type: str = "Not specified"
additional_information: List[AdditionalInformationItemSchema] = []
class CompoundOutSchema(PackageEntityOutSchema):
pass