Coverage for ipam/forms/fields.py: 17%
55 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.core.exceptions import ValidationError
5from django.utils.translation import gettext_lazy as _
7from ipam.forms.widgets import PortMappingWidget
8from ipam.utils import expand_port_mapping, group_port_mapping_rows
9from ipam.validators import validate_port_mappings
11__all__ = (
12 'PortMappingField',
13)
16class PortMappingField(forms.Field):
17 """
18 A form field for editing a service's port mappings. Presents one row per protocol (each with a
19 comma/range list of ports) but cleans to the model's flat list of ``protocol/port`` strings, e.g.
20 ``['tcp/80', 'tcp/443', 'udp/53']``.
21 """
22 widget = PortMappingWidget
24 def prepare_value(self, value):
25 # Group the flat ['tcp/80', 'tcp/443', 'udp/53'] list back into per-protocol rows for the widget.
26 if value in (None, ''):
27 return '[]'
28 if isinstance(value, str):
29 # An already-grouped JSON string (e.g. re-rendering a bound form) is passed through. A bare
30 # 'protocol/port' string arrives when cloning a single-mapping object: the querystring
31 # single-value collapse (normalize_querydict) yields a str rather than a list, so group it
32 # like the list case instead of handing the widget unparseable JSON (which blanks the row).
33 try:
34 json.loads(value)
35 except (TypeError, ValueError):
36 return json.dumps(group_port_mapping_rows([value]))
37 return value
38 return json.dumps(group_port_mapping_rows(value))
40 def to_python(self, value):
41 if value in (None, ''):
42 return []
43 # A list is assumed to already be the flat ['tcp/80', ...] form (e.g. set programmatically)
44 if isinstance(value, list):
45 mappings = value
46 else:
47 try:
48 rows = json.loads(value)
49 except (TypeError, ValueError):
50 raise ValidationError(_("Invalid port mapping data."))
51 if not isinstance(rows, list):
52 raise ValidationError(_("Invalid port mapping data."))
54 mappings = []
55 for position, row in enumerate(rows, start=1):
56 # The widget's JS always submits a list of {protocol, ports} objects, but the hidden
57 # input is just POST data: a hand-crafted payload can put anything here, so validate the
58 # shape rather than letting a non-dict row raise AttributeError (a 500) on .get() below.
59 if not isinstance(row, dict):
60 raise ValidationError(_("Invalid port mapping data."))
61 protocol = row.get('protocol')
62 raw_ports = row.get('ports')
63 # Likewise `protocol` is only ever a string, and `ports` either a string (the widget's
64 # comma/range format) or a list of ports (set programmatically); anything else would reach
65 # expand_port_mapping() and fail there on .strip().
66 if (
67 (protocol is not None and not isinstance(protocol, str))
68 or (raw_ports is not None and not isinstance(raw_ports, (str, list)))
69 ):
70 raise ValidationError(_("Invalid port mapping data."))
71 if isinstance(raw_ports, str):
72 raw_ports = raw_ports.strip()
73 # Ignore entirely-empty rows (e.g. the default blank row on an untouched form)
74 if not protocol and not raw_ports:
75 continue
76 # Expand via the shared helper, which accepts either the widget's comma/range string or an
77 # already-expanded list, rejects a blank protocol, and preserves a protocol-without-ports
78 # row as a bare 'protocol/' token. Errors are re-raised with the row's position (among the
79 # submitted rows — the widget omits entirely-blank ones), since it renders one row per
80 # protocol and an unqualified "must specify a protocol" gives no clue which row to fix.
81 # Errors from validate_port_mappings() below are deliberately left unqualified: each quotes
82 # the offending mapping already, and a duplicate spans two rows.
83 try:
84 mappings.extend(expand_port_mapping(protocol, raw_ports))
85 except ValidationError as e:
86 raise ValidationError([
87 _("Row {position}: {error}").format(position=position, error=message)
88 for message in e.messages
89 ])
91 # Shared validation returns the canonical (normalized) list of protocol/port strings
92 return validate_port_mappings(mappings)
94 def has_changed(self, initial, data):
95 # Compare the parsed mappings rather than raw strings, so cosmetic differences (row/port
96 # ordering, whitespace) don't register as a change.
97 def normalize(value):
98 try:
99 return sorted(self.to_python(value))
100 except ValidationError:
101 return None
103 return normalize(self.prepare_value(initial)) != normalize(data)