Merge ~cgrabowski/maas:expanded_text_filter into maas:master

Proposed by Christian Grabowski
Status: Merged
Approved by: Christian Grabowski
Approved revision: 5c01b76229dc726321269b724e59fec9fc6ba7d4
Merge reported by: MAAS Lander
Merged at revision: not available
Proposed branch: ~cgrabowski/maas:expanded_text_filter
Merge into: maas:master
Diff against target: 677 lines (+544/-18)
3 files modified
src/maasserver/node_constraint_filter_forms.py (+302/-13)
src/maasserver/tests/test_node_constraint_filter_forms.py (+238/-1)
src/maasserver/websockets/handlers/node.py (+4/-4)
Reviewer Review Type Date Requested Status
MAAS Lander Approve
Alexsander de Souza Approve
Review via email: mp+426332@code.launchpad.net

Commit message

move websocket handler node constraint form to child class

substring match multiple substrings

add substring filter

To post a comment you must log in.
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b expanded_text_filter lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas/job/branch-tester/13161/console
COMMIT: 84ff165ae2cac6a41cd9bd8074aa949594e62ef0

review: Needs Fixing
Revision history for this message
Alexsander de Souza (alexsander-souza) wrote :

Some testcases broke (see TestFilterNodeForm)

also a few nits inline

review: Needs Fixing
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b expanded_text_filter lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas/job/branch-tester/13172/console
COMMIT: 221e8b8f94a6bf54f8b2095193b9fe92846aae50

review: Needs Fixing
Revision history for this message
Christian Grabowski (cgrabowski) wrote :

jenkins: !test

Revision history for this message
Christian Grabowski (cgrabowski) :
Revision history for this message
Alexsander de Souza (alexsander-souza) wrote :

+1

review: Approve
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b expanded_text_filter lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: SUCCESS
COMMIT: 221e8b8f94a6bf54f8b2095193b9fe92846aae50

review: Approve
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b expanded_text_filter lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: SUCCESS
COMMIT: 5c01b76229dc726321269b724e59fec9fc6ba7d4

review: Approve
Revision history for this message
MAAS Lander (maas-lander) wrote :
Revision history for this message
MAAS Lander (maas-lander) wrote :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/src/maasserver/node_constraint_filter_forms.py b/src/maasserver/node_constraint_filter_forms.py
2index bfdee5e..1a4a765 100644
3--- a/src/maasserver/node_constraint_filter_forms.py
4+++ b/src/maasserver/node_constraint_filter_forms.py
5@@ -29,6 +29,7 @@ from maasserver.forms import (
6 )
7 from maasserver.models import (
8 BlockDevice,
9+ BootResource,
10 Filesystem,
11 Interface,
12 Node,
13@@ -876,6 +877,13 @@ class FilterNodeForm(RenamableFieldsForm):
14 )
15 return None
16
17+ def _set_clean_tags_error(self, tag_names, db_tag_names):
18+ unknown_tags = tag_names.difference(db_tag_names)
19+ error_msg = "No such tag(s): %s." % ", ".join(
20+ "'%s'" % tag for tag in unknown_tags
21+ )
22+ set_form_error(self, self.get_field_name("tags"), error_msg)
23+
24 def clean_tags(self):
25 value = self.cleaned_data[self.get_field_name("tags")]
26 if value:
27@@ -888,22 +896,24 @@ class FilterNodeForm(RenamableFieldsForm):
28 )
29 )
30 if len(tag_names) != len(db_tag_names):
31- unknown_tags = tag_names.difference(db_tag_names)
32- error_msg = "No such tag(s): %s." % ", ".join(
33- "'%s'" % tag for tag in unknown_tags
34- )
35- set_form_error(self, self.get_field_name("tags"), error_msg)
36+ self._set_clean_tags_error(tag_names, db_tag_names)
37 return None
38 return tag_names
39 return None
40
41+ def _set_zone_error(self, value, field):
42+ if type(value) == list:
43+ error_msg = "No such zone(s): %s." % ", ".join(value)
44+ else:
45+ error_msg = "No such zone: '%s'." % value
46+ set_form_error(self, self.get_field_name(field), error_msg)
47+
48 def clean_zone(self):
49 value = self.cleaned_data[self.get_field_name("zone")]
50 if value:
51 nonexistent_names = detect_nonexistent_names(Zone, [value])
52 if nonexistent_names:
53- error_msg = "No such zone: '%s'." % value
54- set_form_error(self, self.get_field_name("zone"), error_msg)
55+ self._set_zone_error(value, "zone")
56 return None
57 return value
58 return None
59@@ -914,18 +924,23 @@ class FilterNodeForm(RenamableFieldsForm):
60 return None
61 nonexistent_names = detect_nonexistent_names(Zone, value)
62 if nonexistent_names:
63- error_msg = "No such zone(s): %s." % ", ".join(nonexistent_names)
64- set_form_error(self, self.get_field_name("not_in_zone"), error_msg)
65+ self._set_zone_error(nonexistent_names, "not_in_zone")
66 return None
67 return value
68
69+ def _set_pool_error(self, value, field):
70+ if type(value) == list:
71+ error_msg = "No such pool(s): %s." % ", ".join(value)
72+ else:
73+ error_msg = "No such pool: '%s'." % value
74+ set_form_error(self, self.get_field_name(field), error_msg)
75+
76 def clean_pool(self):
77 value = self.cleaned_data[self.get_field_name("pool")]
78 if value:
79 nonexistent_names = detect_nonexistent_names(ResourcePool, [value])
80 if nonexistent_names:
81- error_msg = "No such pool: '%s'." % value
82- set_form_error(self, self.get_field_name("pool"), error_msg)
83+ self._set_pool_error(value, "pool")
84 return None
85 return value
86 return None
87@@ -936,8 +951,7 @@ class FilterNodeForm(RenamableFieldsForm):
88 return None
89 nonexistent_names = detect_nonexistent_names(ResourcePool, value)
90 if nonexistent_names:
91- error_msg = "No such pool(s): %s." % ", ".join(nonexistent_names)
92- set_form_error(self, self.get_field_name("not_in_pool"), error_msg)
93+ self._set_pool_error(nonexistent_names, "not_in_pool")
94 return None
95 return value
96
97@@ -1407,3 +1421,278 @@ class ReadNodesForm(FilterNodeForm):
98 status_id = getattr(NODE_STATUS, status.upper())
99 filtered_nodes = filtered_nodes.filter(status=status_id)
100 return filtered_nodes
101+
102+
103+class FreeTextFilterNodeForm(ReadNodesForm):
104+
105+ mac_address = UnconstrainedMultipleChoiceField(
106+ label="MAC addresses to filter on",
107+ required=False,
108+ )
109+
110+ def _substring_filter(self, queryset, field, substring, exclude=False):
111+ if type(substring) != str: # assume substring is a list of substrings
112+ query = Q()
113+ for substr in substring:
114+ substring_filter = {f"{field}__contains": substr}
115+ query = query | Q(**substring_filter)
116+ if exclude:
117+ return queryset.exclude(query)
118+ return queryset.filter(query)
119+
120+ substring_filter = {f"{field}__contains": substring}
121+ if exclude:
122+ return queryset.exclude(**substring_filter)
123+ return queryset.filter(**substring_filter)
124+
125+ def clean_tags(self):
126+ value = self.cleaned_data[self.get_field_name("tags")]
127+ if value:
128+ tag_names = parse_legacy_tags(value)
129+ # Validate tags.
130+ tag_names = set(tag_names)
131+ db_tag_names = set(
132+ self._substring_filter(
133+ Tag.objects, "name", tag_names
134+ ).values_list("name", flat=True)
135+ )
136+ if len(tag_names) < len(db_tag_names):
137+ self._set_clean_tags_error(tag_names, db_tag_names)
138+ return None
139+ return db_tag_names
140+
141+ def clean_arch(self):
142+ value = self.cleaned_data[self.get_field_name("arch")]
143+ if value:
144+ archs = self._substring_filter(
145+ BootResource.objects, "architecture", value
146+ ).values_list("architecture", flat=True)
147+ if not archs:
148+ set_form_error(
149+ self,
150+ self.get_field_name("arch"),
151+ "Architecture not recognised.",
152+ )
153+ return None
154+ return archs
155+
156+ def _clean_zones(self, value, field):
157+ zones = self._substring_filter(Zone.objects, "name", value)
158+ if not zones:
159+ self._set_zone_error(value + "*", field)
160+ return None
161+ return zones
162+
163+ def clean_zone(self):
164+ value = self.cleaned_data[self.get_field_name("zone")]
165+ if value:
166+ return self._clean_zones(value, "zone")
167+
168+ def clean_not_in_zone(self):
169+ value = self.cleaned_data[self.get_field_name("not_in_zone")]
170+ if value:
171+ return self._clean_zones(value, "not_in_zone")
172+
173+ def filter_by_zone(self, filtered_nodes):
174+ zones = self.cleaned_data.get(self.get_field_name("zone"))
175+ if zones:
176+ filtered_nodes = filtered_nodes.filter(zone__in=zones)
177+ not_in_zones = self.cleaned_data.get(
178+ self.get_field_name("not_in_zone")
179+ )
180+ if not_in_zones:
181+ filtered_nodes = filtered_nodes.exclude(zone__in=not_in_zones)
182+ return filtered_nodes
183+
184+ def _clean_pools(self, value, field):
185+ pool = self._substring_filter(ResourcePool.objects, "name", value)
186+ if not pool:
187+ self._set_pool_error(value + "*", field)
188+ return None
189+ return pool
190+
191+ def clean_pool(self):
192+ value = self.cleaned_data[self.get_field_name("pool")]
193+ if value:
194+ return self._clean_pools(value, "pool")
195+
196+ def clean_not_in_pool(self):
197+ value = self.cleaned_data[self.get_field_name("not_in_pool")]
198+ if value:
199+ return self._clean_pools(value, "not_in_pool")
200+
201+ def filter_by_pool(self, filtered_nodes):
202+ pools = self.cleaned_data.get(self.get_field_name("pool"))
203+ if pools:
204+ filtered_nodes = filtered_nodes.filter(pool__in=pools)
205+ not_in_pools = self.cleaned_data.get(
206+ self.get_field_name("not_in_pool")
207+ )
208+ if not_in_pools:
209+ filtered_nodes = filtered_nodes.exclude(pool__in=not_in_pools)
210+ return filtered_nodes
211+
212+ def filter_by_pod_or_pod_type(self, filtered_nodes):
213+ pod_name = self.cleaned_data[self.get_field_name("pod")]
214+ not_pod_name = self.cleaned_data[self.get_field_name("not_pod")]
215+ pod_type = self.cleaned_data[self.get_field_name("pod_type")]
216+ not_pod_type = self.cleaned_data[self.get_field_name("not_pod_type")]
217+ if pod_name:
218+ filtered_nodes = self._substring_filter(
219+ filtered_nodes, "bmc__name", pod_name
220+ )
221+ if not_pod_name:
222+ filtered_nodes = self._substring_filter(
223+ filtered_nodes, "bmc__name", not_pod_name, exclude=True
224+ )
225+ if pod_type:
226+ filtered_nodes = self._substring_filter(
227+ filtered_nodes, "bmc__type", pod_type
228+ )
229+ if not_pod_type:
230+ filtered_nodes = self._substring_filter(
231+ Pod.objects, "bmc__type", not_pod_type, exclude=True
232+ )
233+ return filtered_nodes
234+
235+ def filter_by_fabric_classes(self, filtered_nodes):
236+ fabric_classes = self.cleaned_data.get(
237+ self.get_field_name("fabric_classes")
238+ )
239+ if fabric_classes:
240+ filtered_nodes = self._substring_filter(
241+ filtered_nodes,
242+ "current_config__interface__vlan__fabric__class_type",
243+ fabric_classes,
244+ )
245+ not_fabric_classes = self.cleaned_data.get(
246+ self.get_field_name("not_fabric_classes")
247+ )
248+ if not_fabric_classes:
249+ filtered_nodes = self._substring_filter(
250+ filtered_nodes,
251+ "current_config__interface__vlan__fabric__class_type",
252+ not_fabric_classes,
253+ exclude=True,
254+ )
255+ return filtered_nodes
256+
257+ def filter_by_fabrics(self, filtered_nodes):
258+ fabrics = self.cleaned_data.get(self.get_field_name("fabrics"))
259+ if fabrics:
260+ filtered_nodes = self._substring_filter(
261+ filtered_nodes,
262+ "current_config__interface__vlan__fabric__name",
263+ fabrics,
264+ )
265+ not_fabrics = self.cleaned_data.get(self.get_field_name("not_fabrics"))
266+ if not_fabrics:
267+ filtered_nodes = self._substring_filter(
268+ filtered_nodes,
269+ "current_config__interface__vlan__fabric__name",
270+ not_fabrics,
271+ exclude=True,
272+ )
273+ return filtered_nodes
274+
275+ def clean_vlans(self):
276+ value = self.cleaned_data.get(self.get_field_name("vlans"))
277+ if value:
278+ vlans = self._substring_filter(VLAN.objects, "name", value)
279+ if not vlans:
280+ set_form_error(
281+ self,
282+ self.get_field_name("vlans"),
283+ "no vlan found for %s" % value,
284+ )
285+ return None
286+ return value
287+
288+ def filter_by_vlans(self, filtered_nodes):
289+ vlans = self.cleaned_data.get(self.get_field_name("vlans"))
290+ if vlans:
291+ filtered_nodes = self._substring_filter(
292+ filtered_nodes, "current_config__interface__vlan__name", vlans
293+ )
294+ not_vlans = self.cleaned_data.get(self.get_field_name("not_vlans"))
295+ if not_vlans:
296+ filtered_nodes = self._substring_filter(
297+ filtered_nodes,
298+ "current_config__interface__vlan__name",
299+ vlans,
300+ exclude=True,
301+ )
302+ return filtered_nodes
303+
304+ def clean_subnets(self):
305+ value = self.cleaned_data.get(self.get_field_name("subnets"))
306+ if value:
307+ subnets = self._substring_filter(Subnet.objects, "cidr", value)
308+ if not subnets:
309+ set_form_error(
310+ self,
311+ self.get_field_name("subnets"),
312+ "no subnet found for %s" % value,
313+ )
314+ return None
315+ return value
316+
317+ def filter_by_subnets(self, filtered_nodes):
318+ subnets = self.cleaned_data.get(self.get_field_name("subnets"))
319+ if subnets:
320+ filtered_nodes = self._substring_filter(
321+ filtered_nodes,
322+ "current_config__interface__ip_addresses__subnet__cidr",
323+ subnets,
324+ )
325+ not_subnets = self.cleaned_data.get(self.get_field_name("not_subnets"))
326+ if not_subnets:
327+ filtered_nodes = self._substring_filter(
328+ filtered_nodes,
329+ "current_config__interface__ip_addresses__subnet__cidr",
330+ not_subnets,
331+ exclude=True,
332+ )
333+ return filtered_nodes
334+
335+ def filter_by_hostnames(self, filtered_nodes):
336+ hostnames = self.cleaned_data.get(self.get_field_name("hostname"))
337+ if hostnames:
338+ filtered_nodes = self._substring_filter(
339+ filtered_nodes, "hostname", hostnames
340+ )
341+ return filtered_nodes
342+
343+ def clean_mac_address(self):
344+ value = self.cleaned_data.get(self.get_field_name("mac_address"))
345+ if value:
346+ interfaces = self._substring_filter(
347+ Interface.objects, "mac_address", value
348+ )
349+ if not interfaces:
350+ set_form_error(
351+ self,
352+ self.get_field_name("mac_address"),
353+ "no mac address found for %s" % value,
354+ )
355+ return None
356+ return value
357+
358+ def filter_by_mac_addresses(self, filtered_nodes):
359+ value = self.cleaned_data.get(self.get_field_name("mac_address"))
360+ if value:
361+ interfaces = self._substring_filter(
362+ Interface.objects, "mac_address", value
363+ )
364+ filtered_nodes = filtered_nodes.filter(
365+ current_config__interface__in=interfaces
366+ )
367+ return filtered_nodes
368+
369+ def filter_by_agent_name(self, filtered_nodes):
370+ agent_name = self.cleaned_data[self.get_field_name("agent_name")]
371+ if agent_name:
372+ filtered_nodes = self._substring_filter(
373+ filtered_nodes, "agent_name", agent_name
374+ )
375+ return filtered_nodes
376diff --git a/src/maasserver/tests/test_node_constraint_filter_forms.py b/src/maasserver/tests/test_node_constraint_filter_forms.py
377index 9df891b..df67c69 100644
378--- a/src/maasserver/tests/test_node_constraint_filter_forms.py
379+++ b/src/maasserver/tests/test_node_constraint_filter_forms.py
380@@ -24,11 +24,12 @@ from maasserver.enum import (
381 NODE_STATUS,
382 POWER_STATE,
383 )
384-from maasserver.models import Domain, Machine, NodeDevice, Zone
385+from maasserver.models import Domain, Machine, NodeDevice, Tag, Zone
386 from maasserver.node_constraint_filter_forms import (
387 AcquireNodeForm,
388 detect_nonexistent_names,
389 FilterNodeForm,
390+ FreeTextFilterNodeForm,
391 generate_architecture_wildcards,
392 get_architecture_wildcards,
393 get_storage_constraints_from_string,
394@@ -1592,6 +1593,242 @@ class TestFilterNodeForm(MAASServerTestCase, FilterConstraintsMixin):
395 self.assertEqual(constraints.keys(), described_constraints)
396
397
398+class TestFreeTextFilterNodeForm(MAASServerTestCase, FilterConstraintsMixin):
399+ def test_substring_filter_one_substring(self):
400+ name = factory.make_name("hostname")
401+ node1 = factory.make_Node(hostname=name)
402+ node2 = factory.make_Node()
403+ form = FreeTextFilterNodeForm(data={})
404+ result = form._substring_filter(
405+ Machine.objects, "hostname", name[0 : len("hostname-") + 1]
406+ )
407+ self.assertIn(node1, list(result))
408+ self.assertNotIn(node2, list(result))
409+
410+ def test_substring_filter_list_of_substrings(self):
411+ tags = [
412+ factory.make_Tag(name=factory.make_name("tag")) for _ in range(3)
413+ ]
414+ form = FreeTextFilterNodeForm(data={})
415+ # +1 to include first letter in random suffix
416+ result = form._substring_filter(
417+ Tag.objects,
418+ "name",
419+ [tag.name[0 : len("tag-") + 1] for tag in tags],
420+ )
421+ self.assertCountEqual(tags, list(result))
422+
423+ def test_substring_filter_exact_match(self):
424+ name = factory.make_name("hostname")
425+ node1 = factory.make_Node(hostname=name)
426+ node2 = factory.make_Node()
427+ form = FreeTextFilterNodeForm(data={})
428+ result = form._substring_filter(Machine.objects, "hostname", name)
429+ self.assertIn(node1, list(result))
430+ self.assertNotIn(node2, list(result))
431+
432+ def test_substring_arch_filter(self):
433+ architecture = factory.make_name("arch")
434+ subarch = factory.make_name()
435+ arch = "/".join([architecture, subarch])
436+ factory.make_usable_boot_resource(architecture=arch)
437+ node1 = factory.make_Node(architecture=arch)
438+ node2 = factory.make_Node()
439+ constraints = {
440+ "arch": arch[:2],
441+ }
442+ form = FreeTextFilterNodeForm(data=constraints)
443+ self.assertTrue(form.is_valid())
444+ filtered_nodes = form.filter_nodes(Machine.objects.all())[0]
445+ self.assertIn(node1, filtered_nodes)
446+ self.assertNotIn(node2, filtered_nodes)
447+
448+ def test_substring_tag_filter(self):
449+ tags = [
450+ factory.make_Tag(name=factory.make_name("tag")) for _ in range(3)
451+ ]
452+ node1 = factory.make_Node()
453+ node2 = factory.make_Node()
454+ [node1.tags.add(tag) for tag in tags]
455+ constraints = {
456+ "tags": [tag.name[: len("tag-") + 1] for tag in tags],
457+ }
458+ form = FreeTextFilterNodeForm(data=constraints)
459+ self.assertTrue(form.is_valid())
460+ filtered_nodes = form.filter_nodes(Machine.objects.all())[0]
461+ self.assertIn(node1, filtered_nodes)
462+ self.assertNotIn(node2, filtered_nodes)
463+
464+ def test_substring_zone_filter(self):
465+ zone = factory.make_Zone()
466+ node1 = factory.make_Node(zone=zone)
467+ node2 = factory.make_Node()
468+ constraints = {
469+ "zone": zone.name[:2],
470+ }
471+ form = FreeTextFilterNodeForm(data=constraints)
472+ self.assertTrue(form.is_valid())
473+ filtered_nodes = form.filter_nodes(Machine.objects.all())[0]
474+ self.assertIn(node1, filtered_nodes)
475+ self.assertNotIn(node2, filtered_nodes)
476+
477+ def test_substring_not_in_zone_filter(self):
478+ zone = factory.make_Zone()
479+ node1 = factory.make_Node(zone=zone)
480+ node2 = factory.make_Node()
481+ constraints = {
482+ "not_in_zone": [zone.name[:2]],
483+ }
484+ form = FreeTextFilterNodeForm(data=constraints)
485+ self.assertTrue(form.is_valid())
486+ filtered_nodes = form.filter_nodes(Machine.objects.all())[0]
487+ self.assertNotIn(node1, filtered_nodes)
488+ self.assertIn(node2, filtered_nodes)
489+
490+ def test_substring_pool_filter(self):
491+ pool = factory.make_ResourcePool()
492+ node1 = factory.make_Node(pool=pool)
493+ node2 = factory.make_Node()
494+ constraints = {
495+ "pool": pool.name[: len("resourcepool-") + 1],
496+ }
497+ form = FreeTextFilterNodeForm(data=constraints)
498+ self.assertTrue(form.is_valid())
499+ filtered_nodes = form.filter_nodes(Machine.objects.all())[0]
500+ self.assertIn(node1, filtered_nodes)
501+ self.assertNotIn(node2, filtered_nodes)
502+
503+ def test_substring_not_in_pool_filter(self):
504+ pool = factory.make_ResourcePool()
505+ node1 = factory.make_Node(pool=pool)
506+ node2 = factory.make_Node()
507+ constraints = {
508+ "not_in_pool": [pool.name[: len("resourcepool-") + 1]],
509+ }
510+ form = FreeTextFilterNodeForm(data=constraints)
511+ self.assertTrue(form.is_valid())
512+ filtered_nodes = form.filter_nodes(Machine.objects.all())[0]
513+ self.assertNotIn(node1, filtered_nodes)
514+ self.assertIn(node2, filtered_nodes)
515+
516+ def test_substring_pod_filter(self):
517+ pod = factory.make_Pod()
518+ node1 = factory.make_Node(bmc=pod.as_bmc())
519+ node2 = factory.make_Node()
520+ constraints = {"pod": pod.name[: len("pod-") + 1]}
521+ form = FreeTextFilterNodeForm(data=constraints)
522+ self.assertTrue(form.is_valid())
523+ filtered_nodes = form.filter_nodes(Machine.objects.all())[0]
524+ self.assertIn(node1, filtered_nodes)
525+ self.assertNotIn(node2, filtered_nodes)
526+
527+ def test_substring_fabrics_filter(self):
528+ fabric = factory.make_Fabric()
529+ vlan = factory.make_VLAN(fabric=fabric)
530+ interface = factory.make_Interface(vlan=vlan)
531+ node1 = factory.make_Node()
532+ node1.current_config.interface_set.add(interface)
533+ node2 = factory.make_Node()
534+ constraints = {
535+ "fabrics": [fabric.name[:2]],
536+ }
537+ form = FreeTextFilterNodeForm(data=constraints)
538+ self.assertTrue(form.is_valid())
539+ filtered_nodes = form.filter_nodes(Machine.objects.all())[0]
540+ self.assertIn(node1, filtered_nodes)
541+ self.assertNotIn(node2, filtered_nodes)
542+
543+ def test_substring_fabric_classes_filter(self):
544+ fabric_class = factory.make_name()
545+ fabric = factory.make_Fabric(class_type=fabric_class)
546+ vlan = factory.make_VLAN(fabric=fabric)
547+ interface = factory.make_Interface(vlan=vlan)
548+ node1 = factory.make_Node()
549+ node1.current_config.interface_set.add(interface)
550+ node2 = factory.make_Node()
551+ constraints = {
552+ "fabric_classes": [fabric_class[:2]],
553+ }
554+ form = FreeTextFilterNodeForm(data=constraints)
555+ form.is_valid()
556+ print(form.errors)
557+ self.assertTrue(form.is_valid())
558+ filtered_nodes = form.filter_nodes(Machine.objects.all())[0]
559+ self.assertIn(node1, filtered_nodes)
560+ self.assertNotIn(node2, filtered_nodes)
561+
562+ def test_substring_vlans_filter(self):
563+ vlan = factory.make_VLAN(name=factory.make_name())
564+ interface = factory.make_Interface(vlan=vlan)
565+ node1 = factory.make_Node()
566+ node1.current_config.interface_set.add(interface)
567+ node2 = factory.make_Node()
568+ constraints = {
569+ "vlans": [vlan.name[:2]],
570+ }
571+ form = FreeTextFilterNodeForm(data=constraints)
572+ self.assertTrue(form.is_valid())
573+ filtered_nodes = form.filter_nodes(Machine.objects.all())[0]
574+ self.assertIn(node1, filtered_nodes)
575+ self.assertNotIn(node2, filtered_nodes)
576+
577+ def test_substring_subnet_filter(self):
578+ subnet = factory.make_Subnet()
579+ interface = factory.make_Interface(subnet=subnet)
580+ node1 = factory.make_Node()
581+ node1.current_config.interface_set.add(interface)
582+ node2 = factory.make_Node()
583+ constraints = {
584+ "subnets": [str(subnet.cidr)[:3]],
585+ }
586+ form = FreeTextFilterNodeForm(data=constraints)
587+ self.assertTrue(form.is_valid())
588+ filtered_nodes = form.filter_nodes(Machine.objects.all())[0]
589+ self.assertIn(node1, filtered_nodes)
590+ self.assertNotIn(node2, filtered_nodes)
591+
592+ def test_substring_hostnames_filter(self):
593+ hostname = factory.make_name()
594+ node1 = factory.make_Node(hostname=hostname)
595+ node2 = factory.make_Node()
596+ constraints = {
597+ "hostname": [hostname[:2]],
598+ }
599+ form = FreeTextFilterNodeForm(data=constraints)
600+ self.assertTrue(form.is_valid())
601+ filtered_nodes = form.filter_nodes(Machine.objects.all())[0]
602+ self.assertIn(node1, filtered_nodes)
603+ self.assertNotIn(node2, filtered_nodes)
604+
605+ def test_substring_mac_addresses_filter(self):
606+ mac_address = factory.make_mac_address()
607+ interface = factory.make_Interface(mac_address=mac_address)
608+ node1 = factory.make_Node()
609+ node1.current_config.interface_set.add(interface)
610+ node2 = factory.make_Node()
611+ constraints = {
612+ "mac_address": [str(mac_address)[:4]],
613+ }
614+ form = FreeTextFilterNodeForm(data=constraints)
615+ self.assertTrue(form.is_valid())
616+ filtered_nodes = form.filter_nodes(Machine.objects.all())[0]
617+ self.assertIn(node1, filtered_nodes)
618+ self.assertNotIn(node2, filtered_nodes)
619+
620+ def test_substring_agent_name_filter(self):
621+ name = factory.make_name()
622+ node1 = factory.make_Node(agent_name=name)
623+ node2 = factory.make_Node()
624+ constraints = {
625+ "agent_name": name,
626+ }
627+ form = FreeTextFilterNodeForm(data=constraints)
628+ self.assertTrue(form.is_valid())
629+ filtered_nodes = form.filter_nodes(Machine.objects.all())[0]
630+ self.assertIn(node1, filtered_nodes)
631+ self.assertNotIn(node2, filtered_nodes)
632+
633+
634 class TestAcquireNodeForm(MAASServerTestCase, FilterConstraintsMixin):
635
636 form_class = AcquireNodeForm
637diff --git a/src/maasserver/websockets/handlers/node.py b/src/maasserver/websockets/handlers/node.py
638index 15a4999..7a9fc53 100644
639--- a/src/maasserver/websockets/handlers/node.py
640+++ b/src/maasserver/websockets/handlers/node.py
641@@ -45,8 +45,8 @@ from maasserver.models import (
642 from maasserver.models.nodeprobeddetails import script_output_nsmap
643 from maasserver.node_action import compile_node_actions
644 from maasserver.node_constraint_filter_forms import (
645+ FreeTextFilterNodeForm,
646 GROUPABLE_FIELDS,
647- ReadNodesForm,
648 STATIC_FILTER_FIELDS,
649 )
650 from maasserver.permissions import NodePermission
651@@ -1200,7 +1200,7 @@ class NodeHandler(TimestampedModelHandler):
652
653 def _filter(self, qs, action, params):
654 qs = super()._filter(qs, action, params)
655- form = ReadNodesForm(data=params)
656+ form = FreeTextFilterNodeForm(data=params)
657 if not form.is_valid():
658 raise HandlerValidationError(form.errors)
659 qs, _, _ = form.filter_nodes(qs)
660@@ -1215,7 +1215,7 @@ class NodeHandler(TimestampedModelHandler):
661 "dynamic": name not in STATIC_FILTER_FIELDS,
662 "for_grouping": name in GROUPABLE_FIELDS,
663 }
664- for name, field in ReadNodesForm.declared_fields.items()
665+ for name, field in FreeTextFilterNodeForm.declared_fields.items()
666 ]
667
668 def _get_dynamic_filter_options(self, key):
669@@ -1316,7 +1316,7 @@ class NodeHandler(TimestampedModelHandler):
670 "a 'group_key' param must be provided for filter_options"
671 )
672 else:
673- if key not in ReadNodesForm.declared_fields.keys():
674+ if key not in FreeTextFilterNodeForm.declared_fields.keys():
675 raise HandlerValidationError(
676 f"{key} is not a valid 'group_key' for filter_options"
677 )

Subscribers

People subscribed via source and target branches