Merge lp:~jtv/maas/node-nodegroup-api-ui into lp:maas/trunk

Proposed by Jeroen T. Vermeulen on 2012-09-21
Status: Merged
Approved by: Jeroen T. Vermeulen on 2012-09-21
Approved revision: 1026
Merged at revision: 1042
Proposed branch: lp:~jtv/maas/node-nodegroup-api-ui
Merge into: lp:maas/trunk
Diff against target: 532 lines (+276/-34)
10 files modified
src/maasserver/fields.py (+81/-1)
src/maasserver/forms.py (+38/-13)
src/maasserver/migrations/0027_add_tag_table.py (+3/-1)
src/maasserver/models/tag.py (+1/-1)
src/maasserver/preseed.py (+1/-1)
src/maasserver/templates/maasserver/snippets.html (+4/-0)
src/maasserver/tests/test_fields.py (+89/-3)
src/maasserver/tests/test_forms.py (+57/-10)
src/maasserver/tests/test_node.py (+1/-1)
src/maasserver/tests/test_tag.py (+1/-3)
To merge this branch: bzr merge lp:~jtv/maas/node-nodegroup-api-ui
Reviewer Review Type Date Requested Status
Raphaël Badin (community) Approve on 2012-09-21
John A Meinel 2012-09-21 Approve on 2012-09-21
Review via email: mp+125624@code.launchpad.net

Commit Message

Allow node-group selection when creating a node in API or UI.

Node groups are selected by subnet: any IP address in a node group's subnet identifies that node group. It could be the node's IP, or the cluster manager's, or the broadcast address, or any other IP in the subnet. This requires a node group to have an interface (subnet) defined before nodes can be added to it, and an IP in the subnet must not be in any other node group's subnet.

Description of the Change

See commit message. Discussed with Raphaël, but then again with Julian which led to a different solution.

A custom field is required to enable the form to identify nodegroups by something other than “id.”

I hit some problems in testing because initialize_node_group reset the nodegroup to the default (the master). And so I had to work yet more node-form functionality into the WithMACAddressesMixin. All the ugliness is necessitated by a combination of limitations in Django's forms hierarchy and ORM: the mixin requires the Node object to be saved (so that MACAddress objects can reference it) before the main form object has fully prepared it for saving. And there seems to be no way for application code to customize field values prior to saving an object: you have to save the form first to get your object, and then you get to change its values. To mitigate the problem when the object isn't in a state that can be saved to the database yet, you can tell the form not to save its object to the database — but that breaks down when other objects need to reference it, as is the case here. The use of mixins for different aspects of a form is classic Django style, but it works with these other limitations to bite us in this weak spot. We may want to sweep some of these classes together to simplify things.

Jeroen

To post a comment you must log in.
John A Meinel (jameinel) wrote :

I think the only missing test is to check that the field is not present when the node already exists.

Otherwise this looks good to me, but maybe we want someone with a stronger Django background to also give it a look.

review: Approve
lp:~jtv/maas/node-nodegroup-api-ui updated on 2012-09-21
1023. By Jeroen T. Vermeulen on 2012-09-21

Merge trunk, resolve conflicts.

1024. By Jeroen T. Vermeulen on 2012-09-21

Added tests, as per review call with jam.

Raphaël Badin (rvb) wrote :

Looks good. A few remarks ([0] is probably the most important one).

[0]

332 + def test_find_nodegroup_reports_if_not_found(self):
333 + self.assertRaises(
334 + NodeGroup.DoesNotExist,
335 + NodeGroupFormField().clean,
336 + factory.getRandomIPAddress())

form.clean should really fail with a ValidationError. This will allow the API to dtrt (return a bad request error).

[1]

145 - fields = (
146 - 'hostname',
147 - 'after_commissioning_action',
148 - 'architecture',
149 - 'distro_series',
150 + fields = NodeForm.Meta.fields + (

I'd rather have the field explicitly listed here, much easy to spot a missing field that way.

[2]

242 + <p>
243 + <label for="id_nodegroup">Node group</label>
244 + {{ node_form.nodegroup }}
245 + </p>

You can test this by adding a test similar to testFormContainsArchitectureChoice in src/maasserver/static/js/tests/test_node_add.js.

review: Approve
lp:~jtv/maas/node-nodegroup-api-ui updated on 2012-09-21
1025. By Jeroen T. Vermeulen on 2012-09-21

Merge trunk.

1026. By Jeroen T. Vermeulen on 2012-09-21

Review changes.

Jeroen T. Vermeulen (jtv) wrote :

Thanks for the review, gentlemen. I made the requested changes, except for two (after talking them over with Raphaël):

 - The fields change would be non-functional and thus a bit misleading in maintenance. Instead I documented the real meaning of Meta.fields, i.e. to say which fields the form should auto-generate from the model.

 - As originally envisioned, the javascript test turned out to be pointless because it only ends up testing the test's own HTML fixtures. It wouldn't be impossible to change, but not particularly cost-effective.

Jeroen

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/fields.py'
2--- src/maasserver/fields.py 2012-09-21 04:56:49 +0000
3+++ src/maasserver/fields.py 2012-09-21 09:08:18 +0000
4@@ -22,12 +22,17 @@
5 )
6 import re
7
8+from django.core.exceptions import ValidationError
9 from django.core.validators import RegexValidator
10 from django.db.models import (
11 Field,
12 SubfieldBase,
13 )
14-from django.forms import RegexField
15+from django.forms import (
16+ ModelChoiceField,
17+ RegexField,
18+ )
19+from maasserver.utils.orm import get_one
20 import psycopg2.extensions
21 from south.modelsinspector import add_introspection_rules
22
23@@ -54,6 +59,81 @@
24 ])
25
26
27+class NodeGroupFormField(ModelChoiceField):
28+ """Form field: reference to a :class:`NodeGroup`.
29+
30+ Node groups are identified by their subnets. More precisely: this
31+ field will accept any IP as an identifier for the nodegroup whose subnet
32+ contains the IP address.
33+
34+ Unless `queryset` is explicitly given, this field covers all NodeGroup
35+ objects.
36+ """
37+
38+ def __init__(self, **kwargs):
39+ # Avoid circular imports.
40+ from maasserver.models import NodeGroup
41+
42+ kwargs.setdefault('queryset', NodeGroup.objects.all())
43+ super(NodeGroupFormField, self).__init__(**kwargs)
44+
45+ def label_from_instance(self, nodegroup):
46+ """Django method: get human-readable choice label for nodegroup."""
47+ interface = nodegroup.get_managed_interface()
48+ if interface is None:
49+ return nodegroup.name
50+ else:
51+ return "%s: %s" % (nodegroup.name, interface.ip)
52+
53+ def find_nodegroup(self, ip_address):
54+ """Find the nodegroup whose subnet contains `ip_address`.
55+
56+ The matching nodegroup may have multiple interfaces on the subnet,
57+ but there can be only one matching nodegroup.
58+ """
59+ # Avoid circular imports.
60+ from maasserver.models import NodeGroup
61+
62+ return get_one(NodeGroup.objects.raw("""
63+ SELECT *
64+ FROM maasserver_nodegroup
65+ WHERE id IN (
66+ SELECT nodegroup_id
67+ FROM maasserver_nodegroupinterface
68+ WHERE (inet '%s' & subnet_mask) = (ip & subnet_mask)
69+ )
70+ """ % ip_address))
71+
72+ def clean(self, value):
73+ """Django method: provide expected output for various inputs.
74+
75+ There seems to be no clear specification on what `value` can be.
76+ This method accepts the types that we see in practice: raw bytes
77+ containing an IP address, a :class:`NodeGroup`, or the nodegroup's
78+ numerical id in text form.
79+
80+ If no nodegroup is indicated, defaults to the master.
81+ """
82+ # Avoid circular imports.
83+ from maasserver.models import NodeGroup
84+
85+ if value in (None, '', b''):
86+ nodegroup_id = NodeGroup.objects.ensure_master().id
87+ elif isinstance(value, NodeGroup):
88+ nodegroup_id = value.id
89+ elif isinstance(value, unicode) and value.isnumeric():
90+ nodegroup_id = int(value)
91+ elif isinstance(value, bytes) and '.' not in value:
92+ nodegroup_id = int(value)
93+ else:
94+ nodegroup = self.find_nodegroup(value)
95+ if nodegroup is None:
96+ raise ValidationError(
97+ "No known subnet contains %s." % value)
98+ nodegroup_id = nodegroup.id
99+ return super(NodeGroupFormField, self).clean(nodegroup_id)
100+
101+
102 class MACAddressFormField(RegexField):
103
104 def __init__(self, *args, **kwargs):
105
106=== modified file 'src/maasserver/forms.py'
107--- src/maasserver/forms.py 2012-09-19 14:07:52 +0000
108+++ src/maasserver/forms.py 2012-09-21 09:08:18 +0000
109@@ -54,15 +54,18 @@
110 from maasserver.enum import (
111 ARCHITECTURE,
112 ARCHITECTURE_CHOICES,
113+ DISTRO_SERIES,
114+ DISTRO_SERIES_CHOICES,
115 NODE_AFTER_COMMISSIONING_ACTION,
116 NODE_AFTER_COMMISSIONING_ACTION_CHOICES,
117 NODEGROUP_STATUS,
118 NODEGROUPINTERFACE_MANAGEMENT,
119 NODEGROUPINTERFACE_MANAGEMENT_CHOICES,
120- DISTRO_SERIES,
121- DISTRO_SERIES_CHOICES,
122- )
123-from maasserver.fields import MACAddressFormField
124+ )
125+from maasserver.fields import (
126+ MACAddressFormField,
127+ NodeGroupFormField,
128+ )
129 from maasserver.models import (
130 Config,
131 MACAddress,
132@@ -104,6 +107,14 @@
133
134
135 class NodeForm(ModelForm):
136+
137+ def __init__(self, *args, **kwargs):
138+ super(NodeForm, self).__init__(*args, **kwargs)
139+ if kwargs.get('instance') is None:
140+ # Creating a new node. Offer choice of nodegroup.
141+ self.fields['nodegroup'] = NodeGroupFormField(
142+ required=False, empty_label="Default (master)")
143+
144 after_commissioning_action = forms.TypedChoiceField(
145 label="After commissioning",
146 choices=NODE_AFTER_COMMISSIONING_ACTION_CHOICES, required=False,
147@@ -122,6 +133,9 @@
148
149 class Meta:
150 model = Node
151+
152+ # Fields that the form should generate automatically from the
153+ # model:
154 fields = (
155 'hostname',
156 'after_commissioning_action',
157@@ -162,11 +176,10 @@
158
159 class Meta:
160 model = Node
161- fields = (
162- 'hostname',
163- 'after_commissioning_action',
164- 'architecture',
165- 'distro_series',
166+
167+ # Fields that the form should generate automatically from the
168+ # model:
169+ fields = NodeForm.Meta.fields + (
170 'power_type',
171 'power_parameters',
172 )
173@@ -288,10 +301,18 @@
174 return []
175
176
177-def initialize_node_group(node):
178- """If `node` is not in a node group yet, enroll it in the master group."""
179- if node.nodegroup_id is None:
180+def initialize_node_group(node, form_value=None):
181+ """If `node` is not in a node group yet, initialize it.
182+
183+ The initial value is `form_value` if given, or the master nodegroup
184+ otherwise.
185+ """
186+ if node.nodegroup_id is not None:
187+ return
188+ if form_value is None:
189 node.nodegroup = NodeGroup.objects.ensure_master()
190+ else:
191+ node.nodegroup = form_value
192
193
194 class WithMACAddressesMixin:
195@@ -341,7 +362,11 @@
196 # We have to save this node in order to attach MACAddress
197 # records to it. But its nodegroup must be initialized before
198 # we can do that.
199- initialize_node_group(node)
200+ # As a side effect, this prevents editing of the node group on
201+ # an existing node. It's all horribly dependent on the order of
202+ # calls in this class family, but Django doesn't seem to give us
203+ # a good way around it.
204+ initialize_node_group(node, self.cleaned_data.get('nodegroup'))
205 node.save()
206 for mac in self.cleaned_data['mac_addresses']:
207 node.add_mac_address(mac)
208
209=== modified file 'src/maasserver/migrations/0027_add_tag_table.py'
210--- src/maasserver/migrations/0027_add_tag_table.py 2012-09-20 13:01:37 +0000
211+++ src/maasserver/migrations/0027_add_tag_table.py 2012-09-21 09:08:18 +0000
212@@ -1,8 +1,10 @@
213 # encoding: utf-8
214 import datetime
215+
216+from django.db import models
217 from south.db import db
218 from south.v2 import SchemaMigration
219-from django.db import models
220+
221
222 class Migration(SchemaMigration):
223
224
225=== modified file 'src/maasserver/models/tag.py'
226--- src/maasserver/models/tag.py 2012-09-20 13:01:37 +0000
227+++ src/maasserver/models/tag.py 2012-09-21 09:08:18 +0000
228@@ -16,8 +16,8 @@
229
230 from django.db.models import (
231 CharField,
232+ Manager,
233 TextField,
234- Manager,
235 )
236 from maasserver import DefaultMeta
237 from maasserver.models.cleansave import CleanSave
238
239=== modified file 'src/maasserver/preseed.py'
240--- src/maasserver/preseed.py 2012-09-20 09:38:18 +0000
241+++ src/maasserver/preseed.py 2012-09-21 09:08:18 +0000
242@@ -28,9 +28,9 @@
243 NODE_STATUS,
244 PRESEED_TYPE,
245 )
246+from maasserver.models import Config
247 from maasserver.server_address import get_maas_facing_server_host
248 from maasserver.utils import absolute_reverse
249-from maasserver.models import Config
250 import tempita
251
252
253
254=== modified file 'src/maasserver/templates/maasserver/snippets.html'
255--- src/maasserver/templates/maasserver/snippets.html 2012-09-18 18:04:52 +0000
256+++ src/maasserver/templates/maasserver/snippets.html 2012-09-21 09:08:18 +0000
257@@ -27,6 +27,10 @@
258 <label for="id_architecture">Architecture</label>
259 {{ node_form.architecture }}
260 </p>
261+ <p>
262+ <label for="id_nodegroup">Node group</label>
263+ {{ node_form.nodegroup }}
264+ </p>
265 {% if node_form.power_type %}
266 <p>
267 <label for="id_power_type">Power type</label>
268
269=== modified file 'src/maasserver/tests/test_fields.py'
270--- src/maasserver/tests/test_fields.py 2012-09-18 14:37:28 +0000
271+++ src/maasserver/tests/test_fields.py 2012-09-21 09:08:18 +0000
272@@ -12,10 +12,17 @@
273 __metaclass__ = type
274 __all__ = []
275
276+from django.core.exceptions import ValidationError
277 from django.db import DatabaseError
278-from django.core.exceptions import ValidationError
279-from maasserver.fields import validate_mac
280-from maasserver.models import MACAddress
281+from maasserver.fields import (
282+ NodeGroupFormField,
283+ validate_mac,
284+ )
285+from maasserver.models import (
286+ MACAddress,
287+ NodeGroup,
288+ NodeGroupInterface,
289+ )
290 from maasserver.testing.factory import factory
291 from maasserver.testing.testcase import (
292 TestCase,
293@@ -27,6 +34,85 @@
294 )
295
296
297+class TestNodeGroupFormField(TestCase):
298+
299+ def test_label_from_instance_tolerates_missing_interface(self):
300+ nodegroup = factory.make_node_group()
301+ interface = nodegroup.get_managed_interface()
302+ if interface is not None:
303+ NodeGroupInterface.objects.filter(id=interface.id).delete()
304+ self.assertEqual(
305+ nodegroup.name,
306+ NodeGroupFormField().label_from_instance(nodegroup))
307+
308+ def test_label_from_instance_shows_name_and_address(self):
309+ nodegroup = factory.make_node_group()
310+ self.assertEqual(
311+ '%s: %s' % (nodegroup.name, nodegroup.get_managed_interface().ip),
312+ NodeGroupFormField().label_from_instance(nodegroup))
313+
314+ def test_clean_defaults_to_master(self):
315+ spellings_for_none = [None, '', b'']
316+ field = NodeGroupFormField()
317+ self.assertEqual(
318+ [NodeGroup.objects.ensure_master()] * len(spellings_for_none),
319+ [field.clean(spelling) for spelling in spellings_for_none])
320+
321+ def test_clean_accepts_nodegroup(self):
322+ nodegroup = factory.make_node_group()
323+ self.assertEqual(nodegroup, NodeGroupFormField().clean(nodegroup))
324+
325+ def test_clean_accepts_id_as_text(self):
326+ nodegroup = factory.make_node_group()
327+ self.assertEqual(
328+ nodegroup,
329+ NodeGroupFormField().clean("%s" % nodegroup.id))
330+
331+ def test_clean_finds_nodegroup_by_network_address(self):
332+ nodegroup = factory.make_node_group(
333+ ip='192.168.28.1', subnet_mask='255.255.255.0')
334+ self.assertEqual(
335+ nodegroup,
336+ NodeGroupFormField().clean('192.168.28.0'))
337+
338+ def test_find_nodegroup_looks_up_nodegroup_by_controller_ip(self):
339+ nodegroup = factory.make_node_group()
340+ self.assertEqual(
341+ nodegroup,
342+ NodeGroupFormField().clean(nodegroup.get_managed_interface().ip))
343+
344+ def test_find_nodegroup_accepts_any_ip_in_nodegroup_subnet(self):
345+ nodegroup = factory.make_node_group(
346+ ip='192.168.41.1', subnet_mask='255.255.255.0')
347+ self.assertEqual(
348+ nodegroup,
349+ NodeGroupFormField().clean('192.168.41.199'))
350+
351+ def test_find_nodegroup_reports_if_not_found(self):
352+ self.assertRaises(
353+ ValidationError,
354+ NodeGroupFormField().clean,
355+ factory.getRandomIPAddress())
356+
357+ def test_find_nodegroup_reports_if_multiple_matches(self):
358+ factory.make_node_group(ip='10.0.0.1', subnet_mask='255.0.0.0')
359+ factory.make_node_group(ip='10.1.1.1', subnet_mask='255.255.255.0')
360+ self.assertRaises(
361+ NodeGroup.MultipleObjectsReturned,
362+ NodeGroupFormField().clean, '10.1.1.2')
363+
364+ def test_find_nodegroup_handles_multiple_matches_on_same_nodegroup(self):
365+ nodegroup = factory.make_node_group(
366+ ip='10.0.0.1', subnet_mask='255.0.0.0')
367+ NodeGroupInterface.objects.create(
368+ nodegroup=nodegroup, ip='10.0.0.2', subnet_mask='255.0.0.0',
369+ interface='eth71')
370+ NodeGroupInterface.objects.create(
371+ nodegroup=nodegroup, ip='10.0.0.3', subnet_mask='255.0.0.0',
372+ interface='eth72')
373+ self.assertEqual(nodegroup, NodeGroupFormField().clean('10.0.0.9'))
374+
375+
376 class TestMACAddressField(TestCase):
377
378 def test_mac_address_is_stored_normalized_and_loaded(self):
379
380=== modified file 'src/maasserver/tests/test_forms.py'
381--- src/maasserver/tests/test_forms.py 2012-09-18 18:18:21 +0000
382+++ src/maasserver/tests/test_forms.py 2012-09-21 09:08:18 +0000
383@@ -62,6 +62,7 @@
384 AcceptAndCommission,
385 Delete,
386 )
387+from maasserver.testing import reload_object
388 from maasserver.testing.factory import factory
389 from maasserver.testing.testcase import TestCase
390 from provisioningserver.enum import POWER_TYPE_CHOICES
391@@ -71,19 +72,27 @@
392
393 class TestHelpers(TestCase):
394
395- def test_initialize_node_group_initializes_nodegroup_to_master(self):
396- node = Node(
397- NODE_STATUS.DECLARED,
398- architecture=factory.getRandomEnum(ARCHITECTURE))
399- initialize_node_group(node)
400- self.assertEqual(NodeGroup.objects.ensure_master(), node.nodegroup)
401-
402 def test_initialize_node_group_leaves_nodegroup_reference_intact(self):
403 preselected_nodegroup = factory.make_node_group()
404 node = factory.make_node(nodegroup=preselected_nodegroup)
405 initialize_node_group(node)
406 self.assertEqual(preselected_nodegroup, node.nodegroup)
407
408+ def test_initialize_node_group_initializes_nodegroup_to_form_value(self):
409+ node = Node(
410+ NODE_STATUS.DECLARED,
411+ architecture=factory.getRandomEnum(ARCHITECTURE))
412+ nodegroup = factory.make_node_group()
413+ initialize_node_group(node, nodegroup)
414+ self.assertEqual(nodegroup, node.nodegroup)
415+
416+ def test_initialize_node_group_defaults_to_master(self):
417+ node = Node(
418+ NODE_STATUS.DECLARED,
419+ architecture=factory.getRandomEnum(ARCHITECTURE))
420+ initialize_node_group(node)
421+ self.assertEqual(NodeGroup.objects.ensure_master(), node.nodegroup)
422+
423
424 class NodeWithMACAddressesFormTest(TestCase):
425
426@@ -96,15 +105,19 @@
427 query_dict[k] = v
428 return query_dict
429
430- def make_params(self, mac_addresses=None, architecture=None):
431+ def make_params(self, mac_addresses=None, architecture=None,
432+ nodegroup=None):
433 if mac_addresses is None:
434 mac_addresses = [factory.getRandomMACAddress()]
435 if architecture is None:
436 architecture = factory.getRandomEnum(ARCHITECTURE)
437- return self.get_QueryDict({
438+ params = {
439 'mac_addresses': mac_addresses,
440 'architecture': architecture,
441- })
442+ }
443+ if nodegroup is not None:
444+ params['nodegroup'] = nodegroup
445+ return self.get_QueryDict(params)
446
447 def test_NodeWithMACAddressesForm_valid(self):
448 architecture = factory.getRandomEnum(ARCHITECTURE)
449@@ -163,11 +176,44 @@
450 macs,
451 [mac.mac_address for mac in node.macaddress_set.all()])
452
453+ def test_includes_nodegroup_field_for_new_node(self):
454+ self.assertIn(
455+ 'nodegroup',
456+ NodeWithMACAddressesForm(self.make_params()).fields)
457+
458+ def test_does_not_include_nodegroup_field_for_existing_node(self):
459+ params = self.make_params()
460+ node = factory.make_node()
461+ self.assertNotIn(
462+ 'nodegroup',
463+ NodeWithMACAddressesForm(params, instance=node).fields)
464+
465 def test_sets_nodegroup_to_master_by_default(self):
466 self.assertEqual(
467 NodeGroup.objects.ensure_master(),
468 NodeWithMACAddressesForm(self.make_params()).save().nodegroup)
469
470+ def test_sets_nodegroup_on_new_node_if_requested(self):
471+ nodegroup = factory.make_node_group(
472+ ip_range_low='192.168.14.2', ip_range_high='192.168.14.254',
473+ ip='192.168.14.1', subnet_mask='255.255.255.0')
474+ form = NodeWithMACAddressesForm(
475+ self.make_params(nodegroup=nodegroup.get_managed_interface().ip))
476+ self.assertEqual(nodegroup, form.save().nodegroup)
477+
478+ def test_leaves_nodegroup_alone_if_unset_on_existing_node(self):
479+ # Selecting a node group for a node is only supported on new
480+ # nodes. You can't change it later.
481+ original_nodegroup = factory.make_node_group()
482+ node = factory.make_node(nodegroup=original_nodegroup)
483+ factory.make_node_group(
484+ ip_range_low='10.0.0.1', ip_range_high='10.0.0.2',
485+ ip='10.0.0.1', subnet_mask='255.0.0.0')
486+ form = NodeWithMACAddressesForm(
487+ self.make_params(nodegroup='10.0.0.1'), instance=node)
488+ form.save()
489+ self.assertEqual(original_nodegroup, reload_object(node).nodegroup)
490+
491
492 class TestOptionForm(ConfigForm):
493 field1 = forms.CharField(label="Field 1", max_length=10)
494@@ -227,6 +273,7 @@
495 'after_commissioning_action',
496 'architecture',
497 'distro_series',
498+ 'nodegroup',
499 ], list(form.fields))
500
501 def test_NodeForm_changes_node(self):
502
503=== modified file 'src/maasserver/tests/test_node.py'
504--- src/maasserver/tests/test_node.py 2012-09-18 18:46:31 +0000
505+++ src/maasserver/tests/test_node.py 2012-09-21 09:08:18 +0000
506@@ -20,11 +20,11 @@
507 ValidationError,
508 )
509 from maasserver.enum import (
510+ DISTRO_SERIES,
511 NODE_PERMISSION,
512 NODE_STATUS,
513 NODE_STATUS_CHOICES,
514 NODE_STATUS_CHOICES_DICT,
515- DISTRO_SERIES,
516 )
517 from maasserver.exceptions import NodeStateViolation
518 from maasserver.models import (
519
520=== modified file 'src/maasserver/tests/test_tag.py'
521--- src/maasserver/tests/test_tag.py 2012-09-20 13:16:13 +0000
522+++ src/maasserver/tests/test_tag.py 2012-09-21 09:08:18 +0000
523@@ -12,9 +12,7 @@
524 __metaclass__ = type
525 __all__ = []
526
527-from maasserver.models import (
528- Node,
529- )
530+from maasserver.models import Node
531 from maasserver.testing.factory import factory
532 from maasserver.testing.testcase import TestCase
533