[Feature] Adds timeseries display (#313)

Adds a way to input/display timeseries data to the additional information

Reviewed-on: enviPath/enviPy#313
Reviewed-by: jebus <lorsbach@envipath.com>
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
This commit is contained in:
2026-02-04 01:01:06 +13:00
committed by jebus
parent d80dfb5ee3
commit dc18b73e08
23 changed files with 1772 additions and 411 deletions

View File

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

View File

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

View File

@ -252,6 +252,51 @@ class AdditionalInformationAPITests(TestCase):
self.assertEqual(patch_response.status_code, 400)
def test_patch_validation_errors_are_user_friendly(self):
"""Test that PATCH validation errors are user-friendly and field-specific."""
self.client.force_login(self.user)
# First create an item
create_payload = {"interval": {"start": 20, "end": 25}}
create_response = self.client.post(
f"/api/v1/scenario/{self.scenario.uuid}/information/temperature/",
data=json.dumps(create_payload),
content_type="application/json",
)
item_uuid = create_response.json()["uuid"]
# Update with invalid data - wrong type (string instead of number in interval)
invalid_payload = {"interval": {"start": "not_a_number", "end": 25}}
patch_response = self.client.patch(
f"/api/v1/scenario/{self.scenario.uuid}/information/item/{item_uuid}/",
data=json.dumps(invalid_payload),
content_type="application/json",
)
self.assertEqual(patch_response.status_code, 400)
data = patch_response.json()
# Parse the error response - Django Ninja wraps errors in 'detail'
error_str = data.get("detail") or data.get("error")
self.assertIsNotNone(error_str, "Response should contain error details")
# Parse the JSON error string
error_data = json.loads(error_str)
# Check structure
self.assertEqual(error_data.get("type"), "validation_error")
self.assertIn("field_errors", error_data)
self.assertIn("message", error_data)
# Ensure error messages are user-friendly (no Pydantic URLs or technical jargon)
error_str = json.dumps(error_data)
self.assertNotIn("pydantic", error_str.lower())
self.assertNotIn("https://errors.pydantic.dev", error_str)
self.assertNotIn("loc", error_str) # No technical field like 'loc'
# Check that error message is helpful
self.assertIn("Please", error_data["message"]) # User-friendly language
def test_delete_additional_information(self):
"""Test DELETE removes additional information."""
self.client.force_login(self.user)

View File

@ -85,6 +85,10 @@ def extract_ui_config_from_model(model_cls: Type[BaseModel]) -> dict[str, Any]:
# Extract config for each field
for field_name in field_names:
# Skip if UI config doesn't exist for this field (field may be hidden from UI)
if not hasattr(ui_class, field_name):
continue
ui_config = getattr(ui_class, field_name)
if isinstance(ui_config, UIConfig):
@ -139,7 +143,9 @@ def build_schema(model_cls: Type[BaseModel]) -> dict[str, Any]:
schema[f"x-{key}"] = value
# Set standard title property from UI metadata for JSON Schema compliance
if "label" in ui_metadata:
if "title" in ui_metadata:
schema["title"] = ui_metadata["title"]
elif "label" in ui_metadata:
schema["title"] = ui_metadata["label"]
return schema

View File

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

View File

@ -2,14 +2,13 @@ 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 epapi.utils.validation_errors import handle_validation_error
from ..dal import get_scenario_for_read, get_scenario_for_write
logger = logging.getLogger(__name__)
@ -56,28 +55,6 @@ def list_scenario_info(request, scenario_uuid: UUID):
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(...)
@ -90,27 +67,7 @@ def add_scenario_info(
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))
handle_validation_error(e)
scenario = get_scenario_for_write(request.user, scenario_uuid)
@ -147,27 +104,7 @@ def update_scenario_info(
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))
handle_validation_error(e)
# Use model method for update
try: