Coverage for utilities/jsonschema.py: 40%

105 statements  

« 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 

4 

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 

12 

13from utilities.string import title 

14from utilities.templatetags.builtins.filters import render_markdown 

15from utilities.validators import MultipleOfValidator 

16 

17__all__ = ( 

18 'JSONSchemaProperty', 

19 'PropertyTypeEnum', 

20 'StringFormatEnum', 

21 'validate_schema', 

22) 

23 

24 

25class PropertyTypeEnum(Enum): 

26 STRING = 'string' 

27 INTEGER = 'integer' 

28 NUMBER = 'number' 

29 BOOLEAN = 'boolean' 

30 ARRAY = 'array' 

31 OBJECT = 'object' 

32 

33 

34class StringFormatEnum(Enum): 

35 EMAIL = 'email' 

36 URI = 'uri' 

37 IRI = 'iri' 

38 UUID = 'uuid' 

39 DATE = 'date' 

40 TIME = 'time' 

41 DATETIME = 'datetime' 

42 

43 

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} 

52 

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} 

62 

63 

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 

71 

72 # Strings 

73 minLength: int | None = None 

74 maxLength: int | None = None 

75 pattern: str | None = None # Regex 

76 format: StringFormatEnum | None = None 

77 

78 # Numbers 

79 minimum: int | float | None = None 

80 maximum: int | float | None = None 

81 multipleOf: int | float | None = None 

82 

83 # Arrays 

84 items: dict | None = field(default_factory=dict) 

85 

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 } 

97 

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 

106 

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]() 

112 

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 ] 

127 

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 ] 

140 

141 return self.field_class(**field_kwargs) 

142 

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}") 

163 

164 

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))