Skip to content

Commit 4ca7e8f

Browse files
committed
make KeyValueWidget a normal Field instead of a MultiValueField/Widget
1 parent c271c70 commit 4ca7e8f

File tree

16 files changed

+505
-478
lines changed

16 files changed

+505
-478
lines changed

ca/django_ca/fields.py

Lines changed: 46 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -14,28 +14,32 @@
1414
"""Django form fields related to django-ca."""
1515

1616
import abc
17+
import json
1718
import typing
18-
from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple, Type, Union
19+
from typing import Any, Dict, Iterable, List, Optional, Tuple, Type, Union
20+
21+
from pydantic import ValidationError as PydanticValidationError
1922

2023
from cryptography import x509
2124
from cryptography.x509.oid import AuthorityInformationAccessOID
2225

2326
from django import forms
27+
from django.core.exceptions import ValidationError
2428
from django.utils.safestring import mark_safe
2529
from django.utils.translation import gettext_lazy as _
2630

27-
from django_ca import constants, utils, widgets
31+
from django_ca import widgets
2832
from django_ca.constants import (
2933
EXTENDED_KEY_USAGE_HUMAN_READABLE_NAMES,
3034
EXTENDED_KEY_USAGE_NAMES,
3135
KEY_USAGE_NAMES,
3236
REVOCATION_REASONS,
3337
)
3438
from django_ca.extensions import get_extension_name
35-
from django_ca.pydantic.general_name import GeneralNameModel
39+
from django_ca.pydantic.general_name import GeneralNameModelList
40+
from django_ca.pydantic.name import NameModel
3641
from django_ca.typehints import AlternativeNameTypeVar, CRLExtensionTypeTypeVar, ExtensionTypeTypeVar
37-
from django_ca.utils import parse_general_name
38-
from django_ca.widgets import GeneralNameKeyValueWidget, KeyValueWidget, SubjectWidget
42+
from django_ca.widgets import GeneralNameKeyValueWidget, KeyValueWidget, NameWidget
3943

4044
if typing.TYPE_CHECKING:
4145
from django_ca.modelfields import LazyCertificateSigningRequest
@@ -120,128 +124,54 @@ def to_python(self, value: str) -> Optional[x509.ObjectIdentifier]: # type: ign
120124
) from ex
121125

122126

123-
class KeyValueField(forms.MultiValueField):
127+
class KeyValueField(forms.CharField):
124128
"""Dynamic Key/Value field."""
125129

126-
widget_class = KeyValueWidget
127-
128-
def __init__(self, key_choices: Sequence[Tuple[str, str]], **kwargs: Any) -> None:
129-
fields = [
130-
forms.JSONField(required=True),
131-
# These two just serve as template for key/value rows
132-
forms.ChoiceField(choices=key_choices, required=False),
133-
forms.CharField(required=False),
134-
]
130+
widget = KeyValueWidget
135131

136-
kwargs["require_all_fields"] = False # ... or we'd require values in the template
132+
def to_python(self, value: Optional[str]) -> List[Dict[str, Any]]: # type: ignore[override]
133+
value = super().to_python(value)
134+
if not value:
135+
return []
136+
return json.loads(value) # type: ignore[no-any-return]
137137

138-
super().__init__(fields, **kwargs)
139-
self.widget = self.widget_class(key_choices=key_choices, attrs={"class": "key-value-input"})
138+
def pydantic_validation_error(self, ex: PydanticValidationError) -> typing.NoReturn:
139+
"""Transform Pydantic ValidationError exceptions into Django ValidationError.
140140
141-
def compress(
142-
self, data_list: Tuple[List[Dict[str, str]], str, str]
143-
) -> Optional[List[Dict[str, str]]]: # pragma: no cover # This class is never used directly so far
144-
if not data_list or not data_list[0]:
145-
return None
146-
return data_list[0]
141+
This method assumes that the error occurs in `value`, as the keys are a select field and should work
142+
properly.
143+
"""
144+
raise ValidationError([error["msg"] for error in ex.errors()]) from ex
147145

148146

149-
class SubjectField(KeyValueField):
150-
"""Specialized version of KeyValue field for a certificate subject."""
147+
class NameField(KeyValueField):
148+
"""Specialized version of KeyValue field for a x509 name."""
151149

152-
widget_class = SubjectWidget
150+
widget = NameWidget
153151

154-
def __init__(self, **kwargs: Any) -> None:
155-
key_choices = [(oid.dotted_string, name) for oid, name in constants.NAME_OID_DISPLAY_NAMES.items()]
156-
super().__init__(key_choices=key_choices, **kwargs)
157-
158-
def compress( # type: ignore[override]
159-
self, data_list: Tuple[List[Dict[str, str]], str, str]
160-
) -> x509.Name:
161-
# Empty list happens when you press submit on a completely empty form
162-
if not data_list or not data_list[0]:
163-
return x509.Name([])
164-
165-
values = data_list[0]
166-
errors = []
167-
attributes: List[x509.NameAttribute] = []
168-
found_oids: Set[x509.ObjectIdentifier] = set()
169-
for oid_dict in values:
170-
try:
171-
oid = x509.ObjectIdentifier(oid_dict["key"])
172-
173-
# Check for duplicate OIDs that should not occur more than once
174-
if oid in found_oids and oid not in utils.MULTIPLE_OIDS:
175-
oid_name = constants.NAME_OID_DISPLAY_NAMES[oid]
176-
errors.append(_("%(attr)s: Attribute cannot occur more then once.") % {"attr": oid_name})
177-
else:
178-
found_oids.add(oid)
179-
180-
attr = x509.NameAttribute(oid=oid, value=oid_dict["value"])
181-
except Exception as ex: # pylint: disable=broad-exception-caught # docs don't specify class
182-
# Creating the NameAttribute failed (e.g. a country code that does *not* have two characters)
183-
errors.append(str(ex))
184-
else:
185-
attributes.append(attr)
186-
187-
if errors:
188-
raise forms.ValidationError(errors)
189-
190-
name = x509.Name(attributes)
191-
return name
152+
def to_python(self, value: Optional[str]) -> x509.Name: # type: ignore[override]
153+
parsed_value = super().to_python(value)
154+
converted_value = [{"oid": v["key"], "value": v["value"]} for v in parsed_value]
155+
try:
156+
model = NameModel.model_validate(converted_value)
157+
except PydanticValidationError as ex:
158+
self.pydantic_validation_error(ex)
159+
return model.cryptography
192160

193161

194162
class GeneralNameKeyValueField(KeyValueField):
195-
"""test."""
196-
197-
widget_class = GeneralNameKeyValueWidget
198-
199-
def __init__(self, **kwargs: Any) -> None:
200-
choices = [(key, key) for key in constants.GENERAL_NAME_TYPES]
201-
super().__init__(key_choices=choices, **kwargs)
202-
self.widget = self.widget_class(key_choices=choices, attrs={"class": "key-value-input"})
203-
204-
def compress( # type: ignore[override]
205-
self, data_list: Tuple[List[Dict[str, str]], str, str]
206-
) -> List[x509.GeneralName]:
207-
# Empty list happens when you press submit on a completely empty form
208-
if not data_list or not data_list[0]:
209-
return []
210-
211-
names = [GeneralNameModel(type=name["key"], value=name["value"]) for name in data_list[0]]
212-
return [name.cryptography for name in names]
163+
"""Specialized version of KeyValue field for a list of general names."""
213164

165+
widget = GeneralNameKeyValueWidget
214166

215-
class GeneralNamesField(forms.CharField):
216-
"""MultipleChoice field for :py:class:`~cg:cryptography.x509.RelativeDistinguishedName`."""
217-
218-
widget = widgets.GeneralNamesWidget
219-
default_error_messages = { # noqa: RUF012 # defined in base class
220-
"invalid": _("Unparsable General Name: %(error)s"),
221-
}
222-
223-
def to_python( # type: ignore[override] # superclass uses Any for str, violates inheritance (in theory)
224-
self, value: str
225-
) -> Optional[List[x509.GeneralName]]:
226-
if not value:
227-
return None
228-
229-
values = []
230-
for line in value.splitlines():
231-
line = line.strip()
232-
if not line:
233-
continue
234-
235-
try:
236-
values.append(parse_general_name(line))
237-
except ValueError as ex:
238-
raise forms.ValidationError(
239-
self.error_messages["invalid"], params={"error": ex}, code="invalid"
240-
) from ex
241-
if not values:
242-
return None
243-
244-
return values
167+
def to_python(self, value: Optional[str]) -> List[x509.GeneralName]: # type: ignore[override]
168+
parsed_value = super().to_python(value)
169+
converted_value = [{"type": v["key"], "value": v["value"]} for v in parsed_value]
170+
try:
171+
models = GeneralNameModelList.validate_python(converted_value)
172+
except PydanticValidationError as ex:
173+
self.pydantic_validation_error(ex)
174+
return [model.cryptography for model in models]
245175

246176

247177
class RelativeDistinguishedNameField(forms.CharField):
@@ -355,9 +285,9 @@ class DistributionPointField(ExtensionField[CRLExtensionTypeTypeVar]):
355285
"no-dp-or-issuer": _("A DistributionPoint needs at least a full or relative name or a crl issuer."),
356286
}
357287
fields = (
358-
GeneralNamesField(required=False), # full_name
288+
GeneralNameKeyValueField(required=False), # full_name
359289
RelativeDistinguishedNameField(required=False), # relative_name
360-
GeneralNamesField(required=False), # crl_issuer
290+
GeneralNameKeyValueField(required=False), # crl_issuer
361291
ReasonsField(required=False), # reasons
362292
)
363293

@@ -405,7 +335,7 @@ class AuthorityInformationAccessField(ExtensionField[x509.AuthorityInformationAc
405335
"""Form field for a :py:class:`~cg:cryptography.x509.AuthorityInformationAccess` extension."""
406336

407337
extension_type = x509.AuthorityInformationAccess
408-
fields = (GeneralNamesField(required=False), GeneralNamesField(required=False))
338+
fields = (GeneralNameKeyValueField(required=False), GeneralNameKeyValueField(required=False))
409339
widget = widgets.AuthorityInformationAccessWidget
410340

411341
def get_value(

ca/django_ca/forms.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ class CreateCertificateBaseForm(CertificateModelForm):
115115
help_text=_("Password for the private key. If not given, the private key must be unencrypted."),
116116
)
117117
expires = forms.DateField(initial=_initial_expires, widget=AdminDateWidget())
118-
subject = fields.SubjectField(label=_("Subject"), required=False)
118+
subject = fields.NameField(label=_("Subject"), required=False)
119119
subject_alternative_name = fields.SubjectAlternativeNameField(
120120
required=False,
121121
help_text=_("""Alternative names for the certificate (one per line)."""),

ca/django_ca/pydantic/general_name.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@
1717
import typing
1818
from datetime import datetime
1919
from ipaddress import ip_address, ip_network
20-
from typing import Any, Optional, Union
20+
from typing import Any, List, Optional, Union
2121

2222
import idna
23-
from pydantic import BeforeValidator, Discriminator, Tag, model_validator
23+
from pydantic import BeforeValidator, Discriminator, Tag, TypeAdapter, model_validator
2424

2525
import asn1crypto.core
2626
from cryptography import x509
@@ -312,3 +312,6 @@ def cryptography(self) -> x509.GeneralName:
312312

313313
# TYPEHINT NOTE: constant has type GeneralName, abstract constructor does not take arguments
314314
return constants.GENERAL_NAME_TYPES[self.type](self.value) # type: ignore[call-arg]
315+
316+
317+
GeneralNameModelList = TypeAdapter(List[GeneralNameModel])

ca/django_ca/pydantic/name.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ def validate_duplicates(self) -> "NameModel":
160160
# Check if any fields are duplicate where this is not allowed (e.g. multiple CommonName fields)
161161
if oid in seen and oid not in MULTIPLE_OIDS:
162162
name = constants.NAME_OID_NAMES.get(oid, oid.dotted_string)
163-
raise ValueError(f"{name}: Attribute of this type must not occur more then once in a name.")
163+
raise ValueError(f"attribute of type {name} must not occur more then once in a name.")
164164
seen.add(oid)
165165
return self
166166

ca/django_ca/static/django_ca/admin/css/key_value.css

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,17 @@
22
display: none;
33
}
44

5-
.key-value-field .draggable-handle {
6-
margin-right: 0.5em;
5+
.key-value-field .key-value-row {
6+
display: flex;
7+
align-items: center;
8+
}
9+
10+
.key-value-field .key-value-row .key-value-input {
11+
margin-right: 0.3em;
12+
}
13+
14+
.key-value-field .key-value-row .draggable-handle {
15+
margin-right: 0.6em;
716
cursor: grab;
817
}
918

@@ -30,7 +39,7 @@
3039
.key-value-field .key-value-row select,
3140
.key-value-field .key-value-row input,
3241
.key-value-field .key-value-row button {
33-
margin: 0 0 0.5em 0;
42+
margin: 0.2em 0 0.2em 0;
3443
height: 1.875rem;
3544
box-sizing: border-box;
3645
}

ca/django_ca/static/django_ca/admin/js/key_value.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44
function updateJsonValueField(field) {
55
var list = field.querySelector('.key-value-list');
6-
var inputField = field.querySelector("input[type='hidden']");
6+
var inputField = field.querySelector("input.key-value-data");
77

88
// collect current data from input row
99
var data = [];
@@ -83,7 +83,7 @@ document.addEventListener('DOMContentLoaded', function() {
8383
// set up each key/value field
8484
document.querySelectorAll('div.key-value-field').forEach((field) => {
8585
// populate with any existing value on load
86-
var inputField = field.querySelector("input[type='hidden']");
86+
var inputField = field.querySelector("input.key-value-data");
8787
if (inputField && inputField.value) {
8888
let inputValue = JSON.parse(inputField.value);
8989
loadKeyValueList(field, inputValue);

ca/django_ca/static/django_ca/admin/js/sign.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ document.addEventListener('DOMContentLoaded', function() {
44

55
// global for the subject field
66
var subject_field = document.querySelector('.field-subject .key-value-field');
7-
var subject_input = subject_field.querySelector('input#id_subject_0'); // actual hidden input
7+
var subject_input = subject_field.querySelector('input#id_subject'); // actual hidden input
88
var key_value_list = subject_field.querySelector('.key-value-list');
99
var csr_subject_input_chapter = subject_field.querySelector(".subject-input-chapter.csr");
1010
var profile_subject_input_chapter = subject_field.querySelector(".subject-input-chapter.profile");

ca/django_ca/templates/django_ca/admin/key_value.html

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,16 @@
22
<div class="key-value-field">
33
{% block templates %}{% endblock %}
44

5-
{% with widget=widget.subwidgets.0 %}
6-
{% include widget.template_name %}
7-
{% endwith %}
5+
{% include "django/forms/widgets/input.html" %}
6+
7+
88
<span class="key-value-template">
99
<div class="key-value-row" draggable="true">
1010
<span class="draggable-handle">&#9776;</span>
11-
{% with widget=widget.subwidgets.1 %}
11+
{% with widget=widget.select_template %}
1212
{% include widget.template_name %}
1313
{% endwith %}
14-
{% with widget=widget.subwidgets.2 %}
14+
{% with widget=widget.value_template %}
1515
{% include widget.template_name %}
1616
{% endwith %}
1717
<button type="button" class="remove-row-btn button">{% translate "Remove" %}</button>

ca/django_ca/templates/django_ca/forms/widgets/multiwidget.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
{% spaceless %}<p {% include "django/forms/widgets/attrs.html" %}>
1+
{% spaceless %}<div {% include "django/forms/widgets/attrs.html" %}>
22
{% for widget in widget.subwidgets %}
33
{% if widget.label and not widget.handles_label %}
44
<label for="{{ widget.attrs.id }}">
@@ -9,4 +9,4 @@
99
<span class="help">{{ widget.help_text }}</span>
1010
{% endif %}
1111
{% endfor %}
12-
</p>{% endspaceless %}
12+
</div>{% endspaceless %}

ca/django_ca/tests/admin/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ def initialize(self) -> None:
7878
"""
7979
self.selenium.get(self.url)
8080

81-
# Top-level element of the SubjectField
81+
# Top-level element of the NameField
8282
# pylint: disable=attribute-defined-outside-init
8383
self.key_value_field = self.find(".field-subject .key-value-field")
8484
self.hidden_input = self.key_value_field.find_element(By.ID, "id_subject")

0 commit comments

Comments
 (0)