Coverage for ipam/forms/widgets.py: 35%
25 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
1import json
3from django import forms
4from django.forms.utils import flatatt
6from ipam.choices import ServiceProtocolChoices
8__all__ = (
9 'PortMappingWidget',
10)
13class PortMappingWidget(forms.Widget):
14 """
15 Renders a dynamic set of (protocol, ports) rows. The rows are serialized to a JSON string held in a
16 single hidden input (client-side JS keeps the hidden input in sync as rows are added/removed). Each
17 row's ``ports`` value is a raw comma/range string (e.g. "80,443,8000-8010"); the server expands it.
18 """
19 template_name = 'ipam/widgets/port_mappings.html'
21 # aria-* attributes which render_field_with_aria() sets per-field, and which must be copied onto the
22 # row controls: the wrapping <div> isn't a form control, so assistive technology ignores them there.
23 CONTROL_ATTRS = ('aria-describedby', 'aria-invalid')
25 def id_for_label(self, id_):
26 # The field's <label for="..."> must point at a real form control, not the wrapping <div> which
27 # carries the widget's id. Target the first row's protocol <select>; the client-side widget keeps
28 # this id on whichever row is first as rows are added and removed.
29 return f'{id_}_protocol_0' if id_ else ''
31 def get_context(self, name, value, attrs):
32 attrs = attrs or {}
33 rows = []
34 if value:
35 try:
36 rows = json.loads(value)
37 except (TypeError, ValueError):
38 rows = []
39 # Re-rendering an invalid bound form hands us back the raw POST value, which need not be the list
40 # of {protocol, ports} objects the JS produces: a crafted payload of e.g. `5` or `{"a": 1}` parses
41 # as valid JSON but would break the template's row loop. Discard anything of the wrong shape and
42 # fall through to the blank row below.
43 if not isinstance(rows, list) or not all(isinstance(row, dict) for row in rows):
44 rows = []
45 # Always render at least one (blank) row so the entry fields are visible on an empty form
46 if not rows:
47 rows = [{'protocol': '', 'ports': ''}]
48 return {
49 'widget': {
50 'name': name,
51 'value': value or '[]',
52 'rows': rows,
53 'attrs': attrs,
54 'label_id': self.id_for_label(attrs.get('id')),
55 'control_attrs': flatatt({
56 key: value for key, value in attrs.items() if key in self.CONTROL_ATTRS
57 }),
58 },
59 'protocol_choices': list(ServiceProtocolChoices),
60 }
62 def value_from_datadict(self, data, files, name):
63 return data.get(name)