Merge lp:~blake-rouse/maas/maascli-multipart-upload into lp:~maas-committers/maas/trunk

Proposed by Blake Rouse
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
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 BootResourceCreateAction, that performs the upload of the content parameter using multiple part uploading, that the MAAS API now supports.

Also contains a drive by fix for LargeFile not being deleted correctly when the reference object is the last.

To post a comment you must log in.
Revision history for this message
Jason Hobbs (jason-hobbs) wrote :

I have comments and questions inline but I don't think any of them are blockers.

review: Approve
Revision history for this message
Blake Rouse (blake-rouse) :
Revision history for this message
MAAS Lander (maas-lander) wrote :
Download full text (18.8 KiB)

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://security.ubuntu.com trusty-security InRelease
Get:1 http://security.ubuntu.com trusty-security Release.gpg [933 B]
Ign http://nova.clouds.archive.ubuntu.com trusty InRelease
Get:2 http://security.ubuntu.com trusty-security Release [59.7 kB]
Ign http://nova.clouds.archive.ubuntu.com trusty-updates InRelease
Hit http://nova.clouds.archive.ubuntu.com trusty Release.gpg
Get:3 http://nova.clouds.archive.ubuntu.com trusty-updates Release.gpg [933 B]
Hit http://nova.clouds.archive.ubuntu.com trusty Release
Get:4 http://nova.clouds.archive.ubuntu.com trusty-updates Release [59.7 kB]
Get:5 http://security.ubuntu.com trusty-security/main Sources [43.8 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty/main Sources
Get:6 http://security.ubuntu.com trusty-security/universe Sources [10.8 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty/universe Sources
Get:7 http://security.ubuntu.com trusty-security/main amd64 Packages [139 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty/main amd64 Packages
Hit http://nova.clouds.archive.ubuntu.com trusty/universe amd64 Packages
Hit http://nova.clouds.archive.ubuntu.com trusty/main Translation-en
Hit http://nova.clouds.archive.ubuntu.com trusty/universe Translation-en
Get:8 http://security.ubuntu.com trusty-security/universe amd64 Packages [47.2 kB]
Hit http://security.ubuntu.com trusty-security/main Translation-en
Hit http://security.ubuntu.com trusty-security/universe Translation-en
Ign http://nova.clouds.archive.ubuntu.com trusty/main Translation-en_US
Ign http://nova.clouds.archive.ubuntu.com trusty/universe Translation-en_US
Get:9 http://nova.clouds.archive.ubuntu.com trusty-updates/main Sources [118 kB]
Get:10 http://nova.clouds.archive.ubuntu.com trusty-updates/universe Sources [83.7 kB]
Get:11 http://nova.clouds.archive.ubuntu.com trusty-updates/main amd64 Packages [316 kB]
Get:12 http://nova.clouds.archive.ubuntu.com trusty-updates/universe amd64 Packages [201 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty-updates/main Translation-en
Hit http://nova.clouds.archive.ubuntu.com trusty-updates/universe Translation-en
Fetched 1,080 kB in 0s (1,770 kB/s)
Reading package lists...
sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
     --no-install-recommends install apache2 authbind bind9 bind9utils build-essential bzr-builddeb curl daemontools debhelper dh-apport distro-info dnsutils firefox freeipmi-tools ipython isc-dhcp-common libjs-raphael libjs-yui3-full libjs-yui3-min libpq-dev make pep8 postgresql pyflakes python-amqplib python-bzrlib python-celery python-convoy python-crochet python-cssselect python-curtin python-dev python-distro-info python-django python-django-piston python-django-south python-djorm-ext-pgarray python-docutils python-extras python-fixtures python-flake8 python-formencode python-hivex python-httplib2 python-jinja2 python-jsonschema python-lockfile python-lxml python-mimeparse python-mock python-netaddr python-netifaces python-nose python-oauth python-oops python-oops-amqp python-oops-datedir-repo python-oops-twisted python-oops-wsgi pyt...

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== added directory 'src/maascli/actions'
=== added file 'src/maascli/actions/__init__.py'
=== added file 'src/maascli/actions/boot_resources_create.py'
--- src/maascli/actions/boot_resources_create.py 1970-01-01 00:00:00 +0000
+++ src/maascli/actions/boot_resources_create.py 2014-09-12 03:32:27 +0000
@@ -0,0 +1,186 @@
1# Copyright 2014 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""MAAS Boot Resources Action."""
5
6from __future__ import (
7 absolute_import,
8 print_function,
9 unicode_literals,
10 )
11
12str = None
13
14__metaclass__ = type
15__all__ = [
16 'action_class'
17 ]
18
19from cStringIO import StringIO
20import hashlib
21import json
22from urlparse import urljoin
23
24from apiclient.multipart import (
25 build_multipart_message,
26 encode_multipart_message,
27 )
28from maascli.api import (
29 Action,
30 http_request,
31 )
32from maascli.command import CommandError
33
34# Send 4MB of data per request.
35CHUNK_SIZE = 1 << 22
36
37
38class BootResourcesCreateAction(Action):
39 """Provides custom logic to the boot-resources create action.
40
41 Command: maas username boot-resources create
42
43 The create command has the ability to upload the content in pieces, using
44 the upload_uri that is returned in the response. This class provides the
45 logic to upload over that API.
46 """
47
48 def __call__(self, options):
49 # TODO: this is el-cheapo URI Template
50 # <http://tools.ietf.org/html/rfc6570> support; use uritemplate-py
51 # <https://github.com/uri-templates/uritemplate-py> here?
52 uri = self.uri.format(**vars(options))
53 content = self.initial_request(uri, options)
54
55 # Get the created resource file for the boot resource
56 rfile = self.get_resource_file(content)
57 if rfile is None:
58 print("Failed to identify created resource.")
59 raise CommandError(2)
60 if rfile['complete']:
61 # File already existed in the database, so no
62 # reason to upload it.
63 return
64
65 # Upload content
66 data = dict(options.data)
67 upload_uri = urljoin(uri, rfile['upload_uri'])
68 self.upload_content(
69 upload_uri, data['content'], insecure=options.insecure)
70
71 def initial_request(self, uri, options):
72 """Performs the initial POST request, to create the boot resource."""
73 # Bundle things up ready to throw over the wire.
74 body, headers = self.prepare_initial_payload(options.data)
75
76 # Headers are returned as a list, but they must be a dict for
77 # the signing machinery.
78 headers = dict(headers)
79
80 # Sign request if credentials have been provided.
81 if self.credentials is not None:
82 self.sign(uri, headers, self.credentials)
83
84 # Use httplib2 instead of urllib2 (or MAASDispatcher, which is based
85 # on urllib2) so that we get full control over HTTP method. TODO:
86 # create custom MAASDispatcher to use httplib2 so that MAASClient can
87 # be used.
88 response, content = http_request(
89 uri, self.method, body=body, headers=headers,
90 insecure=options.insecure)
91
92 # 2xx status codes are all okay.
93 if response.status // 100 != 2:
94 if options.debug:
95 self.print_debug(response)
96 self.print_response(response, content)
97 raise CommandError(2)
98 return content
99
100 def prepare_initial_payload(self, data):
101 """Return the body and headers for the initial request.
102
103 This is method is only used for the first request to MAAS. It
104 removes the passed content, and replaces it with the sha256 and size
105 of that content.
106
107 :param data: An iterable of ``name, value`` or ``name, opener``
108 tuples (see `name_value_pair`) to pack into the body or
109 query, depending on the type of request.
110 """
111 data = dict(data)
112 if 'content' not in data:
113 print('Missing content.')
114 raise CommandError(2)
115
116 content = data.pop('content')
117 size, sha256 = self.calculate_size_and_sha256(content)
118 data['size'] = '%s' % size
119 data['sha256'] = sha256
120
121 data = [(key, value) for key, value in data.items()]
122 message = build_multipart_message(data)
123 headers, body = encode_multipart_message(message)
124 return body, headers
125
126 def calculate_size_and_sha256(self, content):
127 """Return the size and sha256 of the content."""
128 size = 0
129 sha256 = hashlib.sha256()
130 with content() as fd:
131 while True:
132 buf = fd.read(CHUNK_SIZE)
133 length = len(buf)
134 size += length
135 sha256.update(buf)
136 if length != CHUNK_SIZE:
137 break
138 return size, sha256.hexdigest()
139
140 def get_resource_file(self, content):
141 """Return the boot resource file for the created file."""
142 data = json.loads(content)
143 if len(data['sets']) == 0:
144 # No sets returned, no way to get the resource file.
145 return None
146 newest_set = sorted(data['sets'].keys(), reverse=True)[0]
147 resource_set = data['sets'][newest_set]
148 if len(resource_set['files']) != 1:
149 # This api only supports uploading one file. If the set doesn't
150 # have just one file, then there is no way of knowing which file.
151 return None
152 _, rfile = resource_set['files'].popitem()
153 return rfile
154
155 def put_upload(self, upload_uri, data, insecure=False):
156 """Send PUT method to upload data."""
157 headers = {
158 'Content-Type': 'application/octet-stream',
159 'Content-Length': '%s' % len(data),
160 }
161 if self.credentials is not None:
162 self.sign(upload_uri, headers, self.credentials)
163 # httplib2 expects the body to be file-like if its binary data
164 data = StringIO(data)
165 response, content = http_request(
166 upload_uri, 'PUT', body=data, headers=headers,
167 insecure=insecure)
168 if response.status != 200:
169 self.print_response(response, content)
170 raise CommandError(2)
171
172 def upload_content(self, upload_uri, content, insecure=False):
173 """Upload the content in chunks."""
174 with content() as fd:
175 while True:
176 buf = fd.read(CHUNK_SIZE)
177 length = len(buf)
178 if length > 0:
179 self.put_upload(upload_uri, buf, insecure=insecure)
180 if length != CHUNK_SIZE:
181 break
182
183
184# Each action sets this variable so the class can be picked up
185# by get_action_class.
186action_class = BootResourcesCreateAction
0187
=== added directory 'src/maascli/actions/tests'
=== added file 'src/maascli/actions/tests/__init__.py'
=== added file 'src/maascli/actions/tests/test_boot_resources_create.py'
--- src/maascli/actions/tests/test_boot_resources_create.py 1970-01-01 00:00:00 +0000
+++ src/maascli/actions/tests/test_boot_resources_create.py 2014-09-12 03:32:27 +0000
@@ -0,0 +1,207 @@
1# Copyright 2012-2014 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Test for boot-resources create action."""
5
6from __future__ import (
7 absolute_import,
8 print_function,
9 unicode_literals,
10 )
11
12str = None
13
14__metaclass__ = type
15__all__ = []
16
17from functools import partial
18import hashlib
19import json
20import os
21from random import randint
22
23from apiclient.testing.credentials import make_api_credentials
24import httplib2
25from maascli.actions import boot_resources_create
26from maascli.actions.boot_resources_create import (
27 BootResourcesCreateAction,
28 CHUNK_SIZE,
29 )
30from maascli.command import CommandError
31from maastesting.factory import factory
32from maastesting.fixtures import TempDirectory
33from maastesting.matchers import MockCalledOnceWith
34from maastesting.testcase import MAASTestCase
35from mock import (
36 ANY,
37 Mock,
38 sentinel,
39 )
40
41
42class TestBootResourcesCreateAction(MAASTestCase):
43 """Tests for `BootResourcesCreateAction`."""
44
45 def configure_http_request(self, status, content):
46 response = httplib2.Response({'status': status})
47 self.patch(
48 boot_resources_create,
49 'http_request').return_value = (response, content)
50
51 def make_boot_resources_create_action(self):
52 action_name = "create".encode("ascii")
53 action_bases = (BootResourcesCreateAction,)
54 action_ns = {
55 "action": {'method': 'POST'},
56 "handler": {'uri': '/api/1.0/boot-resources/', 'params': []},
57 "profile": {'credentials': make_api_credentials()}
58 }
59 action_class = type(action_name, action_bases, action_ns)
60 action = action_class(Mock())
61 self.patch(action, 'print_debug')
62 self.patch(action, 'print_response')
63 return action
64
65 def make_content(self, size=None):
66 tmpdir = self.useFixture(TempDirectory()).path
67 if size is None:
68 size = randint(1024, 2048)
69 data = factory.make_bytes(size)
70 content_path = os.path.join(tmpdir, 'content')
71 with open(content_path, 'wb') as stream:
72 stream.write(data)
73 sha256 = hashlib.sha256()
74 sha256.update(data)
75 return size, sha256.hexdigest(), partial(open, content_path, "rb")
76
77 def test_initial_request_returns_content(self):
78 content = factory.make_name('content')
79 self.configure_http_request(200, content)
80 action = self.make_boot_resources_create_action()
81 self.patch(action, 'prepare_initial_payload').return_value = ("", {})
82 self.assertEqual(
83 content, action.initial_request(sentinel.uri, Mock()))
84
85 def test_initial_request_raises_CommandError_on_error(self):
86 self.configure_http_request(500, factory.make_name('content'))
87 action = self.make_boot_resources_create_action()
88 self.patch(action, 'prepare_initial_payload').return_value = ("", {})
89 self.assertRaises(
90 CommandError,
91 action.initial_request, sentinel.uri, Mock())
92
93 def test_prepare_initial_payload_raises_CommandError_missing_content(self):
94 action = self.make_boot_resources_create_action()
95 self.patch(boot_resources_create, 'print')
96 self.assertRaises(
97 CommandError,
98 action.prepare_initial_payload, [('invalid', '')])
99
100 def test_prepare_initial_payload_adds_size_and_sha256(self):
101 size, sha256, stream = self.make_content()
102 action = self.make_boot_resources_create_action()
103 mock_build_message = self.patch(
104 boot_resources_create,
105 'build_multipart_message')
106 self.patch(
107 boot_resources_create,
108 'encode_multipart_message').return_value = (None, None)
109 action.prepare_initial_payload([('content', stream)])
110 self.assertThat(
111 mock_build_message,
112 MockCalledOnceWith([('sha256', sha256), ('size', '%s' % size)]))
113
114 def test_get_resource_file_returns_None_when_no_sets(self):
115 content = {
116 'sets': {}
117 }
118 action = self.make_boot_resources_create_action()
119 self.assertIsNone(action.get_resource_file(json.dumps(content)))
120
121 def test_get_resource_file_returns_None_when_no_files(self):
122 content = {
123 'sets': {
124 '20140910': {
125 'files': {}
126 },
127 },
128 }
129 action = self.make_boot_resources_create_action()
130 self.assertIsNone(action.get_resource_file(json.dumps(content)))
131
132 def test_get_resource_file_returns_None_when_more_than_one_file(self):
133 content = {
134 'sets': {
135 '20140910': {
136 'files': {
137 'root-image.gz': {},
138 'root-tgz': {},
139 }
140 },
141 },
142 }
143 action = self.make_boot_resources_create_action()
144 self.assertIsNone(action.get_resource_file(json.dumps(content)))
145
146 def test_get_resource_file_returns_file_from_newest_set(self):
147 filename = factory.make_name('file')
148 content = {
149 'sets': {
150 '20140910': {
151 'files': {
152 filename: {
153 'name': filename,
154 },
155 },
156 },
157 '20140909': {
158 'files': {
159 'other': {
160 'name': 'other',
161 },
162 },
163 },
164 },
165 }
166 action = self.make_boot_resources_create_action()
167 self.assertEqual(
168 {'name': filename},
169 action.get_resource_file(json.dumps(content)))
170
171 def test_put_upload_raise_CommandError_if_status_not_200(self):
172 self.configure_http_request(500, '')
173 action = self.make_boot_resources_create_action()
174 self.assertRaises(
175 CommandError,
176 action.put_upload, sentinel.upload_uri, '')
177
178 def test_put_upload_sends_content_type_and_length_headers(self):
179 response = httplib2.Response({'status': 200})
180 mock_request = self.patch(boot_resources_create, 'http_request')
181 mock_request.return_value = (response, '')
182 action = self.make_boot_resources_create_action()
183 self.patch(action, 'sign')
184 data = factory.make_bytes()
185 action.put_upload(sentinel.upload_uri, data)
186 headers = {
187 'Content-Type': 'application/octet-stream',
188 'Content-Length': '%s' % len(data),
189 }
190 self.assertThat(
191 mock_request,
192 MockCalledOnceWith(
193 sentinel.upload_uri, 'PUT', body=ANY,
194 headers=headers, insecure=False))
195
196 def test_upload_content_calls_put_upload_with_sizeof_CHUNK_SIZE(self):
197 size = CHUNK_SIZE * 2
198 size, sha256, stream = self.make_content(size=size)
199 action = self.make_boot_resources_create_action()
200 mock_upload = self.patch(action, 'put_upload')
201 action.upload_content(sentinel.upload_uri, stream)
202
203 call_data_sizes = [
204 len(call[0][1])
205 for call in mock_upload.call_args_list
206 ]
207 self.assertEqual([CHUNK_SIZE, CHUNK_SIZE], call_data_sizes)
0208
=== modified file 'src/maascli/api.py'
--- src/maascli/api.py 2014-08-13 21:49:35 +0000
+++ src/maascli/api.py 2014-09-12 03:32:27 +0000
@@ -53,6 +53,7 @@
53 handler_command_name,53 handler_command_name,
54 parse_docstring,54 parse_docstring,
55 safe_name,55 safe_name,
56 try_import_module,
56 )57 )
5758
5859
@@ -402,12 +403,32 @@
402 sys.exit(0)403 sys.exit(0)
403404
404405
406def get_action_class(handler, action):
407 """Return custom action handler class."""
408 handler_name = handler_command_name(handler["name"]).replace('-', '_')
409 action_name = '%s_%s' % (
410 handler_name,
411 safe_name(action["name"]).replace('-', '_'))
412 action_module = try_import_module('maascli.actions.%s' % action_name)
413 if action_module is not None:
414 return action_module.action_class
415 return None
416
417
418def get_action_class_bases(handler, action):
419 """Return the base classes for the dynamic class."""
420 action_class = get_action_class(handler, action)
421 if action_class is not None:
422 return (action_class,)
423 return (Action,)
424
425
405def register_actions(profile, handler, parser):426def register_actions(profile, handler, parser):
406 """Register a handler's actions."""427 """Register a handler's actions."""
407 for action in handler["actions"]:428 for action in handler["actions"]:
408 help_title, help_body = parse_docstring(action["doc"])429 help_title, help_body = parse_docstring(action["doc"])
409 action_name = safe_name(action["name"]).encode("ascii")430 action_name = safe_name(action["name"]).encode("ascii")
410 action_bases = (Action,)431 action_bases = get_action_class_bases(handler, action)
411 action_ns = {432 action_ns = {
412 "action": action,433 "action": action,
413 "handler": handler,434 "handler": handler,
414435
=== modified file 'src/maascli/tests/test_api.py'
--- src/maascli/tests/test_api.py 2014-08-13 21:49:35 +0000
+++ src/maascli/tests/test_api.py 2014-09-12 03:32:27 +0000
@@ -24,6 +24,7 @@
2424
25import httplib225import httplib2
26from maascli import api26from maascli import api
27from maascli.actions.boot_resources_create import BootResourcesCreateAction
27from maascli.command import CommandError28from maascli.command import CommandError
28from maascli.config import ProfileConfig29from maascli.config import ProfileConfig
29from maascli.parser import ArgumentParser30from maascli.parser import ArgumentParser
@@ -179,6 +180,34 @@
179 "disable the certificate check.")180 "disable the certificate check.")
180 self.assertEqual(error_expected, "%s" % error)181 self.assertEqual(error_expected, "%s" % error)
181182
183 def test_get_action_class_returns_None_for_unknown_handler(self):
184 handler = {'name': factory.make_name('handler')}
185 action = {'name': 'create'}
186 self.assertIsNone(api.get_action_class(handler, action))
187
188 def test_get_action_class_returns_BootResourcesCreateAction_class(self):
189 # Test uses BootResourcesCreateAction as its know to exist.
190 handler = {'name': 'BootResourcesHandler'}
191 action = {'name': 'create'}
192 self.assertEqual(
193 BootResourcesCreateAction,
194 api.get_action_class(handler, action))
195
196 def test_get_action_class_bases_returns_Action(self):
197 handler = {'name': factory.make_name('handler')}
198 action = {'name': 'create'}
199 self.assertEqual(
200 (api.Action,),
201 api.get_action_class_bases(handler, action))
202
203 def test_get_action_class_bases_returns_BootResourcesCreateAction(self):
204 # Test uses BootResourcesCreateAction as its know to exist.
205 handler = {'name': 'BootResourcesHandler'}
206 action = {'name': 'create'}
207 self.assertEqual(
208 (BootResourcesCreateAction,),
209 api.get_action_class_bases(handler, action))
210
182211
183class TestIsResponseTextual(MAASTestCase):212class TestIsResponseTextual(MAASTestCase):
184 """Tests for `is_response_textual`."""213 """Tests for `is_response_textual`."""
185214
=== modified file 'src/maascli/utils.py'
--- src/maascli/utils.py 2013-10-07 09:31:09 +0000
+++ src/maascli/utils.py 2014-09-12 03:32:27 +0000
@@ -25,6 +25,7 @@
25 getdoc,25 getdoc,
26 )26 )
27import re27import re
28import sys
28from urlparse import urlparse29from urlparse import urlparse
2930
3031
@@ -107,3 +108,17 @@
107 if re.search("/api/[0-9.]+/?$", url.path) is None:108 if re.search("/api/[0-9.]+/?$", url.path) is None:
108 url = url._replace(path=url.path + "api/1.0/")109 url = url._replace(path=url.path + "api/1.0/")
109 return url.geturl()110 return url.geturl()
111
112
113def import_module(import_str):
114 """Import a module."""
115 __import__(import_str)
116 return sys.modules[import_str]
117
118
119def try_import_module(import_str, default=None):
120 """Try to import a module."""
121 try:
122 return import_module(import_str)
123 except ImportError:
124 return default
110125
=== modified file 'src/maasserver/api/boot_resources.py'
--- src/maasserver/api/boot_resources.py 2014-09-03 01:05:32 +0000
+++ src/maasserver/api/boot_resources.py 2014-09-12 03:32:27 +0000
@@ -273,12 +273,14 @@
273273
274 read = create = delete = None274 read = create = delete = None
275275
276 hidden = True
277
276 @admin_method278 @admin_method
277 def update(self, request, id, file_id):279 def update(self, request, id, file_id):
278 """Upload piece of boot resource file."""280 """Upload piece of boot resource file."""
279 resource = get_object_or_404(BootResource, id=id)281 resource = get_object_or_404(BootResource, id=id)
280 rfile = get_object_or_404(BootResourceFile, id=file_id)282 rfile = get_object_or_404(BootResourceFile, id=file_id)
281 size = request.META.get('CONTENT_LENGTH', 0)283 size = int(request.META.get('CONTENT_LENGTH', '0'))
282 data = request.body284 data = request.body
283 if size == 0:285 if size == 0:
284 raise MAASAPIBadRequest("Missing data.")286 raise MAASAPIBadRequest("Missing data.")
285287
=== modified file 'src/maasserver/api/boot_source_selections.py'
--- src/maasserver/api/boot_source_selections.py 2014-08-22 15:21:48 +0000
+++ src/maasserver/api/boot_source_selections.py 2014-09-12 03:32:27 +0000
@@ -101,7 +101,7 @@
101 be set globally for the whole region and clusters. This api is now101 be set globally for the whole region and clusters. This api is now
102 deprecated, and only exists for backwards compatibility.102 deprecated, and only exists for backwards compatibility.
103 """103 """
104 deprecated = True104 hidden = True
105105
106 def read(self, request, uuid, boot_source_id, id):106 def read(self, request, uuid, boot_source_id, id):
107 """Read a boot source selection."""107 """Read a boot source selection."""
@@ -175,7 +175,7 @@
175 be set globally for the whole region and clusters. This api is now175 be set globally for the whole region and clusters. This api is now
176 deprecated, and only exists for backwards compatibility.176 deprecated, and only exists for backwards compatibility.
177 """177 """
178 deprecated = True178 hidden = True
179179
180 def read(self, request, uuid, boot_source_id):180 def read(self, request, uuid, boot_source_id):
181 """List boot source selections.181 """List boot source selections.
182182
=== modified file 'src/maasserver/api/boot_sources.py'
--- src/maasserver/api/boot_sources.py 2014-08-22 15:21:48 +0000
+++ src/maasserver/api/boot_sources.py 2014-09-12 03:32:27 +0000
@@ -119,7 +119,7 @@
119 be set globally for the whole region and clusters. This api is now119 be set globally for the whole region and clusters. This api is now
120 deprecated, and only exists for backwards compatibility.120 deprecated, and only exists for backwards compatibility.
121 """121 """
122 deprecated = True122 hidden = True
123123
124 def read(self, request, uuid, id):124 def read(self, request, uuid, id):
125 """Read a boot source."""125 """Read a boot source."""
@@ -186,7 +186,7 @@
186 be set globally for the whole region and clusters. This api is now186 be set globally for the whole region and clusters. This api is now
187 deprecated, and only exists for backwards compatibility.187 deprecated, and only exists for backwards compatibility.
188 """188 """
189 deprecated = True189 hidden = True
190190
191 def read(self, request, uuid):191 def read(self, request, uuid):
192 """List boot sources.192 """List boot sources.
193193
=== modified file 'src/maasserver/api/doc.py'
--- src/maasserver/api/doc.py 2014-08-22 15:21:48 +0000
+++ src/maasserver/api/doc.py 2014-09-12 03:32:27 +0000
@@ -43,12 +43,14 @@
43 Handlers are of type :class:`HandlerMetaClass`, and must define a43 Handlers are of type :class:`HandlerMetaClass`, and must define a
44 `resource_uri` method.44 `resource_uri` method.
4545
46 Handlers that have the attribute hidden set to True, will not be returned.
47
46 :rtype: Generator, yielding handlers.48 :rtype: Generator, yielding handlers.
47 """49 """
48 p_has_resource_uri = lambda resource: (50 p_has_resource_uri = lambda resource: (
49 getattr(resource.handler, "resource_uri", None) is not None)51 getattr(resource.handler, "resource_uri", None) is not None)
50 p_is_not_deprecated = lambda resource: (52 p_is_not_hidden = lambda resource: (
51 getattr(resource.handler, "deprecated", False))53 getattr(resource.handler, "hidden", False))
52 for pattern in resolver.url_patterns:54 for pattern in resolver.url_patterns:
53 if isinstance(pattern, RegexURLResolver):55 if isinstance(pattern, RegexURLResolver):
54 accumulate_api_resources(pattern, accumulator)56 accumulate_api_resources(pattern, accumulator)
@@ -56,7 +58,7 @@
56 if isinstance(pattern.callback, Resource):58 if isinstance(pattern.callback, Resource):
57 resource = pattern.callback59 resource = pattern.callback
58 if p_has_resource_uri(resource) and \60 if p_has_resource_uri(resource) and \
59 not p_is_not_deprecated(resource):61 not p_is_not_hidden(resource):
60 accumulator.add(resource)62 accumulator.add(resource)
61 else:63 else:
62 raise AssertionError(64 raise AssertionError(
6365
=== modified file 'src/maasserver/api/tests/test_doc.py'
--- src/maasserver/api/tests/test_doc.py 2014-08-20 23:54:33 +0000
+++ src/maasserver/api/tests/test_doc.py 2014-09-12 03:32:27 +0000
@@ -88,6 +88,18 @@
88 module.urlpatterns = patterns("", url("^metal", resource))88 module.urlpatterns = patterns("", url("^metal", resource))
89 self.assertSetEqual({resource}, find_api_resources(module))89 self.assertSetEqual({resource}, find_api_resources(module))
9090
91 def test_urlpatterns_with_resource_hidden(self):
92 # Resources for handlers with resource_uri attributes are discovered
93 # in a urlconf module and returned, unless hidden = True.
94 handler = type(b"\m/", (BaseHandler,), {
95 "resource_uri": True,
96 "hidden": True,
97 })
98 resource = Resource(handler)
99 module = self.make_module()
100 module.urlpatterns = patterns("", url("^metal", resource))
101 self.assertSetEqual(set(), find_api_resources(module))
102
91 def test_nested_urlpatterns_with_handler(self):103 def test_nested_urlpatterns_with_handler(self):
92 # Resources are found in nested urlconfs.104 # Resources are found in nested urlconfs.
93 handler = type(b"\m/", (BaseHandler,), {"resource_uri": True})105 handler = type(b"\m/", (BaseHandler,), {"resource_uri": True})
94106
=== modified file 'src/maasserver/models/bootresourcefile.py'
--- src/maasserver/models/bootresourcefile.py 2014-08-31 02:47:48 +0000
+++ src/maasserver/models/bootresourcefile.py 2014-09-12 03:32:27 +0000
@@ -20,6 +20,8 @@
20 CharField,20 CharField,
21 ForeignKey,21 ForeignKey,
22 )22 )
23from django.db.models.signals import post_delete
24from django.dispatch import receiver
23from maasserver import DefaultMeta25from maasserver import DefaultMeta
24from maasserver.enum import (26from maasserver.enum import (
25 BOOT_RESOURCE_FILE_TYPE,27 BOOT_RESOURCE_FILE_TYPE,
@@ -70,3 +72,16 @@
7072
71 def __repr__(self):73 def __repr__(self):
72 return "<BootResourceFile %s/%s>" % (self.filename, self.filetype)74 return "<BootResourceFile %s/%s>" % (self.filename, self.filetype)
75
76
77@receiver(post_delete)
78def delete_large_file(sender, instance, **kwargs):
79 """Call delete on the LargeFile, now that the relation has been removed.
80 If this was the only resource file referencing this LargeFile then it will
81 be delete.
82
83 This is done using the `post_delete` signal because only then has the
84 relation been removed.
85 """
86 if sender == BootResourceFile:
87 instance.largefile.delete()