Coverage for utilities/jsonschema.py: 40%
105 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1from dataclasses import dataclass, field
2from enum import Enum
3from typing import Any
5from django import forms
6from django.contrib.postgres.forms import SimpleArrayField
7from django.core.exceptions import ValidationError
8from django.core.validators import RegexValidator
9from django.utils.translation import gettext_lazy as _
10from jsonschema.exceptions import SchemaError
11from jsonschema.validators import validator_for
13from utilities.string import title
14from utilities.templatetags.builtins.filters import render_markdown
15from utilities.validators import MultipleOfValidator
17__all__ = (
18 'JSONSchemaProperty',
19 'PropertyTypeEnum',
20 'StringFormatEnum',
21 'validate_schema',
22)
25class PropertyTypeEnum(Enum):
26 STRING = 'string'
27 INTEGER = 'integer'
28 NUMBER = 'number'
29 BOOLEAN = 'boolean'
30 ARRAY = 'array'
31 OBJECT = 'object'
34class StringFormatEnum(Enum):
35 EMAIL = 'email'
36 URI = 'uri'
37 IRI = 'iri'
38 UUID = 'uuid'
39 DATE = 'date'
40 TIME = 'time'
41 DATETIME = 'datetime'
44FORM_FIELDS = {
45 PropertyTypeEnum.STRING.value: forms.CharField,
46 PropertyTypeEnum.INTEGER.value: forms.IntegerField,
47 PropertyTypeEnum.NUMBER.value: forms.FloatField,
48 PropertyTypeEnum.BOOLEAN.value: forms.BooleanField,
49 PropertyTypeEnum.ARRAY.value: SimpleArrayField,
50 PropertyTypeEnum.OBJECT.value: forms.JSONField,
51}
53STRING_FORM_FIELDS = {
54 StringFormatEnum.EMAIL.value: forms.EmailField,
55 StringFormatEnum.URI.value: forms.URLField,
56 StringFormatEnum.IRI.value: forms.URLField,
57 StringFormatEnum.UUID.value: forms.UUIDField,
58 StringFormatEnum.DATE.value: forms.DateField,
59 StringFormatEnum.TIME.value: forms.TimeField,
60 StringFormatEnum.DATETIME.value: forms.DateTimeField,
61}
64@dataclass
65class JSONSchemaProperty:
66 type: PropertyTypeEnum = PropertyTypeEnum.STRING.value
67 title: str | None = None
68 description: str | None = None
69 default: Any = None
70 enum: list | None = None
72 # Strings
73 minLength: int | None = None
74 maxLength: int | None = None
75 pattern: str | None = None # Regex
76 format: StringFormatEnum | None = None
78 # Numbers
79 minimum: int | float | None = None
80 maximum: int | float | None = None
81 multipleOf: int | float | None = None
83 # Arrays
84 items: dict | None = field(default_factory=dict)
86 def to_form_field(self, name, required=False):
87 """
88 Instantiate and return a Django form field suitable for editing the property's value.
89 """
90 field_class = self.field_class
91 field_kwargs = {
92 'label': self.title or title(name),
93 'help_text': render_markdown(self.description),
94 'required': required,
95 'initial': self.default,
96 }
98 # Choices
99 if self.type == PropertyTypeEnum.ARRAY.value and (item_enums := self.items.get('enum')):
100 field_kwargs['choices'] = [(v, v) for v in item_enums]
101 elif self.enum:
102 choices = [(v, v) for v in self.enum]
103 if not required:
104 choices = [(None, ''), *choices]
105 field_kwargs['choices'] = choices
107 # Arrays
108 if self.type == PropertyTypeEnum.ARRAY.value:
109 if not self.items.get('enum'):
110 items_type = self.items.get('type', PropertyTypeEnum.STRING.value)
111 field_kwargs['base_field'] = FORM_FIELDS[items_type]()
113 # String validation
114 if self.type == PropertyTypeEnum.STRING.value:
115 # Checking against CharField is safe because the other CharField-derived fields are
116 # ruled out by the "is a string" check above. UUIDField is the exception: it cleans to
117 # a uuid.UUID, which the length validators can't call len() on.
118 if issubclass(field_class, forms.CharField) and not issubclass(field_class, forms.UUIDField):
119 if self.minLength is not None:
120 field_kwargs['min_length'] = self.minLength
121 if self.maxLength is not None:
122 field_kwargs['max_length'] = self.maxLength
123 if self.pattern is not None:
124 field_kwargs['validators'] = [
125 RegexValidator(regex=self.pattern)
126 ]
128 # Integer/number validation
129 elif self.type in (PropertyTypeEnum.INTEGER.value, PropertyTypeEnum.NUMBER.value):
130 if issubclass(field_class, forms.IntegerField):
131 field_kwargs['widget'] = forms.NumberInput(attrs={'step': 'any'})
132 if self.minimum is not None:
133 field_kwargs['min_value'] = self.minimum
134 if self.maximum is not None:
135 field_kwargs['max_value'] = self.maximum
136 if self.multipleOf:
137 field_kwargs['validators'] = [
138 MultipleOfValidator(multiple=self.multipleOf)
139 ]
141 return self.field_class(**field_kwargs)
143 @property
144 def field_class(self):
145 """
146 Resolve the property's type (and string format, if specified) to the appropriate field class.
147 """
148 if self.type == PropertyTypeEnum.ARRAY.value and self.items.get('enum'):
149 return forms.MultipleChoiceField
150 if self.enum:
151 if self.type == PropertyTypeEnum.ARRAY.value:
152 return forms.MultipleChoiceField
153 return forms.ChoiceField
154 if self.type == PropertyTypeEnum.STRING.value and self.format is not None:
155 try:
156 return STRING_FORM_FIELDS[self.format]
157 except KeyError:
158 raise ValueError(f"Unsupported string format type: {self.format}")
159 try:
160 return FORM_FIELDS[self.type]
161 except KeyError:
162 raise ValueError(f"Unknown property type: {self.type}")
165def validate_schema(schema):
166 """
167 Check that a minimum JSON schema definition is defined.
168 """
169 # Pass on empty values
170 if schema in (None, ''):
171 return
172 # Provide some basic sanity checking (not provided by jsonschema)
173 if type(schema) is not dict: 173 ↛ 174line 173 didn't jump to line 174 because the condition on line 173 was never true
174 raise ValidationError(_("Invalid JSON schema definition"))
175 try:
176 ValidatorClass = validator_for(schema)
177 ValidatorClass.check_schema(schema)
178 except SchemaError as e:
179 raise ValidationError(_("Invalid JSON schema definition: {error}").format(error=e))