Merge lp:~mpontillo/maas/device-api-1566109 into lp:~maas-committers/maas/trunk

Proposed by Mike Pontillo
Status: Merged
Approved by: Mike Pontillo
Approved revision: no longer in the source branch.
Merged at revision: 4937
Proposed branch: lp:~mpontillo/maas/device-api-1566109
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 160 lines (+92/-4)
4 files modified
src/maasserver/api/tests/test_devices.py (+18/-0)
src/maasserver/forms.py (+8/-0)
src/maasserver/tests/test_forms_device.py (+64/-2)
src/maasserver/utils/forms.py (+2/-2)
To merge this branch: bzr merge lp:~mpontillo/maas/device-api-1566109
Reviewer Review Type Date Requested Status
Mike Pontillo (community) Approve
Gavin Panella (community) Approve
Review via email: mp+291580@code.launchpad.net

Commit message

Ensure QueryDict is always used with WithMACAddressesMixin.

 * This is the likely fix for bug #1566109.

To post a comment you must log in.
Revision history for this message
Gavin Panella (allenap) wrote :

There's a problem in WithMACAddressesMixin that illustrates one of my
pet hates.

I want to pass a MultiValueDict into the form's constructor, so that
self.data has a getlist() method. This is used by WithMACAddressesMixin.
However, Django's BaseForm has the following code:

  class BaseForm(object):
      def __init__(self, data=None, ...):
          ...
          self.data = data or {}
          ...

so when I pass an *empty* MultiValueDict in — which is an ancestor of
QueryDict, the container that Django uses to convey request arguments to
handlers, and thus a perfectly reasonable thing to pass into a form —
it's replaced with a plain old Python dict, and subsequent attempts to
use getlist() on it break with AttributeError.

All because someone was too f***ing lazy test against None:

    self.data = {} if data is None else data

My blood boils. Implicit boolean-ness is a curse.

Revision history for this message
Gavin Panella (allenap) wrote :

I've got the tests passing in lp:~allenap/maas/device-api-1566109 (diff:
https://paste.ubuntu.com/15789749/). This was far harder than it ought
to have been. It has upgraded my dislike for Django's form machinery
from "moderate" to "strong".

WithMACAddressesMixin expects self.data to be a QueryDict. I haven't
double checked, but it seems that not even a MultiValueDict is enough.

Django's BaseForm works on the assumption that self.data is a plain
dict, so we're already in dodgy territory, because plain dicts and
QueryDicts do not quack quite the same way.

But maybe Django's all okay with this, because its MultipleHiddenInput
and SelectMultiple widgets have the following code:

    def value_from_datadict(self, data, files, name):
        if isinstance(data, (MultiValueDict, MergeDict)):
            return data.getlist(name)
        return data.get(name, None)

I don't know. It's a mess.

Revision history for this message
Gavin Panella (allenap) wrote :

Looks good. A few small things to tidy up, nothing more. Also, describe the fix in the commit message; the bug number is automatically added to the commit message by the lander so there's no need to mention it again (or at all).

review: Approve
Revision history for this message
MAAS Lander (maas-lander) wrote :
Download full text (1.0 MiB)

The attempt to merge lp:~mpontillo/maas/device-api-1566109 into lp:maas failed. Below is the output from the failed tests.

Hit:1 http://security.ubuntu.com/ubuntu xenial-security InRelease
Get:2 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial InRelease [247 kB]
Hit:3 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-updates InRelease
Hit:4 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-backports InRelease
Get:5 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial/main Sources [865 kB]
Get:6 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial/universe Sources [7,757 kB]
Get:7 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial/main amd64 Packages [1,182 kB]
Get:8 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial/universe amd64 Packages [7,541 kB]
Fetched 17.6 MB in 3s (5,067 kB/s)
Reading package lists...
sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
    --no-install-recommends install apache2 archdetect-deb authbind bash bind9 bind9utils build-essential bzr-builddeb chromium-browser chromium-chromedriver curl daemontools debhelper dh-apport dh-systemd distro-info dnsutils firefox freeipmi-tools git gjs ipython isc-dhcp-common libjs-angularjs libjs-jquery libjs-jquery-hotkeys libjs-yui3-full libjs-yui3-min libpq-dev make nodejs-legacy npm postgresql pxelinux python3-all python3-apt python3-bson python3-convoy python3-coverage python3-crochet python3-cssselect python3-curtin python3-dev python3-distro-info python3-django python3-django-nose python3-django-piston3 python3-dnspython python3-docutils python3-formencode python3-hivex python3-httplib2 python3-jinja2 python3-jsonschema python3-lxml python3-mock python3-netaddr python3-netifaces python3-oauth python3-oauthlib python3-openssl python3-paramiko python3-petname python3-pexpect python3-psycopg2 python3-pyinotify python3-pyparsing python3-pyvmomi python3-requests python3-seamicroclient python3-setuptools python3-simplestreams python3-sphinx python3-tempita python3-twisted python3-txtftp python3-tz python3-yaml python3-zope.interface python-bson python-crochet python-django python-django-piston python-djorm-ext-pgarray python-formencode python-lxml python-netaddr python-netifaces python-pocket-lint python-psycopg2 python-tempita python-twisted python-yaml socat syslinux-common tgt ubuntu-cloudimage-keyring wget xvfb
Reading package lists...
Building dependency tree...
Reading state information...
apache2 is already the newest version (2.4.18-2ubuntu3).
archdetect-deb is already the newest version (1.117ubuntu2).
authbind is already the newest version (2.1.1+nmu1).
bash is already the newest version (4.3-14ubuntu1).
bind9 is already the newest version (1:9.10.3.dfsg.P4-8).
bind9utils is already the newest version (1:9.10.3.dfsg.P4-8).
build-essential is already the newest version (12.1ubuntu2).
curl is already the newest version (7.47.0-1ubuntu2).
debhelper is already the newest version (9.20160115ubuntu3).
distro-info is already the newest version (0.14build1).
dnsutils is already the newest version (1:9.10.3.dfsg.P4-8).
firefox is already the newest version (45.0.2+build1-0ubuntu1).
freeipmi-...

Revision history for this message
Mike Pontillo (mpontillo) wrote :

Could not reproduce random failure in maasserver.rpc.tests.test_regionservice.

Trying again to land.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/api/tests/test_devices.py'
2--- src/maasserver/api/tests/test_devices.py 2016-04-14 15:25:36 +0000
3+++ src/maasserver/api/tests/test_devices.py 2016-04-19 16:54:49 +0000
4@@ -133,6 +133,24 @@
5 self.assertEquals(domain, device.domain)
6 self.assertEqual(device.node_type, NODE_TYPE.DEVICE)
7
8+ def test_POST_without_macs_raises_appropriate_error(self):
9+ hostname = factory.make_name('host')
10+ domain = factory.make_Domain()
11+ response = self.client.post(
12+ reverse('devices_handler'),
13+ {
14+ 'hostname': hostname,
15+ 'domain': domain.name,
16+ })
17+ self.assertEqual(
18+ http.client.BAD_REQUEST, response.status_code, response.content)
19+
20+ def test_empty_POST_raises_appropriate_error(self):
21+ response = self.client.post(
22+ reverse('devices_handler'), {})
23+ self.assertEqual(
24+ http.client.BAD_REQUEST, response.status_code, response.content)
25+
26 def test_POST_returns_limited_fields(self):
27 response = self.client.post(
28 reverse('devices_handler'),
29
30=== modified file 'src/maasserver/forms.py'
31--- src/maasserver/forms.py 2016-04-15 22:14:33 +0000
32+++ src/maasserver/forms.py 2016-04-19 16:54:49 +0000
33@@ -55,6 +55,7 @@
34 Form,
35 MultipleChoiceField,
36 )
37+from django.http import QueryDict
38 from django.utils.safestring import mark_safe
39 from lxml import etree
40 from maasserver.api.utils import get_overridden_query_dict
41@@ -128,6 +129,7 @@
42 from maasserver.utils.converters import machine_readable_bytes
43 from maasserver.utils.forms import (
44 compose_invalid_choice_text,
45+ get_QueryDict,
46 set_form_error,
47 )
48 from maasserver.utils.orm import (
49@@ -1055,6 +1057,12 @@
50
51 def __init__(self, *args, **kwargs):
52 super(WithMACAddressesMixin, self).__init__(*args, **kwargs)
53+ # This is a workaround for a Django bug in which self.data (which is
54+ # supposed to be a QueryDict) ends up being a normal Python dict.
55+ # This class requires a QueryDict (which it seems like Django should
56+ # enforce, for consistency).
57+ if not isinstance(self.data, QueryDict):
58+ self.data = get_QueryDict(self.data)
59 self.set_up_mac_addresses_field()
60
61 def set_up_mac_addresses_field(self):
62
63=== modified file 'src/maasserver/tests/test_forms_device.py'
64--- src/maasserver/tests/test_forms_device.py 2016-03-28 13:54:47 +0000
65+++ src/maasserver/tests/test_forms_device.py 2016-04-19 16:54:49 +0000
66@@ -5,10 +5,27 @@
67
68 __all__ = []
69
70-from maasserver.forms import DeviceForm
71+from django.http import HttpRequest
72+from django.http.request import QueryDict
73+from maasserver.forms import (
74+ DeviceForm,
75+ DeviceWithMACsForm,
76+)
77+from maasserver.models import (
78+ Device,
79+ Interface,
80+)
81 from maasserver.testing.factory import factory
82 from maasserver.testing.testcase import MAASServerTestCase
83-from maasserver.utils.orm import reload_object
84+from maasserver.utils.forms import get_QueryDict
85+from maasserver.utils.orm import (
86+ get_one,
87+ reload_object,
88+)
89+from testtools.matchers import (
90+ Contains,
91+ Equals,
92+)
93
94
95 class TestDeviceForm(MAASServerTestCase):
96@@ -39,3 +56,48 @@
97 reload_object(parent)
98
99 self.assertEqual(parent, device.parent)
100+
101+
102+class TestDeviceWithMACsForm(MAASServerTestCase):
103+
104+ def make_request(self):
105+ """Return a :class:`HttpRequest` with the given parameters."""
106+ request = HttpRequest()
107+ request.user = factory.make_User()
108+ return request
109+
110+ def test_contains_mac_addresses_field_and_converts_non_querydict(self):
111+ form = DeviceWithMACsForm(data={})
112+ self.assertThat(form.fields, Contains('mac_addresses'))
113+ self.assertIsInstance(form.data, QueryDict)
114+
115+ def test_creates_device_with_mac(self):
116+ hostname = factory.make_name("device")
117+ mac = factory.make_mac_address()
118+ form = DeviceWithMACsForm(data=get_QueryDict({
119+ "hostname": hostname,
120+ "mac_addresses": mac
121+ }), request=self.make_request())
122+ self.assertTrue(form.is_valid(), dict(form.errors))
123+ form.save()
124+ device = get_one(Device.objects.filter(hostname=hostname))
125+ self.assertThat(device.hostname, Equals(hostname))
126+ iface = get_one(Interface.objects.filter(mac_address=mac))
127+ self.assertThat(iface.node, Equals(device))
128+
129+ def test_creates_device_with_macs(self):
130+ hostname = factory.make_name("device")
131+ mac1 = factory.make_mac_address()
132+ mac2 = factory.make_mac_address()
133+ form = DeviceWithMACsForm(data=get_QueryDict({
134+ "hostname": hostname,
135+ "mac_addresses": [mac1, mac2]
136+ }), request=self.make_request())
137+ self.assertTrue(form.is_valid(), dict(form.errors))
138+ form.save()
139+ device = get_one(Device.objects.filter(hostname=hostname))
140+ self.assertThat(device.hostname, Equals(hostname))
141+ iface = get_one(Interface.objects.filter(mac_address=mac1))
142+ self.assertThat(iface.node, Equals(device))
143+ iface = get_one(Interface.objects.filter(mac_address=mac2))
144+ self.assertThat(iface.node, Equals(device))
145
146=== modified file 'src/maasserver/utils/forms.py'
147--- src/maasserver/utils/forms.py 2015-12-01 18:12:59 +0000
148+++ src/maasserver/utils/forms.py 2016-04-19 16:54:49 +0000
149@@ -32,9 +32,9 @@
150 )
151
152
153-def get_QueryDict(params):
154+def get_QueryDict(params, mutable=True):
155 """Convert `params` to a `QueryDict`."""
156- query_dict = QueryDict('', mutable=True)
157+ query_dict = QueryDict('', mutable=mutable)
158 for k, v in params.items():
159 if isinstance(v, list):
160 query_dict.setlist(k, v)