Merge lp:~mpontillo/maas/make-ipaddresses-api-great-again--bug-1629061 into lp:~maas-committers/maas/trunk
- make-ipaddresses-api-great-again--bug-1629061
- Merge into trunk
Status: | Merged | ||||
---|---|---|---|---|---|
Approved by: | Mike Pontillo | ||||
Approved revision: | no longer in the source branch. | ||||
Merged at revision: | 5472 | ||||
Proposed branch: | lp:~mpontillo/maas/make-ipaddresses-api-great-again--bug-1629061 | ||||
Merge into: | lp:~maas-committers/maas/trunk | ||||
Diff against target: |
978 lines (+519/-211) 8 files modified
src/maasserver/api/devices.py (+2/-15) src/maasserver/api/ip_addresses.py (+153/-22) src/maasserver/api/machines.py (+2/-15) src/maasserver/api/rackcontrollers.py (+2/-15) src/maasserver/api/regioncontrollers.py (+2/-15) src/maasserver/api/tests/test_ipaddresses.py (+336/-129) src/maasserver/models/staticipaddress.py (+5/-0) src/maasserver/models/tests/test_staticipaddress.py (+17/-0) |
||||
To merge this branch: | bzr merge lp:~mpontillo/maas/make-ipaddresses-api-great-again--bug-1629061 | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Gavin Panella (community) | Approve | ||
Review via email: mp+308208@code.launchpad.net |
Commit message
Make the 'ipaddresses' API do what everyone expected it to do.
* Modify 'read' operation to show additional relevant fields, so that
API users can determine which address they want to release.
- Adds 'user' field so that administrators can verify that addresses
they want to release belong to a particular user
- Add 'alloc_type_name' field, so that administrators can verify the
expected [human readable] type of the address.
- Add 'interface_set' field, so that administrators can check which
interfaces are linked to the each IP address.
* Add 'user=<user>' option to 'read' operation, so that administrators
can list addresses belonging to a specific user.
* Add 'all=true' option to 'read' operation, so that administrators
can list all addresses without restriction. (Normally, only
"User reserved" addresses are shown. This option also removes the
'user' filter.)
* Add 'ip=<ip-address>' option to 'read' operation, so that
administrators can verify that a specific address is the intended
address to release before releasing it.
* Allow the 'release' operation to release any IP address, not just
a user-reserved IP address belonging to the current user.
(Requires the 'force=true' parameter to be used, otherwise restricts
the release operation the same way as before.)
* Add 'ip=<ip-address>' optional argument to 'reserve' operation, so
that the user experience is consistent across all operations on
the 'ipaddresses' endpoint. This replaces the now-deprecated
'ip_address' parameter, which may still be used, and acts as an alias
to the 'ip' parameter (if the 'ip' parameter is not specified).
* Drive-by DRY up for displayed interface fields in the API.
Description of the change
Gavin Panella (allenap) wrote : | # |
The alloc_type_name thing is the only thing that shouldn't land, but as agreed you're going to change it to None or the empty string, so +1. That's not to say I think you should ignore my other comments :)
Mike Pontillo (mpontillo) wrote : | # |
Thanks for the feedback, Gavin. (I've changed `alloc_type_name` it to an empty string; will add a unit test for it as well.)
Small rant regarding multiple branches: I really wanted to propose this as multiple branches. Honest. ;-) I think my development style was getting in the way here. My process is, first I iterate on the API code and get the user experience to be how I want it. I'll iterate on changing the API code and doing end-to-end testing, periodically deploying to a live MAAS. By the time I was done with that, I had a ~400 line diff, and most of the existing tests were failing. At that time, I thought about splitting up the branch into a second --tests branch, but that didn't make sense because there was no way to land it by itself.
The key problem for me was, I didn't know where I was going with it until I was done. If I had a better plan in mind, doing 2 or 3 branches would have been easy.
Now, if we were using git, I might have used a "git rebase -i" to split out the branch into multiple, smaller feature branches. But that's a pain with bzr, and I wasn't sure if I was going to go back and change the code again after I completed the unit testing. So I left it how it was, and hoped I would't exceed our 800 line limit. Sorry. =(
In retrospect, it wouldn't have been too bad to do a branch for each API operation I changed. (though the test case refactoring I did might have gotten in the way of that.) But the return on investment isn't clear. If I spend an hour cleaning up the branch for easier review, how much time do I save you while reviewing it?
Mike Pontillo (mpontillo) wrote : | # |
Some replies below. Thanks for the review!
Preview Diff
1 | === modified file 'src/maasserver/api/devices.py' |
2 | --- src/maasserver/api/devices.py 2016-05-12 19:07:37 +0000 |
3 | +++ src/maasserver/api/devices.py 2016-10-12 17:51:07 +0000 |
4 | @@ -6,6 +6,7 @@ |
5 | "DevicesHandler", |
6 | ] |
7 | |
8 | +from maasserver.api.interfaces import DISPLAYED_INTERFACE_FIELDS |
9 | from maasserver.api.logger import maaslog |
10 | from maasserver.api.nodes import ( |
11 | NodeHandler, |
12 | @@ -35,21 +36,7 @@ |
13 | 'tag_names', |
14 | 'address_ttl', |
15 | 'ip_addresses', |
16 | - ('interface_set', ( |
17 | - 'id', |
18 | - 'name', |
19 | - 'type', |
20 | - 'vlan', |
21 | - 'mac_address', |
22 | - 'parents', |
23 | - 'children', |
24 | - 'tags', |
25 | - 'enabled', |
26 | - 'links', |
27 | - 'params', |
28 | - 'discovered', |
29 | - 'effective_mtu', |
30 | - )), |
31 | + ('interface_set', DISPLAYED_INTERFACE_FIELDS), |
32 | 'zone', |
33 | 'node_type', |
34 | 'node_type_name', |
35 | |
36 | === modified file 'src/maasserver/api/ip_addresses.py' |
37 | --- src/maasserver/api/ip_addresses.py 2016-07-30 01:17:54 +0000 |
38 | +++ src/maasserver/api/ip_addresses.py 2016-10-12 17:51:07 +0000 |
39 | @@ -8,6 +8,12 @@ |
40 | ] |
41 | |
42 | from django.http import Http404 |
43 | +from django.http.response import ( |
44 | + HttpResponseBadRequest, |
45 | + HttpResponseForbidden, |
46 | +) |
47 | +from formencode.validators import StringBool |
48 | +from maasserver.api.interfaces import DISPLAYED_INTERFACE_FIELDS |
49 | from maasserver.api.support import ( |
50 | operation, |
51 | OperationsHandler, |
52 | @@ -39,6 +45,9 @@ |
53 | Subnet, |
54 | ) |
55 | from maasserver.utils.orm import transactional |
56 | +from netaddr import IPAddress |
57 | +from netaddr.core import AddrFormatError |
58 | +from piston3.utils import rc |
59 | from provisioningserver.logger import get_maas_logger |
60 | |
61 | |
62 | @@ -49,10 +58,22 @@ |
63 | """Manage IP addresses allocated by MAAS.""" |
64 | api_doc_section_name = "IP Addresses" |
65 | model = StaticIPAddress |
66 | - fields = ('alloc_type', 'created', 'ip', 'subnet') |
67 | + fields = ( |
68 | + 'alloc_type', |
69 | + 'alloc_type_name', |
70 | + 'created', |
71 | + 'ip', |
72 | + 'owner', |
73 | + ('interface_set', DISPLAYED_INTERFACE_FIELDS), |
74 | + 'subnet' |
75 | + ) |
76 | create = update = delete = None |
77 | |
78 | @classmethod |
79 | + def owner(cls, static_ip_address): |
80 | + return static_ip_address.user |
81 | + |
82 | + @classmethod |
83 | def resource_uri(cls, *args, **kwargs): |
84 | return ('ipaddresses_handler', []) |
85 | |
86 | @@ -144,8 +165,10 @@ |
87 | |
88 | :param subnet: CIDR representation of the subnet on which the IP |
89 | reservation is required. e.g. 10.1.2.0/24 |
90 | - :param ip_address: The IP address, which must be within |
91 | + :param ip: The IP address, which must be within |
92 | a known subnet. |
93 | + :param ip_address: (Deprecated.) Alias for 'ip' parameter. Provided |
94 | + for backward compatibility. |
95 | :param hostname: The hostname to use for the specified IP address. If |
96 | no domain component is given, the default domain will be used. |
97 | :param mac: The MAC address that should be linked to this reservation. |
98 | @@ -156,33 +179,43 @@ |
99 | Returns 503 if there are no more IP addresses available. |
100 | """ |
101 | subnet = get_optional_param(request.POST, "subnet") |
102 | - ip_address = get_optional_param( |
103 | - request.POST, "ip_address") |
104 | + ip = get_optional_param(request.POST, "ip") |
105 | + ip_address = get_optional_param(request.POST, "ip_address") |
106 | hostname = get_optional_param(request.POST, "hostname") |
107 | mac_address = get_optional_param(request.POST, "mac") |
108 | |
109 | - form = ClaimIPForMACForm(request.POST) |
110 | + # Fix to make the API backward compatible, yet consistent with the |
111 | + # other APIs in this file. |
112 | + if ip is None and ip_address is not None: |
113 | + ip = ip_address |
114 | + |
115 | + form = ClaimIPForMACForm(data={ |
116 | + 'subnet': subnet, |
117 | + 'ip_address': ip, |
118 | + 'hostname': hostname, |
119 | + 'mac': mac_address, |
120 | + }) |
121 | if not form.is_valid(): |
122 | raise MAASAPIValidationError(form.errors) |
123 | |
124 | - if ip_address is not None: |
125 | - subnet = Subnet.objects.get_best_subnet_for_ip(ip_address) |
126 | + if ip is not None: |
127 | + subnet = Subnet.objects.get_best_subnet_for_ip(ip) |
128 | if subnet is None: |
129 | raise MAASAPIBadRequest( |
130 | - "No known subnet for IP %s." % ip_address) |
131 | + "No known subnet for IP address: %s." % ip) |
132 | elif subnet is not None: |
133 | try: |
134 | subnet = Subnet.objects.get_object_by_specifiers_or_raise( |
135 | subnet) |
136 | except Http404: |
137 | raise MAASAPIBadRequest( |
138 | - "Unable to identify subnet %s." % subnet) from None |
139 | + "Unable to identify subnet: %s." % subnet) from None |
140 | else: |
141 | raise MAASAPIBadRequest( |
142 | - "Must supply either a subnet or a ip_address.") |
143 | + "Must supply either the 'subnet' or the 'ip' parameter.") |
144 | |
145 | return self._claim_ip( |
146 | - request.user, subnet, ip_address, mac_address, |
147 | + request.user, subnet, ip, mac_address, |
148 | hostname=hostname) |
149 | |
150 | @operation(idempotent=False) |
151 | @@ -192,21 +225,48 @@ |
152 | :param ip: The IP address to release. |
153 | :type ip: unicode |
154 | |
155 | + :param force: If True, allows a MAAS administrator to force an IP |
156 | + address to be released, even if it is not a user-reserved IP |
157 | + address or does not belong to the requesting user. Use with |
158 | + caution. |
159 | + :type force: bool |
160 | + |
161 | Returns 404 if the provided IP address is not found. |
162 | """ |
163 | ip = get_mandatory_param(request.POST, "ip") |
164 | + force = get_optional_param( |
165 | + request.POST, 'force', default=False, validator=StringBool) |
166 | + |
167 | + if force is True and not request.user.is_superuser: |
168 | + return HttpResponseForbidden( |
169 | + content_type='text/plain', |
170 | + content="Force-releasing an IP address requires admin " |
171 | + "privileges.") |
172 | + |
173 | form = ReleaseIPForm(request.POST) |
174 | if not form.is_valid(): |
175 | raise MAASAPIValidationError(form.errors) |
176 | |
177 | + if force: |
178 | + query_args = dict(ip=ip) |
179 | + else: |
180 | + query_args = dict( |
181 | + alloc_type=IPADDRESS_TYPE.USER_RESERVED, ip=ip, |
182 | + user=request.user) |
183 | + |
184 | # Get the reserved IP address, or raise bad request. |
185 | try: |
186 | - ip_address = StaticIPAddress.objects.get( |
187 | - alloc_type=IPADDRESS_TYPE.USER_RESERVED, ip=ip, |
188 | - user=request.user) |
189 | + ip_address = StaticIPAddress.objects.get(**query_args) |
190 | except StaticIPAddress.DoesNotExist: |
191 | - raise MAASAPIBadRequest( |
192 | - "User reserved IP %s does not exist." % ip) from None |
193 | + if force: |
194 | + error = "IP address %s does not exist." |
195 | + else: |
196 | + error = ( |
197 | + "IP address %s does not exist, is not a user-reserved " |
198 | + "address, or does not belong to the requesting user.\n" |
199 | + "If you are sure you want to release this address, use " |
200 | + "force=true as a MAAS administrator.") |
201 | + raise MAASAPIBadRequest(error % ip) from None |
202 | |
203 | # Unlink the IP address from the interfaces it is connected. |
204 | interfaces = list(ip_address.interface_set.all()) |
205 | @@ -222,12 +282,83 @@ |
206 | if interface.node is None and interface.only_has_link_up(): |
207 | interface.delete() |
208 | |
209 | - maaslog.info("User %s released IP %s", request.user.username, ip) |
210 | + maaslog.info( |
211 | + "User %s%s released IP address: %s (%s).", request.user.username, |
212 | + " forcibly" if force else "", ip, |
213 | + ip_address.alloc_type_name) |
214 | + |
215 | + return rc.DELETED |
216 | |
217 | def read(self, request): |
218 | - """List IPAddresses. |
219 | - |
220 | - Get a listing of all IPAddresses allocated to the requesting user. |
221 | + """List IP addresses known to MAAS. |
222 | + |
223 | + By default, gets a listing of all IP addresses allocated to the |
224 | + requesting user. |
225 | + |
226 | + :param ip: If specified, will only display information for the |
227 | + specified IP address. |
228 | + :type ip: unicode (must be an IPv4 or IPv6 address) |
229 | + |
230 | + If the requesting user is a MAAS administrator, the following options |
231 | + may also be supplied: |
232 | + |
233 | + :param all: If True, all reserved IP addresses will be shown. (By |
234 | + default, only addresses of type 'User reserved' that are assigned |
235 | + to the requesting user are shown.) |
236 | + :type all: bool |
237 | + |
238 | + :param owner: If specified, filters the list to show only IP addresses |
239 | + owned by the specified username. |
240 | + :type user: unicode |
241 | """ |
242 | - return StaticIPAddress.objects.filter( |
243 | - user=request.user).order_by('id') |
244 | + _all = get_optional_param( |
245 | + request.GET, 'all', default=False, validator=StringBool) |
246 | + ip = get_optional_param(request.GET, 'ip', default=None) |
247 | + owner = get_optional_param(request.GET, 'owner', default=None) |
248 | + # If an IP address was specified, validate that it is indeed a valid |
249 | + # IP address before trying to hit the database with it. |
250 | + if ip is not None: |
251 | + try: |
252 | + IPAddress(ip) |
253 | + except AddrFormatError: |
254 | + return HttpResponseBadRequest( |
255 | + content_type='text/plain', |
256 | + content="Parameter 'ip' must contain an IP address.") |
257 | + # It doesn't make sense for this API to display NULL IP addresses. |
258 | + # These indicate things like "do DHCP on <interface>", "we observed a |
259 | + # commissioned node connected to <subnet>", and "we would assign an |
260 | + # automatic address to <machine-interface>, but it isn't deployed at |
261 | + # the moment". |
262 | + query = StaticIPAddress.objects.exclude(ip__isnull=True) |
263 | + if _all and not request.user.is_superuser: |
264 | + return HttpResponseForbidden( |
265 | + content_type='text/plain', |
266 | + content="Listing all IP addresses requires admin " |
267 | + "privileges.") |
268 | + if owner is not None and not request.user.is_superuser: |
269 | + return HttpResponseForbidden( |
270 | + content_type='text/plain', |
271 | + content="Listing another user's IP addresses requires admin " |
272 | + "privileges.") |
273 | + # Add additional filters based on permissions, and based on the |
274 | + # request parameters. |
275 | + if not request.user.is_superuser: |
276 | + # If the requesting user isn't an admin, always filter by the |
277 | + # currently-logged-in API user. |
278 | + query = query.filter(user=request.user) |
279 | + elif owner is not None: |
280 | + # If the admin specified a username filter, use that. |
281 | + query = query.filter(user__username=owner) |
282 | + elif _all is False: |
283 | + # If the admin didn't specify 'all=true' (and didn't specify a |
284 | + # specific username), only show IP addresses belonging to the |
285 | + # admin. (This preserves API compatibility.) |
286 | + query = query.filter(user=request.user) |
287 | + # If we're not displaying all addresses, we also need to filter by |
288 | + # address type (again, to preserve API compatibility). |
289 | + if not _all: |
290 | + query = query.filter(alloc_type=IPADDRESS_TYPE.USER_RESERVED) |
291 | + if ip is not None: |
292 | + query = query.filter(ip=ip) |
293 | + query = query.order_by('id') |
294 | + return query |
295 | |
296 | === modified file 'src/maasserver/api/machines.py' |
297 | --- src/maasserver/api/machines.py 2016-09-29 19:23:13 +0000 |
298 | +++ src/maasserver/api/machines.py 2016-10-12 17:51:07 +0000 |
299 | @@ -24,6 +24,7 @@ |
300 | StringBool, |
301 | ) |
302 | from maasserver import locks |
303 | +from maasserver.api.interfaces import DISPLAYED_INTERFACE_FIELDS |
304 | from maasserver.api.logger import maaslog |
305 | from maasserver.api.nodes import ( |
306 | AnonNodeHandler, |
307 | @@ -111,21 +112,7 @@ |
308 | 'tag_names', |
309 | 'address_ttl', |
310 | 'ip_addresses', |
311 | - ('interface_set', ( |
312 | - 'id', |
313 | - 'name', |
314 | - 'type', |
315 | - 'vlan', |
316 | - 'mac_address', |
317 | - 'parents', |
318 | - 'children', |
319 | - 'tags', |
320 | - 'enabled', |
321 | - 'links', |
322 | - 'params', |
323 | - 'discovered', |
324 | - 'effective_mtu', |
325 | - )), |
326 | + ('interface_set', DISPLAYED_INTERFACE_FIELDS), |
327 | 'zone', |
328 | 'disable_ipv4', |
329 | 'constraints_by_type', |
330 | |
331 | === modified file 'src/maasserver/api/rackcontrollers.py' |
332 | --- src/maasserver/api/rackcontrollers.py 2016-06-08 20:20:40 +0000 |
333 | +++ src/maasserver/api/rackcontrollers.py 2016-10-12 17:51:07 +0000 |
334 | @@ -9,6 +9,7 @@ |
335 | |
336 | from django.conf import settings |
337 | from django.http import HttpResponse |
338 | +from maasserver.api.interfaces import DISPLAYED_INTERFACE_FIELDS |
339 | from maasserver.api.nodes import ( |
340 | NodeHandler, |
341 | NodesHandler, |
342 | @@ -43,21 +44,7 @@ |
343 | 'power_type', |
344 | 'power_state', |
345 | 'ip_addresses', |
346 | - ('interface_set', ( |
347 | - 'id', |
348 | - 'name', |
349 | - 'type', |
350 | - 'vlan', |
351 | - 'mac_address', |
352 | - 'parents', |
353 | - 'children', |
354 | - 'tags', |
355 | - 'enabled', |
356 | - 'links', |
357 | - 'params', |
358 | - 'discovered', |
359 | - 'effective_mtu', |
360 | - )), |
361 | + ('interface_set', DISPLAYED_INTERFACE_FIELDS), |
362 | 'zone', |
363 | 'status_action', |
364 | 'node_type', |
365 | |
366 | === modified file 'src/maasserver/api/regioncontrollers.py' |
367 | --- src/maasserver/api/regioncontrollers.py 2016-04-28 01:14:14 +0000 |
368 | +++ src/maasserver/api/regioncontrollers.py 2016-10-12 17:51:07 +0000 |
369 | @@ -6,6 +6,7 @@ |
370 | 'RegionControllersHandler', |
371 | ] |
372 | |
373 | +from maasserver.api.interfaces import DISPLAYED_INTERFACE_FIELDS |
374 | from maasserver.api.nodes import ( |
375 | NodeHandler, |
376 | NodesHandler, |
377 | @@ -31,21 +32,7 @@ |
378 | 'power_type', |
379 | 'power_state', |
380 | 'ip_addresses', |
381 | - ('interface_set', ( |
382 | - 'id', |
383 | - 'name', |
384 | - 'type', |
385 | - 'vlan', |
386 | - 'mac_address', |
387 | - 'parents', |
388 | - 'children', |
389 | - 'tags', |
390 | - 'enabled', |
391 | - 'links', |
392 | - 'params', |
393 | - 'discovered', |
394 | - 'effective_mtu', |
395 | - )), |
396 | + ('interface_set', DISPLAYED_INTERFACE_FIELDS), |
397 | 'zone', |
398 | 'status_action', |
399 | 'node_type', |
400 | |
401 | === modified file 'src/maasserver/api/tests/test_ipaddresses.py' |
402 | --- src/maasserver/api/tests/test_ipaddresses.py 2016-07-30 01:17:54 +0000 |
403 | +++ src/maasserver/api/tests/test_ipaddresses.py 2016-10-12 17:51:07 +0000 |
404 | @@ -2,6 +2,8 @@ |
405 | # GNU Affero General Public License version 3 (see the file LICENSE). |
406 | |
407 | """Tests for IP addresses API.""" |
408 | +from testtools.matchers import HasLength |
409 | + |
410 | |
411 | __all__ = [] |
412 | |
413 | @@ -22,20 +24,342 @@ |
414 | from maasserver.testing.factory import factory |
415 | from maasserver.utils.converters import json_load_bytes |
416 | from maasserver.utils.orm import reload_object |
417 | +from maastesting.matchers import DocTestMatches |
418 | from netaddr import IPAddress |
419 | from testtools.matchers import Equals |
420 | |
421 | |
422 | -class TestIPAddressesAPI(APITestCase.ForUser): |
423 | +class TestIPAddressesAPI(APITestCase.ForUserAndAdmin): |
424 | + |
425 | + def test_handler_path(self): |
426 | + self.assertEqual( |
427 | + '/api/2.0/ipaddresses/', reverse('ipaddresses_handler')) |
428 | + |
429 | + def test_GET_returns_ipaddresses(self): |
430 | + original_ipaddress = factory.make_StaticIPAddress( |
431 | + alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user) |
432 | + response = self.client.get(reverse('ipaddresses_handler')) |
433 | + self.assertEqual( |
434 | + http.client.OK, response.status_code, response.content) |
435 | + |
436 | + parsed_result = json_load_bytes(response.content) |
437 | + self.assertEqual(1, len(parsed_result), response.content) |
438 | + [returned_address] = parsed_result |
439 | + fields = {'alloc_type', 'alloc_type_name', 'ip'} |
440 | + self.assertEqual( |
441 | + fields.union({ |
442 | + 'resource_uri', |
443 | + 'created', |
444 | + 'subnet', |
445 | + 'interface_set', |
446 | + 'owner'}), |
447 | + set(returned_address.keys())) |
448 | + expected_values = { |
449 | + field: getattr(original_ipaddress, field) |
450 | + for field in fields |
451 | + if field not in ('resource_uri', 'created') |
452 | + } |
453 | + # Test that these exist, but not for exact values. |
454 | + del returned_address['created'] |
455 | + del returned_address['subnet'] |
456 | + del returned_address['owner'] |
457 | + del returned_address['interface_set'] |
458 | + expected_values['resource_uri'] = reverse('ipaddresses_handler') |
459 | + self.assertEqual(expected_values, returned_address) |
460 | + |
461 | + def test_GET_returns_empty_if_no_ipaddresses(self): |
462 | + response = self.client.get(reverse('ipaddresses_handler')) |
463 | + self.assertEqual( |
464 | + http.client.OK, response.status_code, response.content) |
465 | + self.assertEqual([], json_load_bytes(response.content)) |
466 | + |
467 | + def test_GET_only_returns_request_users_addresses(self): |
468 | + ipaddress = factory.make_StaticIPAddress( |
469 | + alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user) |
470 | + factory.make_StaticIPAddress( |
471 | + alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=factory.make_User()) |
472 | + response = self.client.get(reverse('ipaddresses_handler')) |
473 | + self.assertEqual( |
474 | + http.client.OK, response.status_code, response.content) |
475 | + parsed_result = json_load_bytes(response.content) |
476 | + [returned_address] = parsed_result |
477 | + self.assertEqual(ipaddress.ip, returned_address['ip']) |
478 | + |
479 | + def test_GET_returns_all_addresses_if_admin_and_all_specified(self): |
480 | + factory.make_StaticIPAddress( |
481 | + alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user) |
482 | + factory.make_StaticIPAddress( |
483 | + alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=factory.make_User()) |
484 | + response = self.client.get(reverse('ipaddresses_handler'), { |
485 | + "all": "1" |
486 | + }) |
487 | + if self.user.is_superuser: |
488 | + self.assertEqual( |
489 | + http.client.OK, response.status_code, response.content) |
490 | + parsed_result = json_load_bytes(response.content) |
491 | + self.assertThat(parsed_result, HasLength(2)) |
492 | + else: |
493 | + self.assertEqual( |
494 | + http.client.FORBIDDEN, response.status_code, response.content) |
495 | + self.assertThat( |
496 | + response.content.decode("utf-8"), |
497 | + Equals("Listing all IP addresses requires admin privileges.")) |
498 | + |
499 | + def test_GET_returns_other_user_addresses_if_admin_and_user_specified( |
500 | + self): |
501 | + factory.make_StaticIPAddress( |
502 | + alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user) |
503 | + user2 = factory.make_User() |
504 | + ipaddress2 = factory.make_StaticIPAddress( |
505 | + alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=user2) |
506 | + response = self.client.get(reverse('ipaddresses_handler'), { |
507 | + "owner": user2.username |
508 | + }) |
509 | + if self.user.is_superuser: |
510 | + self.assertEqual( |
511 | + http.client.OK, response.status_code, response.content) |
512 | + parsed_result = json_load_bytes(response.content) |
513 | + self.assertThat(parsed_result, HasLength(1)) |
514 | + self.assertEqual(ipaddress2.ip, parsed_result[0]['ip']) |
515 | + else: |
516 | + self.assertEqual( |
517 | + http.client.FORBIDDEN, response.status_code, response.content) |
518 | + self.assertThat( |
519 | + response.content.decode("utf-8"), |
520 | + Equals( |
521 | + "Listing another user's IP addresses requires admin " |
522 | + "privileges.")) |
523 | + |
524 | + def test_GET_returns_other_users_ip_address_for_admin_with_all_with_ip( |
525 | + self): |
526 | + factory.make_StaticIPAddress( |
527 | + alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user) |
528 | + user2 = factory.make_User() |
529 | + ipaddress2 = factory.make_StaticIPAddress( |
530 | + alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=user2) |
531 | + response = self.client.get(reverse('ipaddresses_handler'), { |
532 | + "ip": str(ipaddress2.ip), |
533 | + "all": "true" |
534 | + }) |
535 | + if self.user.is_superuser: |
536 | + self.assertEqual( |
537 | + http.client.OK, response.status_code, response.content) |
538 | + parsed_result = json_load_bytes(response.content) |
539 | + self.assertThat(parsed_result, HasLength(1)) |
540 | + self.assertEqual(ipaddress2.ip, parsed_result[0]['ip']) |
541 | + else: |
542 | + self.assertEqual( |
543 | + http.client.FORBIDDEN, response.status_code, response.content) |
544 | + self.assertThat( |
545 | + response.content.decode("utf-8"), |
546 | + Equals("Listing all IP addresses requires admin privileges.")) |
547 | + |
548 | + def test_GET_with_all_for_admin_returns_non_user_reserved_types( |
549 | + self): |
550 | + factory.make_StaticIPAddress(alloc_type=IPADDRESS_TYPE.STICKY) |
551 | + factory.make_StaticIPAddress( |
552 | + alloc_type=IPADDRESS_TYPE.USER_RESERVED) |
553 | + response = self.client.get(reverse('ipaddresses_handler'), { |
554 | + "all": "true" |
555 | + }) |
556 | + if self.user.is_superuser: |
557 | + self.assertEqual( |
558 | + http.client.OK, response.status_code, response.content) |
559 | + parsed_result = json_load_bytes(response.content) |
560 | + self.assertThat(parsed_result, HasLength(2)) |
561 | + else: |
562 | + self.assertEqual( |
563 | + http.client.FORBIDDEN, response.status_code, response.content) |
564 | + self.assertThat( |
565 | + response.content.decode("utf-8"), |
566 | + Equals("Listing all IP addresses requires admin privileges.")) |
567 | + |
568 | + def test_GET_returns_own_ip_address_with_ip(self): |
569 | + factory.make_StaticIPAddress( |
570 | + alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user) |
571 | + ipaddress2 = factory.make_StaticIPAddress( |
572 | + alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user) |
573 | + response = self.client.get(reverse('ipaddresses_handler'), { |
574 | + "ip": str(ipaddress2.ip), |
575 | + }) |
576 | + self.assertEqual( |
577 | + http.client.OK, response.status_code, response.content) |
578 | + parsed_result = json_load_bytes(response.content) |
579 | + self.assertThat(parsed_result, HasLength(1)) |
580 | + self.assertEqual(ipaddress2.ip, parsed_result[0]['ip']) |
581 | + |
582 | + def test_GET_sorts_by_id(self): |
583 | + addrs = [] |
584 | + for _ in range(3): |
585 | + addrs.append( |
586 | + factory.make_StaticIPAddress( |
587 | + alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user)) |
588 | + response = self.client.get(reverse('ipaddresses_handler')) |
589 | + self.assertEqual( |
590 | + http.client.OK, response.status_code, response.content) |
591 | + parsed_result = json_load_bytes(response.content) |
592 | + expected = [ |
593 | + addr.ip for addr in |
594 | + sorted(addrs, key=lambda addr: getattr(addr, "id"))] |
595 | + observed = [result['ip'] for result in parsed_result] |
596 | + self.assertEqual(expected, observed) |
597 | + |
598 | + |
599 | +class TestIPAddressesReleaseAPI(APITestCase.ForUserAndAdmin): |
600 | + |
601 | + scenarios = ( |
602 | + ("normal", {"force": None}), |
603 | + ("without_force", {"force": False}), |
604 | + ("with_force", {"force": True}), |
605 | + ) |
606 | + |
607 | + @property |
608 | + def force_should_work(self): |
609 | + # The 'force' parameter should only work if (1) the user-under-test |
610 | + # is a superuser, and (2) force=true was specified. |
611 | + return self.user.is_superuser and self.force is True |
612 | + |
613 | + @property |
614 | + def expect_forbidden(self): |
615 | + # The 'force' parameter should only work if (1) the user-under-test |
616 | + # is a superuser, and (2) force=true was specified. |
617 | + return not self.user.is_superuser and self.force is True |
618 | + |
619 | + def expected_status(self, status): |
620 | + # Non-administrators always get a FORBIDDEN (403) when requesting |
621 | + # a forced delete. |
622 | + if self.force and not self.user.is_superuser: |
623 | + return http.client.FORBIDDEN |
624 | + else: |
625 | + return status |
626 | + |
627 | + def post_release_request(self, ip, mac=None): |
628 | + params = { |
629 | + 'op': 'release', |
630 | + 'ip': ip, |
631 | + } |
632 | + if mac is not None: |
633 | + params["mac"] = mac |
634 | + if self.force is not None: |
635 | + params["force"] = str(self.force) |
636 | + return self.client.post(reverse('ipaddresses_handler'), params) |
637 | + |
638 | + def test_POST_release_rejects_invalid_ip(self): |
639 | + response = self.post_release_request("1690.254.0.1") |
640 | + expected_status = self.expected_status(http.client.BAD_REQUEST) |
641 | + self.assertEqual(expected_status, response.status_code) |
642 | + if expected_status != http.client.FORBIDDEN: |
643 | + self.assertEqual( |
644 | + dict(ip=["Enter a valid IPv4 or IPv6 address."]), |
645 | + json_load_bytes(response.content)) |
646 | + else: |
647 | + self.assertEqual( |
648 | + "Force-releasing an IP address requires admin privileges.", |
649 | + response.content.decode("utf-8")) |
650 | + |
651 | + def test_POST_release_allows_admin_to_release_other_users_ip(self): |
652 | + factory.make_StaticIPAddress( |
653 | + user=factory.make_User(), alloc_type=IPADDRESS_TYPE.USER_RESERVED, |
654 | + ip="192.168.0.1") |
655 | + response = self.post_release_request("192.168.0.1") |
656 | + if self.expect_forbidden: |
657 | + self.assertEqual(http.client.FORBIDDEN, response.status_code) |
658 | + self.assertEqual( |
659 | + "Force-releasing an IP address requires admin privileges.", |
660 | + response.content.decode("utf-8")) |
661 | + elif not self.force: |
662 | + self.assertEqual(http.client.BAD_REQUEST, response.status_code) |
663 | + self.assertThat( |
664 | + response.content.decode("utf-8"), |
665 | + DocTestMatches("...does not belong to the requesting user...")) |
666 | + else: |
667 | + self.assertEqual(http.client.NO_CONTENT, response.status_code) |
668 | + self.assertThat( |
669 | + response.content.decode("utf-8"), |
670 | + Equals("")) |
671 | + |
672 | + def test_POST_release_allows_admin_to_release_sticky_ip(self): |
673 | + # Make an "orphaned" IP address, like the one in bug #1630034. |
674 | + static_ip = factory.make_StaticIPAddress( |
675 | + user=factory.make_User(), alloc_type=IPADDRESS_TYPE.STICKY) |
676 | + response = self.post_release_request(str(static_ip.ip)) |
677 | + if self.expect_forbidden: |
678 | + self.assertEqual(http.client.FORBIDDEN, response.status_code) |
679 | + self.assertEqual( |
680 | + "Force-releasing an IP address requires admin privileges.", |
681 | + response.content.decode("utf-8")) |
682 | + elif not self.force: |
683 | + self.assertEqual(http.client.BAD_REQUEST, response.status_code) |
684 | + self.assertThat( |
685 | + response.content.decode("utf-8"), |
686 | + DocTestMatches("...does not belong to the requesting user...")) |
687 | + else: |
688 | + self.assertEqual(http.client.NO_CONTENT, response.status_code) |
689 | + self.assertThat( |
690 | + response.content.decode("utf-8"), |
691 | + Equals("")) |
692 | + |
693 | + def test_POST_release_deallocates_address(self): |
694 | + ipaddress = factory.make_StaticIPAddress( |
695 | + alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user) |
696 | + response = self.post_release_request(ipaddress.ip) |
697 | + self.assertEqual( |
698 | + self.expected_status(http.client.NO_CONTENT), |
699 | + response.status_code, response.content) |
700 | + if not self.expect_forbidden: |
701 | + self.assertIsNone(reload_object(ipaddress)) |
702 | + |
703 | + def test_POST_release_deletes_unknown_interface(self): |
704 | + subnet = factory.make_Subnet() |
705 | + unknown_nic = factory.make_Interface(INTERFACE_TYPE.UNKNOWN) |
706 | + ipaddress = unknown_nic.link_subnet( |
707 | + INTERFACE_LINK_TYPE.STATIC, subnet, |
708 | + alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user) |
709 | + response = self.post_release_request(ipaddress.ip) |
710 | + self.assertEqual( |
711 | + self.expected_status(http.client.NO_CONTENT), |
712 | + response.status_code, response.content) |
713 | + if not self.expect_forbidden: |
714 | + self.assertIsNone(reload_object(unknown_nic)) |
715 | + |
716 | + def test_POST_release_does_not_delete_interfaces_linked_to_nodes(self): |
717 | + node = factory.make_Node() |
718 | + attached_nic = factory.make_Interface(node=node) |
719 | + subnet = factory.make_Subnet() |
720 | + ipaddress = attached_nic.link_subnet( |
721 | + INTERFACE_LINK_TYPE.STATIC, subnet, |
722 | + alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user) |
723 | + |
724 | + self.post_release_request(ipaddress.ip) |
725 | + self.assertEqual(attached_nic, reload_object(attached_nic)) |
726 | + |
727 | + def test_POST_release_does_not_delete_other_IPs_I_own(self): |
728 | + ipaddress = factory.make_StaticIPAddress( |
729 | + alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user) |
730 | + other_address = factory.make_StaticIPAddress( |
731 | + alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user) |
732 | + response = self.post_release_request(ipaddress.ip) |
733 | + self.assertEqual( |
734 | + self.expected_status(http.client.NO_CONTENT), |
735 | + response.status_code, response.content) |
736 | + self.assertIsNotNone(reload_object(other_address)) |
737 | + |
738 | + |
739 | +class TestIPAddressesReserveAPI(APITestCase.ForUser): |
740 | + |
741 | + scenarios = ( |
742 | + ("with_ip_param", {"ip_param": "ip"}), |
743 | + ("with_ip_address_param", {"ip_param": "ip_address"}), |
744 | + ) |
745 | |
746 | def post_reservation_request( |
747 | - self, subnet=None, ip_address=None, network=None, |
748 | - mac=None, hostname=None): |
749 | + self, subnet=None, ip_address=None, network=None, mac=None, |
750 | + hostname=None): |
751 | params = { |
752 | 'op': 'reserve', |
753 | } |
754 | if ip_address is not None: |
755 | - params["ip_address"] = ip_address |
756 | + params[self.ip_param] = ip_address |
757 | if subnet is not None: |
758 | params["subnet"] = subnet.cidr |
759 | if network is not None and subnet is None: |
760 | @@ -46,40 +370,29 @@ |
761 | params["hostname"] = hostname |
762 | return self.client.post(reverse('ipaddresses_handler'), params) |
763 | |
764 | - def post_release_request(self, ip, mac=None): |
765 | - params = { |
766 | - 'op': 'release', |
767 | - 'ip': ip, |
768 | - } |
769 | - if mac is not None: |
770 | - params["mac"] = mac |
771 | - return self.client.post(reverse('ipaddresses_handler'), params) |
772 | - |
773 | def assertNoMatchingNetworkError(self, response, net): |
774 | self.assertEqual( |
775 | http.client.BAD_REQUEST, response.status_code, response.content) |
776 | expected = ( |
777 | - "Unable to identify subnet %s." % str(net)) |
778 | + "Unable to identify subnet: %s." % str(net)) |
779 | self.assertEqual( |
780 | expected.encode(settings.DEFAULT_CHARSET), |
781 | response.content) |
782 | |
783 | - def test_handler_path(self): |
784 | - self.assertEqual( |
785 | - '/api/2.0/ipaddresses/', reverse('ipaddresses_handler')) |
786 | - |
787 | def test_POST_reserve_creates_ipaddress(self): |
788 | subnet = factory.make_Subnet() |
789 | response = self.post_reservation_request(subnet) |
790 | self.assertEqual(http.client.OK, response.status_code) |
791 | returned_address = json_load_bytes(response.content) |
792 | [staticipaddress] = StaticIPAddress.objects.all() |
793 | - # We don't need to test the value of the 'created' datetime |
794 | - # field. By removing it, we also test for its presence. |
795 | + # Test that these fields exist, but don't test for exact values. |
796 | del returned_address['created'] |
797 | del returned_address['subnet'] |
798 | + del returned_address['interface_set'] |
799 | + del returned_address['owner'] |
800 | expected = dict( |
801 | alloc_type=staticipaddress.alloc_type, |
802 | + alloc_type_name=staticipaddress.alloc_type_name, |
803 | ip=staticipaddress.ip, |
804 | resource_uri=reverse('ipaddresses_handler'), |
805 | ) |
806 | @@ -102,8 +415,11 @@ |
807 | # field. By removing it, we also test for its presence. |
808 | del returned_address['created'] |
809 | del returned_address['subnet'] |
810 | + del returned_address['interface_set'] |
811 | + del returned_address['owner'] |
812 | expected = dict( |
813 | alloc_type=staticipaddress.alloc_type, |
814 | + alloc_type_name=staticipaddress.alloc_type_name, |
815 | ip=staticipaddress.ip, |
816 | resource_uri=reverse('ipaddresses_handler'), |
817 | ) |
818 | @@ -281,112 +597,3 @@ |
819 | self.assertEqual( |
820 | dict(ip_address=["Enter a valid IPv4 or IPv6 address."]), |
821 | json_load_bytes(response.content)) |
822 | - |
823 | - def test_POST_release_rejects_invalid_ip(self): |
824 | - response = self.post_release_request("1690.254.0.1") |
825 | - self.assertEqual(http.client.BAD_REQUEST, response.status_code) |
826 | - self.assertEqual( |
827 | - dict(ip=["Enter a valid IPv4 or IPv6 address."]), |
828 | - json_load_bytes(response.content)) |
829 | - |
830 | - def test_GET_returns_ipaddresses(self): |
831 | - original_ipaddress = factory.make_StaticIPAddress( |
832 | - user=self.user) |
833 | - response = self.client.get(reverse('ipaddresses_handler')) |
834 | - self.assertEqual( |
835 | - http.client.OK, response.status_code, response.content) |
836 | - |
837 | - parsed_result = json_load_bytes(response.content) |
838 | - self.assertEqual(1, len(parsed_result), response.content) |
839 | - [returned_address] = parsed_result |
840 | - fields = {'alloc_type', 'ip'} |
841 | - self.assertEqual( |
842 | - fields.union({'resource_uri', 'created', 'subnet'}), |
843 | - set(returned_address.keys())) |
844 | - expected_values = { |
845 | - field: getattr(original_ipaddress, field) |
846 | - for field in fields |
847 | - if field not in ('resource_uri', 'created') |
848 | - } |
849 | - # We don't need to test the value of the 'created' datetime |
850 | - # field. |
851 | - del returned_address['created'] |
852 | - del returned_address['subnet'] |
853 | - expected_values['resource_uri'] = reverse('ipaddresses_handler') |
854 | - self.assertEqual(expected_values, returned_address) |
855 | - |
856 | - def test_GET_returns_empty_if_no_ipaddresses(self): |
857 | - response = self.client.get(reverse('ipaddresses_handler')) |
858 | - self.assertEqual( |
859 | - http.client.OK, response.status_code, response.content) |
860 | - self.assertEqual([], json_load_bytes(response.content)) |
861 | - |
862 | - def test_GET_only_returns_request_users_addresses(self): |
863 | - ipaddress = factory.make_StaticIPAddress(user=self.user) |
864 | - factory.make_StaticIPAddress(user=factory.make_User()) |
865 | - response = self.client.get(reverse('ipaddresses_handler')) |
866 | - self.assertEqual( |
867 | - http.client.OK, response.status_code, response.content) |
868 | - parsed_result = json_load_bytes(response.content) |
869 | - [returned_address] = parsed_result |
870 | - self.assertEqual(ipaddress.ip, returned_address['ip']) |
871 | - |
872 | - def test_GET_sorts_by_id(self): |
873 | - addrs = [] |
874 | - for _ in range(3): |
875 | - addrs.append( |
876 | - factory.make_StaticIPAddress(user=self.user)) |
877 | - response = self.client.get(reverse('ipaddresses_handler')) |
878 | - self.assertEqual( |
879 | - http.client.OK, response.status_code, response.content) |
880 | - parsed_result = json_load_bytes(response.content) |
881 | - expected = [ |
882 | - addr.ip for addr in |
883 | - sorted(addrs, key=lambda addr: getattr(addr, "id"))] |
884 | - observed = [result['ip'] for result in parsed_result] |
885 | - self.assertEqual(expected, observed) |
886 | - |
887 | - def test_POST_release_deallocates_address(self): |
888 | - ipaddress = factory.make_StaticIPAddress( |
889 | - alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user) |
890 | - response = self.post_release_request(ipaddress.ip) |
891 | - self.assertEqual( |
892 | - http.client.OK, response.status_code, response.content) |
893 | - self.assertIsNone(reload_object(ipaddress)) |
894 | - |
895 | - def test_POST_release_deletes_unknown_interface(self): |
896 | - subnet = factory.make_Subnet() |
897 | - unknown_nic = factory.make_Interface(INTERFACE_TYPE.UNKNOWN) |
898 | - ipaddress = unknown_nic.link_subnet( |
899 | - INTERFACE_LINK_TYPE.STATIC, subnet, |
900 | - alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user) |
901 | - |
902 | - self.post_release_request(ipaddress.ip) |
903 | - self.assertIsNone(reload_object(unknown_nic)) |
904 | - |
905 | - def test_POST_release_does_not_delete_interfaces_linked_to_nodes(self): |
906 | - node = factory.make_Node() |
907 | - attached_nic = factory.make_Interface(node=node) |
908 | - subnet = factory.make_Subnet() |
909 | - ipaddress = attached_nic.link_subnet( |
910 | - INTERFACE_LINK_TYPE.STATIC, subnet, |
911 | - alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user) |
912 | - |
913 | - self.post_release_request(ipaddress.ip) |
914 | - self.assertEqual(attached_nic, reload_object(attached_nic)) |
915 | - |
916 | - def test_POST_release_does_not_delete_IP_that_I_dont_own(self): |
917 | - ipaddress = factory.make_StaticIPAddress(user=factory.make_User()) |
918 | - response = self.post_release_request(ipaddress.ip) |
919 | - self.assertEqual( |
920 | - http.client.BAD_REQUEST, response.status_code, response.content) |
921 | - |
922 | - def test_POST_release_does_not_delete_other_IPs_I_own(self): |
923 | - ipaddress = factory.make_StaticIPAddress( |
924 | - alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user) |
925 | - other_address = factory.make_StaticIPAddress( |
926 | - alloc_type=IPADDRESS_TYPE.USER_RESERVED, user=self.user) |
927 | - response = self.post_release_request(ipaddress.ip) |
928 | - self.assertEqual( |
929 | - http.client.OK, response.status_code, response.content) |
930 | - self.assertIsNotNone(reload_object(other_address)) |
931 | |
932 | === modified file 'src/maasserver/models/staticipaddress.py' |
933 | --- src/maasserver/models/staticipaddress.py 2016-09-22 02:53:33 +0000 |
934 | +++ src/maasserver/models/staticipaddress.py 2016-10-12 17:51:07 +0000 |
935 | @@ -558,6 +558,11 @@ |
936 | strtype = type_names.get(self.alloc_type, '%s' % self.alloc_type) |
937 | return "%s:type=%s" % (self.ip, strtype) |
938 | |
939 | + @property |
940 | + def alloc_type_name(self): |
941 | + """Returns a human-readable representation of the `alloc_type`.""" |
942 | + return IPADDRESS_TYPE_CHOICES_DICT.get(self.alloc_type, "") |
943 | + |
944 | def get_node(self): |
945 | """Return the Node of the first Interface connected to this IP |
946 | address.""" |
947 | |
948 | === modified file 'src/maasserver/models/tests/test_staticipaddress.py' |
949 | --- src/maasserver/models/tests/test_staticipaddress.py 2016-09-22 02:53:33 +0000 |
950 | +++ src/maasserver/models/tests/test_staticipaddress.py 2016-10-12 17:51:07 +0000 |
951 | @@ -20,6 +20,7 @@ |
952 | INTERFACE_TYPE, |
953 | IPADDRESS_FAMILY, |
954 | IPADDRESS_TYPE, |
955 | + IPADDRESS_TYPE_CHOICES_DICT, |
956 | IPRANGE_TYPE, |
957 | ) |
958 | from maasserver.exceptions import ( |
959 | @@ -1062,3 +1063,19 @@ |
960 | node_summary = json["node_summary"] |
961 | self.expectThat(node_summary["system_id"], Equals(node.system_id)) |
962 | self.expectThat(node_summary["node_type"], Equals(node.node_type)) |
963 | + |
964 | + |
965 | +class TestAllocTypeName(MAASServerTestCase): |
966 | + |
967 | + def test__provides_human_readable_values_for_known_types(self): |
968 | + ip = factory.make_StaticIPAddress() |
969 | + self.assertThat( |
970 | + ip.alloc_type_name, |
971 | + Equals(IPADDRESS_TYPE_CHOICES_DICT[ip.alloc_type])) |
972 | + |
973 | + def test__returns_empty_string_for_unknown_types(self): |
974 | + ip = factory.make_StaticIPAddress() |
975 | + ip.alloc_type = randint(2**16, 2**32) |
976 | + self.assertThat( |
977 | + ip.alloc_type_name, |
978 | + Equals("")) |
A few things need fixing, like the "Undefined" default in allow_type_name, but it's a good change in all, especially for consumers of the API.
This could have been done in multiple branches. This *should* have been done in multiple branches. If not developed that way, at least split out for review. It is much easier to review small changes. This change is hardly egregious and it wasn't too hard to review, but it could have been better.
If nothing else know this: as a reviewer, when there are multiple branches to review, I will always review short branches first.