Merge lp:~blake-rouse/maas/maascli-multipart-upload into lp:~maas-committers/maas/trunk
- maascli-multipart-upload
- Merge into trunk
Status: | Merged | ||||
---|---|---|---|---|---|
Approved by: | Blake Rouse | ||||
Approved revision: | no longer in the source branch. | ||||
Merged at revision: | 2971 | ||||
Proposed branch: | lp:~blake-rouse/maas/maascli-multipart-upload | ||||
Merge into: | lp:~maas-committers/maas/trunk | ||||
Diff against target: |
676 lines (+498/-9) 11 files modified
src/maascli/actions/boot_resources_create.py (+186/-0) src/maascli/actions/tests/test_boot_resources_create.py (+207/-0) src/maascli/api.py (+22/-1) src/maascli/tests/test_api.py (+29/-0) src/maascli/utils.py (+15/-0) src/maasserver/api/boot_resources.py (+3/-1) src/maasserver/api/boot_source_selections.py (+2/-2) src/maasserver/api/boot_sources.py (+2/-2) src/maasserver/api/doc.py (+5/-3) src/maasserver/api/tests/test_doc.py (+12/-0) src/maasserver/models/bootresourcefile.py (+15/-0) |
||||
To merge this branch: | bzr merge lp:~blake-rouse/maas/maascli-multipart-upload | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Jason Hobbs (community) | Approve | ||
Review via email: mp+234142@code.launchpad.net |
Commit message
Modified the maascli to allow for custom Action classes based on the handler and action. Added the BootResourceCre
Also contains a drive by fix for LargeFile not being deleted correctly when the reference object is the last.
Description of the change
Blake Rouse (blake-rouse) : | # |
MAAS Lander (maas-lander) wrote : | # |
The attempt to merge lp:~blake-rouse/maas/maascli-multipart-upload into lp:maas failed. Below is the output from the failed tests.
Ign http://
Get:1 http://
Ign http://
Get:2 http://
Ign http://
Hit http://
Get:3 http://
Hit http://
Get:4 http://
Get:5 http://
Hit http://
Get:6 http://
Hit http://
Get:7 http://
Hit http://
Hit http://
Hit http://
Hit http://
Get:8 http://
Hit http://
Hit http://
Ign http://
Ign http://
Get:9 http://
Get:10 http://
Get:11 http://
Get:12 http://
Hit http://
Hit http://
Fetched 1,080 kB in 0s (1,770 kB/s)
Reading package lists...
sudo DEBIAN_
--
Preview Diff
1 | === added directory 'src/maascli/actions' |
2 | === added file 'src/maascli/actions/__init__.py' |
3 | === added file 'src/maascli/actions/boot_resources_create.py' |
4 | --- src/maascli/actions/boot_resources_create.py 1970-01-01 00:00:00 +0000 |
5 | +++ src/maascli/actions/boot_resources_create.py 2014-09-12 03:32:27 +0000 |
6 | @@ -0,0 +1,186 @@ |
7 | +# Copyright 2014 Canonical Ltd. This software is licensed under the |
8 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
9 | + |
10 | +"""MAAS Boot Resources Action.""" |
11 | + |
12 | +from __future__ import ( |
13 | + absolute_import, |
14 | + print_function, |
15 | + unicode_literals, |
16 | + ) |
17 | + |
18 | +str = None |
19 | + |
20 | +__metaclass__ = type |
21 | +__all__ = [ |
22 | + 'action_class' |
23 | + ] |
24 | + |
25 | +from cStringIO import StringIO |
26 | +import hashlib |
27 | +import json |
28 | +from urlparse import urljoin |
29 | + |
30 | +from apiclient.multipart import ( |
31 | + build_multipart_message, |
32 | + encode_multipart_message, |
33 | + ) |
34 | +from maascli.api import ( |
35 | + Action, |
36 | + http_request, |
37 | + ) |
38 | +from maascli.command import CommandError |
39 | + |
40 | +# Send 4MB of data per request. |
41 | +CHUNK_SIZE = 1 << 22 |
42 | + |
43 | + |
44 | +class BootResourcesCreateAction(Action): |
45 | + """Provides custom logic to the boot-resources create action. |
46 | + |
47 | + Command: maas username boot-resources create |
48 | + |
49 | + The create command has the ability to upload the content in pieces, using |
50 | + the upload_uri that is returned in the response. This class provides the |
51 | + logic to upload over that API. |
52 | + """ |
53 | + |
54 | + def __call__(self, options): |
55 | + # TODO: this is el-cheapo URI Template |
56 | + # <http://tools.ietf.org/html/rfc6570> support; use uritemplate-py |
57 | + # <https://github.com/uri-templates/uritemplate-py> here? |
58 | + uri = self.uri.format(**vars(options)) |
59 | + content = self.initial_request(uri, options) |
60 | + |
61 | + # Get the created resource file for the boot resource |
62 | + rfile = self.get_resource_file(content) |
63 | + if rfile is None: |
64 | + print("Failed to identify created resource.") |
65 | + raise CommandError(2) |
66 | + if rfile['complete']: |
67 | + # File already existed in the database, so no |
68 | + # reason to upload it. |
69 | + return |
70 | + |
71 | + # Upload content |
72 | + data = dict(options.data) |
73 | + upload_uri = urljoin(uri, rfile['upload_uri']) |
74 | + self.upload_content( |
75 | + upload_uri, data['content'], insecure=options.insecure) |
76 | + |
77 | + def initial_request(self, uri, options): |
78 | + """Performs the initial POST request, to create the boot resource.""" |
79 | + # Bundle things up ready to throw over the wire. |
80 | + body, headers = self.prepare_initial_payload(options.data) |
81 | + |
82 | + # Headers are returned as a list, but they must be a dict for |
83 | + # the signing machinery. |
84 | + headers = dict(headers) |
85 | + |
86 | + # Sign request if credentials have been provided. |
87 | + if self.credentials is not None: |
88 | + self.sign(uri, headers, self.credentials) |
89 | + |
90 | + # Use httplib2 instead of urllib2 (or MAASDispatcher, which is based |
91 | + # on urllib2) so that we get full control over HTTP method. TODO: |
92 | + # create custom MAASDispatcher to use httplib2 so that MAASClient can |
93 | + # be used. |
94 | + response, content = http_request( |
95 | + uri, self.method, body=body, headers=headers, |
96 | + insecure=options.insecure) |
97 | + |
98 | + # 2xx status codes are all okay. |
99 | + if response.status // 100 != 2: |
100 | + if options.debug: |
101 | + self.print_debug(response) |
102 | + self.print_response(response, content) |
103 | + raise CommandError(2) |
104 | + return content |
105 | + |
106 | + def prepare_initial_payload(self, data): |
107 | + """Return the body and headers for the initial request. |
108 | + |
109 | + This is method is only used for the first request to MAAS. It |
110 | + removes the passed content, and replaces it with the sha256 and size |
111 | + of that content. |
112 | + |
113 | + :param data: An iterable of ``name, value`` or ``name, opener`` |
114 | + tuples (see `name_value_pair`) to pack into the body or |
115 | + query, depending on the type of request. |
116 | + """ |
117 | + data = dict(data) |
118 | + if 'content' not in data: |
119 | + print('Missing content.') |
120 | + raise CommandError(2) |
121 | + |
122 | + content = data.pop('content') |
123 | + size, sha256 = self.calculate_size_and_sha256(content) |
124 | + data['size'] = '%s' % size |
125 | + data['sha256'] = sha256 |
126 | + |
127 | + data = [(key, value) for key, value in data.items()] |
128 | + message = build_multipart_message(data) |
129 | + headers, body = encode_multipart_message(message) |
130 | + return body, headers |
131 | + |
132 | + def calculate_size_and_sha256(self, content): |
133 | + """Return the size and sha256 of the content.""" |
134 | + size = 0 |
135 | + sha256 = hashlib.sha256() |
136 | + with content() as fd: |
137 | + while True: |
138 | + buf = fd.read(CHUNK_SIZE) |
139 | + length = len(buf) |
140 | + size += length |
141 | + sha256.update(buf) |
142 | + if length != CHUNK_SIZE: |
143 | + break |
144 | + return size, sha256.hexdigest() |
145 | + |
146 | + def get_resource_file(self, content): |
147 | + """Return the boot resource file for the created file.""" |
148 | + data = json.loads(content) |
149 | + if len(data['sets']) == 0: |
150 | + # No sets returned, no way to get the resource file. |
151 | + return None |
152 | + newest_set = sorted(data['sets'].keys(), reverse=True)[0] |
153 | + resource_set = data['sets'][newest_set] |
154 | + if len(resource_set['files']) != 1: |
155 | + # This api only supports uploading one file. If the set doesn't |
156 | + # have just one file, then there is no way of knowing which file. |
157 | + return None |
158 | + _, rfile = resource_set['files'].popitem() |
159 | + return rfile |
160 | + |
161 | + def put_upload(self, upload_uri, data, insecure=False): |
162 | + """Send PUT method to upload data.""" |
163 | + headers = { |
164 | + 'Content-Type': 'application/octet-stream', |
165 | + 'Content-Length': '%s' % len(data), |
166 | + } |
167 | + if self.credentials is not None: |
168 | + self.sign(upload_uri, headers, self.credentials) |
169 | + # httplib2 expects the body to be file-like if its binary data |
170 | + data = StringIO(data) |
171 | + response, content = http_request( |
172 | + upload_uri, 'PUT', body=data, headers=headers, |
173 | + insecure=insecure) |
174 | + if response.status != 200: |
175 | + self.print_response(response, content) |
176 | + raise CommandError(2) |
177 | + |
178 | + def upload_content(self, upload_uri, content, insecure=False): |
179 | + """Upload the content in chunks.""" |
180 | + with content() as fd: |
181 | + while True: |
182 | + buf = fd.read(CHUNK_SIZE) |
183 | + length = len(buf) |
184 | + if length > 0: |
185 | + self.put_upload(upload_uri, buf, insecure=insecure) |
186 | + if length != CHUNK_SIZE: |
187 | + break |
188 | + |
189 | + |
190 | +# Each action sets this variable so the class can be picked up |
191 | +# by get_action_class. |
192 | +action_class = BootResourcesCreateAction |
193 | |
194 | === added directory 'src/maascli/actions/tests' |
195 | === added file 'src/maascli/actions/tests/__init__.py' |
196 | === added file 'src/maascli/actions/tests/test_boot_resources_create.py' |
197 | --- src/maascli/actions/tests/test_boot_resources_create.py 1970-01-01 00:00:00 +0000 |
198 | +++ src/maascli/actions/tests/test_boot_resources_create.py 2014-09-12 03:32:27 +0000 |
199 | @@ -0,0 +1,207 @@ |
200 | +# Copyright 2012-2014 Canonical Ltd. This software is licensed under the |
201 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
202 | + |
203 | +"""Test for boot-resources create action.""" |
204 | + |
205 | +from __future__ import ( |
206 | + absolute_import, |
207 | + print_function, |
208 | + unicode_literals, |
209 | + ) |
210 | + |
211 | +str = None |
212 | + |
213 | +__metaclass__ = type |
214 | +__all__ = [] |
215 | + |
216 | +from functools import partial |
217 | +import hashlib |
218 | +import json |
219 | +import os |
220 | +from random import randint |
221 | + |
222 | +from apiclient.testing.credentials import make_api_credentials |
223 | +import httplib2 |
224 | +from maascli.actions import boot_resources_create |
225 | +from maascli.actions.boot_resources_create import ( |
226 | + BootResourcesCreateAction, |
227 | + CHUNK_SIZE, |
228 | + ) |
229 | +from maascli.command import CommandError |
230 | +from maastesting.factory import factory |
231 | +from maastesting.fixtures import TempDirectory |
232 | +from maastesting.matchers import MockCalledOnceWith |
233 | +from maastesting.testcase import MAASTestCase |
234 | +from mock import ( |
235 | + ANY, |
236 | + Mock, |
237 | + sentinel, |
238 | + ) |
239 | + |
240 | + |
241 | +class TestBootResourcesCreateAction(MAASTestCase): |
242 | + """Tests for `BootResourcesCreateAction`.""" |
243 | + |
244 | + def configure_http_request(self, status, content): |
245 | + response = httplib2.Response({'status': status}) |
246 | + self.patch( |
247 | + boot_resources_create, |
248 | + 'http_request').return_value = (response, content) |
249 | + |
250 | + def make_boot_resources_create_action(self): |
251 | + action_name = "create".encode("ascii") |
252 | + action_bases = (BootResourcesCreateAction,) |
253 | + action_ns = { |
254 | + "action": {'method': 'POST'}, |
255 | + "handler": {'uri': '/api/1.0/boot-resources/', 'params': []}, |
256 | + "profile": {'credentials': make_api_credentials()} |
257 | + } |
258 | + action_class = type(action_name, action_bases, action_ns) |
259 | + action = action_class(Mock()) |
260 | + self.patch(action, 'print_debug') |
261 | + self.patch(action, 'print_response') |
262 | + return action |
263 | + |
264 | + def make_content(self, size=None): |
265 | + tmpdir = self.useFixture(TempDirectory()).path |
266 | + if size is None: |
267 | + size = randint(1024, 2048) |
268 | + data = factory.make_bytes(size) |
269 | + content_path = os.path.join(tmpdir, 'content') |
270 | + with open(content_path, 'wb') as stream: |
271 | + stream.write(data) |
272 | + sha256 = hashlib.sha256() |
273 | + sha256.update(data) |
274 | + return size, sha256.hexdigest(), partial(open, content_path, "rb") |
275 | + |
276 | + def test_initial_request_returns_content(self): |
277 | + content = factory.make_name('content') |
278 | + self.configure_http_request(200, content) |
279 | + action = self.make_boot_resources_create_action() |
280 | + self.patch(action, 'prepare_initial_payload').return_value = ("", {}) |
281 | + self.assertEqual( |
282 | + content, action.initial_request(sentinel.uri, Mock())) |
283 | + |
284 | + def test_initial_request_raises_CommandError_on_error(self): |
285 | + self.configure_http_request(500, factory.make_name('content')) |
286 | + action = self.make_boot_resources_create_action() |
287 | + self.patch(action, 'prepare_initial_payload').return_value = ("", {}) |
288 | + self.assertRaises( |
289 | + CommandError, |
290 | + action.initial_request, sentinel.uri, Mock()) |
291 | + |
292 | + def test_prepare_initial_payload_raises_CommandError_missing_content(self): |
293 | + action = self.make_boot_resources_create_action() |
294 | + self.patch(boot_resources_create, 'print') |
295 | + self.assertRaises( |
296 | + CommandError, |
297 | + action.prepare_initial_payload, [('invalid', '')]) |
298 | + |
299 | + def test_prepare_initial_payload_adds_size_and_sha256(self): |
300 | + size, sha256, stream = self.make_content() |
301 | + action = self.make_boot_resources_create_action() |
302 | + mock_build_message = self.patch( |
303 | + boot_resources_create, |
304 | + 'build_multipart_message') |
305 | + self.patch( |
306 | + boot_resources_create, |
307 | + 'encode_multipart_message').return_value = (None, None) |
308 | + action.prepare_initial_payload([('content', stream)]) |
309 | + self.assertThat( |
310 | + mock_build_message, |
311 | + MockCalledOnceWith([('sha256', sha256), ('size', '%s' % size)])) |
312 | + |
313 | + def test_get_resource_file_returns_None_when_no_sets(self): |
314 | + content = { |
315 | + 'sets': {} |
316 | + } |
317 | + action = self.make_boot_resources_create_action() |
318 | + self.assertIsNone(action.get_resource_file(json.dumps(content))) |
319 | + |
320 | + def test_get_resource_file_returns_None_when_no_files(self): |
321 | + content = { |
322 | + 'sets': { |
323 | + '20140910': { |
324 | + 'files': {} |
325 | + }, |
326 | + }, |
327 | + } |
328 | + action = self.make_boot_resources_create_action() |
329 | + self.assertIsNone(action.get_resource_file(json.dumps(content))) |
330 | + |
331 | + def test_get_resource_file_returns_None_when_more_than_one_file(self): |
332 | + content = { |
333 | + 'sets': { |
334 | + '20140910': { |
335 | + 'files': { |
336 | + 'root-image.gz': {}, |
337 | + 'root-tgz': {}, |
338 | + } |
339 | + }, |
340 | + }, |
341 | + } |
342 | + action = self.make_boot_resources_create_action() |
343 | + self.assertIsNone(action.get_resource_file(json.dumps(content))) |
344 | + |
345 | + def test_get_resource_file_returns_file_from_newest_set(self): |
346 | + filename = factory.make_name('file') |
347 | + content = { |
348 | + 'sets': { |
349 | + '20140910': { |
350 | + 'files': { |
351 | + filename: { |
352 | + 'name': filename, |
353 | + }, |
354 | + }, |
355 | + }, |
356 | + '20140909': { |
357 | + 'files': { |
358 | + 'other': { |
359 | + 'name': 'other', |
360 | + }, |
361 | + }, |
362 | + }, |
363 | + }, |
364 | + } |
365 | + action = self.make_boot_resources_create_action() |
366 | + self.assertEqual( |
367 | + {'name': filename}, |
368 | + action.get_resource_file(json.dumps(content))) |
369 | + |
370 | + def test_put_upload_raise_CommandError_if_status_not_200(self): |
371 | + self.configure_http_request(500, '') |
372 | + action = self.make_boot_resources_create_action() |
373 | + self.assertRaises( |
374 | + CommandError, |
375 | + action.put_upload, sentinel.upload_uri, '') |
376 | + |
377 | + def test_put_upload_sends_content_type_and_length_headers(self): |
378 | + response = httplib2.Response({'status': 200}) |
379 | + mock_request = self.patch(boot_resources_create, 'http_request') |
380 | + mock_request.return_value = (response, '') |
381 | + action = self.make_boot_resources_create_action() |
382 | + self.patch(action, 'sign') |
383 | + data = factory.make_bytes() |
384 | + action.put_upload(sentinel.upload_uri, data) |
385 | + headers = { |
386 | + 'Content-Type': 'application/octet-stream', |
387 | + 'Content-Length': '%s' % len(data), |
388 | + } |
389 | + self.assertThat( |
390 | + mock_request, |
391 | + MockCalledOnceWith( |
392 | + sentinel.upload_uri, 'PUT', body=ANY, |
393 | + headers=headers, insecure=False)) |
394 | + |
395 | + def test_upload_content_calls_put_upload_with_sizeof_CHUNK_SIZE(self): |
396 | + size = CHUNK_SIZE * 2 |
397 | + size, sha256, stream = self.make_content(size=size) |
398 | + action = self.make_boot_resources_create_action() |
399 | + mock_upload = self.patch(action, 'put_upload') |
400 | + action.upload_content(sentinel.upload_uri, stream) |
401 | + |
402 | + call_data_sizes = [ |
403 | + len(call[0][1]) |
404 | + for call in mock_upload.call_args_list |
405 | + ] |
406 | + self.assertEqual([CHUNK_SIZE, CHUNK_SIZE], call_data_sizes) |
407 | |
408 | === modified file 'src/maascli/api.py' |
409 | --- src/maascli/api.py 2014-08-13 21:49:35 +0000 |
410 | +++ src/maascli/api.py 2014-09-12 03:32:27 +0000 |
411 | @@ -53,6 +53,7 @@ |
412 | handler_command_name, |
413 | parse_docstring, |
414 | safe_name, |
415 | + try_import_module, |
416 | ) |
417 | |
418 | |
419 | @@ -402,12 +403,32 @@ |
420 | sys.exit(0) |
421 | |
422 | |
423 | +def get_action_class(handler, action): |
424 | + """Return custom action handler class.""" |
425 | + handler_name = handler_command_name(handler["name"]).replace('-', '_') |
426 | + action_name = '%s_%s' % ( |
427 | + handler_name, |
428 | + safe_name(action["name"]).replace('-', '_')) |
429 | + action_module = try_import_module('maascli.actions.%s' % action_name) |
430 | + if action_module is not None: |
431 | + return action_module.action_class |
432 | + return None |
433 | + |
434 | + |
435 | +def get_action_class_bases(handler, action): |
436 | + """Return the base classes for the dynamic class.""" |
437 | + action_class = get_action_class(handler, action) |
438 | + if action_class is not None: |
439 | + return (action_class,) |
440 | + return (Action,) |
441 | + |
442 | + |
443 | def register_actions(profile, handler, parser): |
444 | """Register a handler's actions.""" |
445 | for action in handler["actions"]: |
446 | help_title, help_body = parse_docstring(action["doc"]) |
447 | action_name = safe_name(action["name"]).encode("ascii") |
448 | - action_bases = (Action,) |
449 | + action_bases = get_action_class_bases(handler, action) |
450 | action_ns = { |
451 | "action": action, |
452 | "handler": handler, |
453 | |
454 | === modified file 'src/maascli/tests/test_api.py' |
455 | --- src/maascli/tests/test_api.py 2014-08-13 21:49:35 +0000 |
456 | +++ src/maascli/tests/test_api.py 2014-09-12 03:32:27 +0000 |
457 | @@ -24,6 +24,7 @@ |
458 | |
459 | import httplib2 |
460 | from maascli import api |
461 | +from maascli.actions.boot_resources_create import BootResourcesCreateAction |
462 | from maascli.command import CommandError |
463 | from maascli.config import ProfileConfig |
464 | from maascli.parser import ArgumentParser |
465 | @@ -179,6 +180,34 @@ |
466 | "disable the certificate check.") |
467 | self.assertEqual(error_expected, "%s" % error) |
468 | |
469 | + def test_get_action_class_returns_None_for_unknown_handler(self): |
470 | + handler = {'name': factory.make_name('handler')} |
471 | + action = {'name': 'create'} |
472 | + self.assertIsNone(api.get_action_class(handler, action)) |
473 | + |
474 | + def test_get_action_class_returns_BootResourcesCreateAction_class(self): |
475 | + # Test uses BootResourcesCreateAction as its know to exist. |
476 | + handler = {'name': 'BootResourcesHandler'} |
477 | + action = {'name': 'create'} |
478 | + self.assertEqual( |
479 | + BootResourcesCreateAction, |
480 | + api.get_action_class(handler, action)) |
481 | + |
482 | + def test_get_action_class_bases_returns_Action(self): |
483 | + handler = {'name': factory.make_name('handler')} |
484 | + action = {'name': 'create'} |
485 | + self.assertEqual( |
486 | + (api.Action,), |
487 | + api.get_action_class_bases(handler, action)) |
488 | + |
489 | + def test_get_action_class_bases_returns_BootResourcesCreateAction(self): |
490 | + # Test uses BootResourcesCreateAction as its know to exist. |
491 | + handler = {'name': 'BootResourcesHandler'} |
492 | + action = {'name': 'create'} |
493 | + self.assertEqual( |
494 | + (BootResourcesCreateAction,), |
495 | + api.get_action_class_bases(handler, action)) |
496 | + |
497 | |
498 | class TestIsResponseTextual(MAASTestCase): |
499 | """Tests for `is_response_textual`.""" |
500 | |
501 | === modified file 'src/maascli/utils.py' |
502 | --- src/maascli/utils.py 2013-10-07 09:31:09 +0000 |
503 | +++ src/maascli/utils.py 2014-09-12 03:32:27 +0000 |
504 | @@ -25,6 +25,7 @@ |
505 | getdoc, |
506 | ) |
507 | import re |
508 | +import sys |
509 | from urlparse import urlparse |
510 | |
511 | |
512 | @@ -107,3 +108,17 @@ |
513 | if re.search("/api/[0-9.]+/?$", url.path) is None: |
514 | url = url._replace(path=url.path + "api/1.0/") |
515 | return url.geturl() |
516 | + |
517 | + |
518 | +def import_module(import_str): |
519 | + """Import a module.""" |
520 | + __import__(import_str) |
521 | + return sys.modules[import_str] |
522 | + |
523 | + |
524 | +def try_import_module(import_str, default=None): |
525 | + """Try to import a module.""" |
526 | + try: |
527 | + return import_module(import_str) |
528 | + except ImportError: |
529 | + return default |
530 | |
531 | === modified file 'src/maasserver/api/boot_resources.py' |
532 | --- src/maasserver/api/boot_resources.py 2014-09-03 01:05:32 +0000 |
533 | +++ src/maasserver/api/boot_resources.py 2014-09-12 03:32:27 +0000 |
534 | @@ -273,12 +273,14 @@ |
535 | |
536 | read = create = delete = None |
537 | |
538 | + hidden = True |
539 | + |
540 | @admin_method |
541 | def update(self, request, id, file_id): |
542 | """Upload piece of boot resource file.""" |
543 | resource = get_object_or_404(BootResource, id=id) |
544 | rfile = get_object_or_404(BootResourceFile, id=file_id) |
545 | - size = request.META.get('CONTENT_LENGTH', 0) |
546 | + size = int(request.META.get('CONTENT_LENGTH', '0')) |
547 | data = request.body |
548 | if size == 0: |
549 | raise MAASAPIBadRequest("Missing data.") |
550 | |
551 | === modified file 'src/maasserver/api/boot_source_selections.py' |
552 | --- src/maasserver/api/boot_source_selections.py 2014-08-22 15:21:48 +0000 |
553 | +++ src/maasserver/api/boot_source_selections.py 2014-09-12 03:32:27 +0000 |
554 | @@ -101,7 +101,7 @@ |
555 | be set globally for the whole region and clusters. This api is now |
556 | deprecated, and only exists for backwards compatibility. |
557 | """ |
558 | - deprecated = True |
559 | + hidden = True |
560 | |
561 | def read(self, request, uuid, boot_source_id, id): |
562 | """Read a boot source selection.""" |
563 | @@ -175,7 +175,7 @@ |
564 | be set globally for the whole region and clusters. This api is now |
565 | deprecated, and only exists for backwards compatibility. |
566 | """ |
567 | - deprecated = True |
568 | + hidden = True |
569 | |
570 | def read(self, request, uuid, boot_source_id): |
571 | """List boot source selections. |
572 | |
573 | === modified file 'src/maasserver/api/boot_sources.py' |
574 | --- src/maasserver/api/boot_sources.py 2014-08-22 15:21:48 +0000 |
575 | +++ src/maasserver/api/boot_sources.py 2014-09-12 03:32:27 +0000 |
576 | @@ -119,7 +119,7 @@ |
577 | be set globally for the whole region and clusters. This api is now |
578 | deprecated, and only exists for backwards compatibility. |
579 | """ |
580 | - deprecated = True |
581 | + hidden = True |
582 | |
583 | def read(self, request, uuid, id): |
584 | """Read a boot source.""" |
585 | @@ -186,7 +186,7 @@ |
586 | be set globally for the whole region and clusters. This api is now |
587 | deprecated, and only exists for backwards compatibility. |
588 | """ |
589 | - deprecated = True |
590 | + hidden = True |
591 | |
592 | def read(self, request, uuid): |
593 | """List boot sources. |
594 | |
595 | === modified file 'src/maasserver/api/doc.py' |
596 | --- src/maasserver/api/doc.py 2014-08-22 15:21:48 +0000 |
597 | +++ src/maasserver/api/doc.py 2014-09-12 03:32:27 +0000 |
598 | @@ -43,12 +43,14 @@ |
599 | Handlers are of type :class:`HandlerMetaClass`, and must define a |
600 | `resource_uri` method. |
601 | |
602 | + Handlers that have the attribute hidden set to True, will not be returned. |
603 | + |
604 | :rtype: Generator, yielding handlers. |
605 | """ |
606 | p_has_resource_uri = lambda resource: ( |
607 | getattr(resource.handler, "resource_uri", None) is not None) |
608 | - p_is_not_deprecated = lambda resource: ( |
609 | - getattr(resource.handler, "deprecated", False)) |
610 | + p_is_not_hidden = lambda resource: ( |
611 | + getattr(resource.handler, "hidden", False)) |
612 | for pattern in resolver.url_patterns: |
613 | if isinstance(pattern, RegexURLResolver): |
614 | accumulate_api_resources(pattern, accumulator) |
615 | @@ -56,7 +58,7 @@ |
616 | if isinstance(pattern.callback, Resource): |
617 | resource = pattern.callback |
618 | if p_has_resource_uri(resource) and \ |
619 | - not p_is_not_deprecated(resource): |
620 | + not p_is_not_hidden(resource): |
621 | accumulator.add(resource) |
622 | else: |
623 | raise AssertionError( |
624 | |
625 | === modified file 'src/maasserver/api/tests/test_doc.py' |
626 | --- src/maasserver/api/tests/test_doc.py 2014-08-20 23:54:33 +0000 |
627 | +++ src/maasserver/api/tests/test_doc.py 2014-09-12 03:32:27 +0000 |
628 | @@ -88,6 +88,18 @@ |
629 | module.urlpatterns = patterns("", url("^metal", resource)) |
630 | self.assertSetEqual({resource}, find_api_resources(module)) |
631 | |
632 | + def test_urlpatterns_with_resource_hidden(self): |
633 | + # Resources for handlers with resource_uri attributes are discovered |
634 | + # in a urlconf module and returned, unless hidden = True. |
635 | + handler = type(b"\m/", (BaseHandler,), { |
636 | + "resource_uri": True, |
637 | + "hidden": True, |
638 | + }) |
639 | + resource = Resource(handler) |
640 | + module = self.make_module() |
641 | + module.urlpatterns = patterns("", url("^metal", resource)) |
642 | + self.assertSetEqual(set(), find_api_resources(module)) |
643 | + |
644 | def test_nested_urlpatterns_with_handler(self): |
645 | # Resources are found in nested urlconfs. |
646 | handler = type(b"\m/", (BaseHandler,), {"resource_uri": True}) |
647 | |
648 | === modified file 'src/maasserver/models/bootresourcefile.py' |
649 | --- src/maasserver/models/bootresourcefile.py 2014-08-31 02:47:48 +0000 |
650 | +++ src/maasserver/models/bootresourcefile.py 2014-09-12 03:32:27 +0000 |
651 | @@ -20,6 +20,8 @@ |
652 | CharField, |
653 | ForeignKey, |
654 | ) |
655 | +from django.db.models.signals import post_delete |
656 | +from django.dispatch import receiver |
657 | from maasserver import DefaultMeta |
658 | from maasserver.enum import ( |
659 | BOOT_RESOURCE_FILE_TYPE, |
660 | @@ -70,3 +72,16 @@ |
661 | |
662 | def __repr__(self): |
663 | return "<BootResourceFile %s/%s>" % (self.filename, self.filetype) |
664 | + |
665 | + |
666 | +@receiver(post_delete) |
667 | +def delete_large_file(sender, instance, **kwargs): |
668 | + """Call delete on the LargeFile, now that the relation has been removed. |
669 | + If this was the only resource file referencing this LargeFile then it will |
670 | + be delete. |
671 | + |
672 | + This is done using the `post_delete` signal because only then has the |
673 | + relation been removed. |
674 | + """ |
675 | + if sender == BootResourceFile: |
676 | + instance.largefile.delete() |
I have comments and questions inline but I don't think any of them are blockers.