Merge lp:~cjwatson/launchpad/better-bzr-mp-diffs into lp:launchpad
- better-bzr-mp-diffs
- Merge into devel
Proposed by
Colin Watson
Status: | Merged |
---|---|
Merged at revision: | 18707 |
Proposed branch: | lp:~cjwatson/launchpad/better-bzr-mp-diffs |
Merge into: | lp:launchpad |
Prerequisite: | lp:~cjwatson/launchpad/branch-hosting-client |
Diff against target: |
506 lines (+230/-34) 11 files modified
lib/lp/code/browser/branch.py (+48/-0) lib/lp/code/browser/branchmergeproposal.py (+9/-12) lib/lp/code/browser/configure.zcml (+5/-1) lib/lp/code/browser/gitrepository.py (+12/-10) lib/lp/code/browser/tests/test_branch.py (+72/-0) lib/lp/code/browser/tests/test_branchmergeproposal.py (+19/-2) lib/lp/code/browser/tests/test_gitrepository.py (+18/-2) lib/lp/code/interfaces/branch.py (+10/-1) lib/lp/code/model/branch.py (+6/-0) lib/lp/code/tests/helpers.py (+16/-1) lib/lp/services/webapp/publisher.py (+15/-5) |
To merge this branch: | bzr merge lp:~cjwatson/launchpad/better-bzr-mp-diffs |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
William Grant (community) | code | Approve | |
Review via email: mp+345705@code.launchpad.net |
Commit message
Proxy loggerhead branch diffs through the webapp, allowing AJAX MP revision diffs to work for private branches.
Description of the change
This is the same approach as we already use for Git, built on top of my recent work to expose a suitable loggerhead API on a private port.
To post a comment you must log in.
Revision history for this message
William Grant (wgrant) : | # |
review:
Approve
(code)
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'lib/lp/code/browser/branch.py' |
2 | --- lib/lp/code/browser/branch.py 2018-03-16 21:20:00 +0000 |
3 | +++ lib/lp/code/browser/branch.py 2018-06-22 10:03:28 +0000 |
4 | @@ -46,6 +46,7 @@ |
5 | providedBy, |
6 | ) |
7 | from zope.publisher.interfaces import NotFound |
8 | +from zope.publisher.interfaces.browser import IBrowserPublisher |
9 | from zope.schema import ( |
10 | Bool, |
11 | Choice, |
12 | @@ -136,6 +137,7 @@ |
13 | from lp.services.webapp.breadcrumb import NameBreadcrumb |
14 | from lp.services.webapp.escaping import structured |
15 | from lp.services.webapp.interfaces import ICanonicalUrlData |
16 | +from lp.services.webapp.publisher import DataDownloadView |
17 | from lp.services.webhooks.browser import WebhookTargetNavigationMixin |
18 | from lp.snappy.browser.hassnaps import ( |
19 | HasSnapsMenuMixin, |
20 | @@ -203,6 +205,22 @@ |
21 | return None |
22 | return self.context.getMergeProposalByID(id) |
23 | |
24 | + @stepto("+diff") |
25 | + def traverse_diff(self): |
26 | + segments = list(self.request.getTraversalStack()) |
27 | + if len(segments) == 1: |
28 | + new = segments.pop() |
29 | + old = None |
30 | + self.request.stepstogo.consume() |
31 | + elif len(segments) == 2: |
32 | + new = segments.pop() |
33 | + old = segments.pop() |
34 | + self.request.stepstogo.consume() |
35 | + self.request.stepstogo.consume() |
36 | + else: |
37 | + return None |
38 | + return BranchDiffView(self.context, self.request, new, old=old) |
39 | + |
40 | @stepto("+code-import") |
41 | def traverse_code_import(self): |
42 | """Traverses to the `ICodeImport` for the branch.""" |
43 | @@ -1280,3 +1298,33 @@ |
44 | 'target_branch', |
45 | "This branch is not mergeable into %s." % |
46 | target_branch.bzr_identity) |
47 | + |
48 | + |
49 | +@implementer(IBrowserPublisher) |
50 | +class BranchDiffView(DataDownloadView): |
51 | + |
52 | + content_type = "text/x-patch" |
53 | + |
54 | + def __init__(self, context, request, new, old=None): |
55 | + super(BranchDiffView, self).__init__(context, request) |
56 | + self.new = new |
57 | + self.old = old |
58 | + |
59 | + def __call__(self): |
60 | + if getFeatureFlag(u"code.bzr.diff.disable_proxy"): |
61 | + self.request.response.setStatus(401) |
62 | + return "Proxying of branch diffs is disabled.\n" |
63 | + return super(BranchDiffView, self).__call__() |
64 | + |
65 | + @property |
66 | + def filename(self): |
67 | + if self.old is None: |
68 | + return "%s_%s.diff" % (self.context.name, self.new) |
69 | + else: |
70 | + return "%s_%s_%s.diff" % (self.context.name, self.old, self.new) |
71 | + |
72 | + def getBody(self): |
73 | + return self.context.getDiff(self.new, old=self.old) |
74 | + |
75 | + def browserDefault(self, request): |
76 | + return self, () |
77 | |
78 | === modified file 'lib/lp/code/browser/branchmergeproposal.py' |
79 | --- lib/lp/code/browser/branchmergeproposal.py 2018-04-25 12:01:33 +0000 |
80 | +++ lib/lp/code/browser/branchmergeproposal.py 2018-06-22 10:03:28 +0000 |
81 | @@ -617,19 +617,16 @@ |
82 | super(BranchMergeProposalView, self).initialize() |
83 | cache = IJSONRequestCache(self.request) |
84 | cache.objects['branch_name'] = self.context.merge_source.name |
85 | - if IBranch.providedBy(self.context.merge_source): |
86 | - cache.objects.update({ |
87 | - 'branch_diff_link': |
88 | - 'https://%s/+loggerhead/%s/diff/' % ( |
89 | - config.launchpad.code_domain, |
90 | - self.context.source_branch.unique_name), |
91 | - }) |
92 | + if (IBranch.providedBy(self.context.merge_source) and |
93 | + getFeatureFlag("code.bzr.diff.disable_proxy")): |
94 | + # This fallback works for public branches, but not private ones. |
95 | + cache.objects['branch_diff_link'] = ( |
96 | + 'https://%s/+loggerhead/%s/diff/' % ( |
97 | + config.launchpad.code_domain, |
98 | + self.context.source_branch.unique_name)) |
99 | else: |
100 | - cache.objects.update({ |
101 | - 'branch_diff_link': |
102 | - canonical_url(self.context.source_git_repository) + |
103 | - '/+diff/', |
104 | - }) |
105 | + cache.objects['branch_diff_link'] = ( |
106 | + canonical_url(self.context.parent) + '/+diff/') |
107 | if getFeatureFlag("longpoll.merge_proposals.enabled"): |
108 | cache.objects['merge_proposal_event_key'] = subscribe( |
109 | self.context).event_key |
110 | |
111 | === modified file 'lib/lp/code/browser/configure.zcml' |
112 | --- lib/lp/code/browser/configure.zcml 2017-09-27 13:50:02 +0000 |
113 | +++ lib/lp/code/browser/configure.zcml 2018-06-22 10:03:28 +0000 |
114 | @@ -1,4 +1,4 @@ |
115 | -<!-- Copyright 2009-2017 Canonical Ltd. This software is licensed under the |
116 | +<!-- Copyright 2009-2018 Canonical Ltd. This software is licensed under the |
117 | GNU Affero General Public License version 3 (see the file LICENSE). |
118 | --> |
119 | |
120 | @@ -442,6 +442,10 @@ |
121 | class="lp.code.browser.codeimport.CodeImportEditView" |
122 | permission="launchpad.Edit" |
123 | template="../../app/templates/generic-edit.pt"/> |
124 | + <class class="lp.code.browser.branch.BranchDiffView"> |
125 | + <allow interface="zope.publisher.interfaces.browser.IBrowserPublisher" |
126 | + attributes="__call__"/> |
127 | + </class> |
128 | <browser:page |
129 | name="+delete" |
130 | for="lp.code.interfaces.branch.IBranch" |
131 | |
132 | === modified file 'lib/lp/code/browser/gitrepository.py' |
133 | --- lib/lp/code/browser/gitrepository.py 2016-10-15 01:14:32 +0000 |
134 | +++ lib/lp/code/browser/gitrepository.py 2018-06-22 10:03:28 +0000 |
135 | @@ -97,6 +97,7 @@ |
136 | from lp.services.webapp.breadcrumb import Breadcrumb |
137 | from lp.services.webapp.escaping import structured |
138 | from lp.services.webapp.interfaces import ICanonicalUrlData |
139 | +from lp.services.webapp.publisher import DataDownloadView |
140 | from lp.services.webhooks.browser import WebhookTargetNavigationMixin |
141 | from lp.snappy.browser.hassnaps import HasSnapsViewMixin |
142 | |
143 | @@ -638,23 +639,24 @@ |
144 | |
145 | |
146 | @implementer(IBrowserPublisher) |
147 | -class GitRepositoryDiffView: |
148 | +class GitRepositoryDiffView(DataDownloadView): |
149 | + |
150 | + content_type = "text/x-patch" |
151 | + charset = "UTF-8" |
152 | |
153 | def __init__(self, context, request, old, new): |
154 | + super(GitRepositoryDiffView, self).__init__(context, request) |
155 | self.context = context |
156 | self.request = request |
157 | self.old = old |
158 | self.new = new |
159 | |
160 | - def __call__(self): |
161 | - content = self.context.getDiff(self.old, self.new) |
162 | - filename = "%s_%s.diff" % (self.old, self.new) |
163 | - self.request.response.setHeader( |
164 | - "Content-Type", 'text/x-patch; charset="UTF-8"') |
165 | - self.request.response.setHeader("Content-Length", str(len(content))) |
166 | - self.request.response.setHeader( |
167 | - "Content-Disposition", "attachment; filename=%s" % filename) |
168 | - return content |
169 | + @property |
170 | + def filename(self): |
171 | + return "%s_%s_%s.diff" % (self.context.name, self.old, self.new) |
172 | + |
173 | + def getBody(self): |
174 | + return self.context.getDiff(self.old, self.new) |
175 | |
176 | def browserDefault(self, request): |
177 | return self, () |
178 | |
179 | === modified file 'lib/lp/code/browser/tests/test_branch.py' |
180 | --- lib/lp/code/browser/tests/test_branch.py 2018-02-01 18:38:24 +0000 |
181 | +++ lib/lp/code/browser/tests/test_branch.py 2018-06-22 10:03:28 +0000 |
182 | @@ -12,10 +12,12 @@ |
183 | |
184 | from fixtures import FakeLogger |
185 | import pytz |
186 | +from six.moves.urllib_error import HTTPError |
187 | from storm.store import Store |
188 | from testtools.matchers import Equals |
189 | from zope.component import getUtility |
190 | from zope.publisher.interfaces import NotFound |
191 | +from zope.security.interfaces import Unauthorized |
192 | from zope.security.proxy import removeSecurityProxy |
193 | |
194 | from lp.app.enums import InformationType |
195 | @@ -32,12 +34,14 @@ |
196 | RepositoryFormat, |
197 | ) |
198 | from lp.code.enums import BranchType |
199 | +from lp.code.tests.helpers import BranchHostingFixture |
200 | from lp.registry.enums import BranchSharingPolicy |
201 | from lp.registry.interfaces.accesspolicy import IAccessPolicySource |
202 | from lp.registry.interfaces.person import PersonVisibility |
203 | from lp.services.beautifulsoup import BeautifulSoup |
204 | from lp.services.config import config |
205 | from lp.services.database.constants import UTC_NOW |
206 | +from lp.services.features.testing import FeatureFixture |
207 | from lp.services.helpers import truncate_text |
208 | from lp.services.webapp.publisher import canonical_url |
209 | from lp.services.webapp.servers import LaunchpadTestRequest |
210 | @@ -1220,3 +1224,71 @@ |
211 | InformationType.USERDATA.description, description.renderContents()) |
212 | self.assertIsNotNone( |
213 | soup.find('a', id='privacy-link', attrs={'href': edit_url})) |
214 | + |
215 | + |
216 | +class TestBranchDiffView(BrowserTestCase): |
217 | + |
218 | + layer = DatabaseFunctionalLayer |
219 | + |
220 | + def test_feature_disabled(self): |
221 | + self.useFixture(FeatureFixture({"code.bzr.diff.disable_proxy": "on"})) |
222 | + hosting_fixture = self.useFixture(BranchHostingFixture()) |
223 | + person = self.factory.makePerson() |
224 | + branch = self.factory.makeBranch(owner=person) |
225 | + e = self.assertRaises( |
226 | + HTTPError, self.getUserBrowser, |
227 | + canonical_url(branch) + "/+diff/2/1") |
228 | + self.assertEqual(401, e.code) |
229 | + self.assertEqual("Proxying of branch diffs is disabled.\n", e.read()) |
230 | + self.assertEqual([], hosting_fixture.getDiff.calls) |
231 | + |
232 | + def test_render(self): |
233 | + diff = b"A fake diff\n" |
234 | + hosting_fixture = self.useFixture(BranchHostingFixture(diff=diff)) |
235 | + person = self.factory.makePerson() |
236 | + branch = self.factory.makeBranch(owner=person, name="some-branch") |
237 | + browser = self.getUserBrowser( |
238 | + canonical_url(branch) + "/+diff/2/1") |
239 | + with person_logged_in(person): |
240 | + self.assertEqual( |
241 | + [((branch.id, "2"), {"old": "1"})], |
242 | + hosting_fixture.getDiff.calls) |
243 | + self.assertEqual("text/x-patch", browser.headers["Content-Type"]) |
244 | + self.assertEqual(str(len(diff)), browser.headers["Content-Length"]) |
245 | + self.assertEqual( |
246 | + 'attachment; filename="some-branch_1_2.diff"', |
247 | + browser.headers["Content-Disposition"]) |
248 | + self.assertEqual(diff, browser.contents) |
249 | + |
250 | + def test_security(self): |
251 | + # A user who can see a private branch can fetch diffs from it, but |
252 | + # other users cannot. |
253 | + diff = b"A fake diff\n" |
254 | + self.useFixture(BranchHostingFixture(diff=diff)) |
255 | + person = self.factory.makePerson() |
256 | + project = self.factory.makeProduct( |
257 | + owner=person, information_type=InformationType.PROPRIETARY) |
258 | + with person_logged_in(person): |
259 | + branch = self.factory.makeBranch( |
260 | + owner=person, product=project, |
261 | + information_type=InformationType.PROPRIETARY) |
262 | + branch_url = canonical_url(branch) |
263 | + browser = self.getUserBrowser(branch_url + "/+diff/2/1", user=person) |
264 | + self.assertEqual(diff, browser.contents) |
265 | + self.useFixture(FakeLogger()) |
266 | + self.assertRaises( |
267 | + Unauthorized, self.getUserBrowser, branch_url + "/+diff/2/1") |
268 | + |
269 | + def test_filename_quoting(self): |
270 | + # If we construct revisions containing metacharacters and somehow |
271 | + # manage to get that past the hosting service, the Content-Disposition |
272 | + # header is quoted properly. |
273 | + diff = b"A fake diff\n" |
274 | + self.useFixture(BranchHostingFixture(diff=diff)) |
275 | + person = self.factory.makePerson() |
276 | + branch = self.factory.makeBranch(owner=person, name="some-branch") |
277 | + browser = self.getUserBrowser( |
278 | + canonical_url(branch) + '/+diff/foo"/"bar') |
279 | + self.assertEqual( |
280 | + r'attachment; filename="some-branch_\"bar_foo\".diff"', |
281 | + browser.headers["Content-Disposition"]) |
282 | |
283 | === modified file 'lib/lp/code/browser/tests/test_branchmergeproposal.py' |
284 | --- lib/lp/code/browser/tests/test_branchmergeproposal.py 2018-04-25 12:01:33 +0000 |
285 | +++ lib/lp/code/browser/tests/test_branchmergeproposal.py 2018-06-22 10:03:28 +0000 |
286 | @@ -89,6 +89,7 @@ |
287 | PersonVisibility, |
288 | TeamMembershipPolicy, |
289 | ) |
290 | +from lp.services.features.testing import FeatureFixture |
291 | from lp.services.librarian.interfaces.client import LibrarianServerError |
292 | from lp.services.messages.model.message import MessageSet |
293 | from lp.services.timeout import TimeoutError |
294 | @@ -1634,8 +1635,24 @@ |
295 | "on 2015-01-01" % sha1, extract_text(tag)) |
296 | |
297 | def test_client_cache_bzr(self): |
298 | - # For Bazaar, the client cache contains the branch name and a |
299 | - # loggerhead-based diff link. |
300 | + # For Bazaar, the client cache contains the branch name and a link |
301 | + # to the Loggerhead diff via the webapp. |
302 | + bmp = self.factory.makeBranchMergeProposal() |
303 | + view = create_initialized_view(bmp, '+index') |
304 | + cache = IJSONRequestCache(view.request) |
305 | + self.assertThat(cache.objects, ContainsDict({ |
306 | + 'branch_name': Equals(bmp.source_branch.name), |
307 | + 'branch_diff_link': Equals( |
308 | + 'http://code.launchpad.dev/%s/+diff/' % |
309 | + bmp.source_branch.unique_name), |
310 | + })) |
311 | + |
312 | + def test_client_cache_bzr_proxy_feature_disabled(self): |
313 | + # For Bazaar, a feature flag can be set to cause the client cache to |
314 | + # contain a direct link to the Loggerhead diff rather than via the |
315 | + # webapp. (This only works for public branches.) |
316 | + self.useFixture( |
317 | + FeatureFixture({u'code.bzr.diff.disable_proxy': u'on'})) |
318 | bmp = self.factory.makeBranchMergeProposal() |
319 | view = create_initialized_view(bmp, '+index') |
320 | cache = IJSONRequestCache(view.request) |
321 | |
322 | === modified file 'lib/lp/code/browser/tests/test_gitrepository.py' |
323 | --- lib/lp/code/browser/tests/test_gitrepository.py 2018-02-01 18:38:24 +0000 |
324 | +++ lib/lp/code/browser/tests/test_gitrepository.py 2018-06-22 10:03:28 +0000 |
325 | @@ -875,7 +875,8 @@ |
326 | hosting_fixture = self.useFixture(GitHostingFixture( |
327 | diff={"patch": diff})) |
328 | person = self.factory.makePerson() |
329 | - repository = self.factory.makeGitRepository(owner=person) |
330 | + repository = self.factory.makeGitRepository( |
331 | + owner=person, name="some-repository") |
332 | browser = self.getUserBrowser( |
333 | canonical_url(repository) + "/+diff/0123456/0123456^") |
334 | with person_logged_in(person): |
335 | @@ -886,7 +887,7 @@ |
336 | 'text/x-patch;charset=UTF-8', browser.headers["Content-Type"]) |
337 | self.assertEqual(str(len(diff)), browser.headers["Content-Length"]) |
338 | self.assertEqual( |
339 | - "attachment; filename=0123456^_0123456.diff", |
340 | + 'attachment; filename="some-repository_0123456^_0123456.diff"', |
341 | browser.headers["Content-Disposition"]) |
342 | self.assertEqual(diff, browser.contents) |
343 | |
344 | @@ -911,6 +912,21 @@ |
345 | Unauthorized, self.getUserBrowser, |
346 | repository_url + "/+diff/0123456/0123456^") |
347 | |
348 | + def test_filename_quoting(self): |
349 | + # If we construct revisions containing metacharacters and somehow |
350 | + # manage to get that past the hosting service, the |
351 | + # Content-Disposition header is quoted properly. |
352 | + diff = "A fake diff\n" |
353 | + self.useFixture(GitHostingFixture(diff={"patch": diff})) |
354 | + person = self.factory.makePerson() |
355 | + repository = self.factory.makeGitRepository( |
356 | + owner=person, name="some-repository") |
357 | + browser = self.getUserBrowser( |
358 | + canonical_url(repository) + '/+diff/foo"/"bar') |
359 | + self.assertEqual( |
360 | + r'attachment; filename="some-repository_\"bar_foo\".diff"', |
361 | + browser.headers["Content-Disposition"]) |
362 | + |
363 | |
364 | class TestGitRepositoryDeletionView(BrowserTestCase): |
365 | |
366 | |
367 | === modified file 'lib/lp/code/interfaces/branch.py' |
368 | --- lib/lp/code/interfaces/branch.py 2018-03-06 00:59:06 +0000 |
369 | +++ lib/lp/code/interfaces/branch.py 2018-06-22 10:03:28 +0000 |
370 | @@ -1,4 +1,4 @@ |
371 | -# Copyright 2009-2016 Canonical Ltd. This software is licensed under the |
372 | +# Copyright 2009-2018 Canonical Ltd. This software is licensed under the |
373 | # GNU Affero General Public License version 3 (see the file LICENSE). |
374 | |
375 | """Branch interfaces.""" |
376 | @@ -765,6 +765,15 @@ |
377 | :param launchbag: `ILaunchBag`. |
378 | """ |
379 | |
380 | + def getDiff(new, old): |
381 | + """Get the diff between two revisions in this branch. |
382 | + |
383 | + :param new: The new revno or revision ID. |
384 | + :param old: The old revno or revision ID. Defaults to the parent |
385 | + revision of `new`. |
386 | + :return: The diff as a byte string. |
387 | + """ |
388 | + |
389 | @export_read_operation() |
390 | @operation_for_version('beta') |
391 | def canBeDeleted(): |
392 | |
393 | === modified file 'lib/lp/code/model/branch.py' |
394 | --- lib/lp/code/model/branch.py 2018-03-06 00:59:06 +0000 |
395 | +++ lib/lp/code/model/branch.py 2018-06-22 10:03:28 +0000 |
396 | @@ -105,6 +105,7 @@ |
397 | WrongNumberOfReviewTypeArguments, |
398 | ) |
399 | from lp.code.interfaces.branchcollection import IAllBranches |
400 | +from lp.code.interfaces.branchhosting import IBranchHostingClient |
401 | from lp.code.interfaces.branchlookup import IBranchLookup |
402 | from lp.code.interfaces.branchmergeproposal import ( |
403 | BRANCH_MERGE_PROPOSAL_FINAL_STATES, |
404 | @@ -782,6 +783,11 @@ |
405 | RevisionAuthor, revisions, ['revision_author_id']) |
406 | return DecoratedResultSet(result, pre_iter_hook=eager_load) |
407 | |
408 | + def getDiff(self, new, old=None): |
409 | + """See `IBranch`.""" |
410 | + hosting_client = getUtility(IBranchHostingClient) |
411 | + return hosting_client.getDiff(self.id, new, old=old) |
412 | + |
413 | def canBeDeleted(self): |
414 | """See `IBranch`.""" |
415 | if ((len(self.deletionRequirements()) != 0) or not |
416 | |
417 | === modified file 'lib/lp/code/tests/helpers.py' |
418 | --- lib/lp/code/tests/helpers.py 2017-12-19 17:16:38 +0000 |
419 | +++ lib/lp/code/tests/helpers.py 2018-06-22 10:03:28 +0000 |
420 | @@ -1,4 +1,4 @@ |
421 | -# Copyright 2009-2017 Canonical Ltd. This software is licensed under the |
422 | +# Copyright 2009-2018 Canonical Ltd. This software is licensed under the |
423 | # GNU Affero General Public License version 3 (see the file LICENSE). |
424 | |
425 | """Helper functions for code testing live here.""" |
426 | @@ -8,6 +8,7 @@ |
427 | __metaclass__ = type |
428 | __all__ = [ |
429 | 'add_revision_to_branch', |
430 | + 'BranchHostingFixture', |
431 | 'get_non_existant_source_package_branch_unique_name', |
432 | 'GitHostingFixture', |
433 | 'make_erics_fooix_project', |
434 | @@ -35,6 +36,7 @@ |
435 | ) |
436 | |
437 | from lp.app.enums import InformationType |
438 | +from lp.code.interfaces.branchhosting import IBranchHostingClient |
439 | from lp.code.interfaces.branchmergeproposal import ( |
440 | IBranchMergeProposalJobSource, |
441 | ) |
442 | @@ -301,6 +303,19 @@ |
443 | c.execute('delete from branch') |
444 | |
445 | |
446 | +class BranchHostingFixture(fixtures.Fixture): |
447 | + """A fixture that temporarily registers a fake Bazaar hosting client.""" |
448 | + |
449 | + def __init__(self, diff=None, inventory=None, blob=None): |
450 | + self.create = FakeMethod() |
451 | + self.getDiff = FakeMethod(result=diff or {}) |
452 | + self.getInventory = FakeMethod(result=inventory or {}) |
453 | + self.getBlob = FakeMethod(result=blob) |
454 | + |
455 | + def _setUp(self): |
456 | + self.useFixture(ZopeUtilityFixture(self, IBranchHostingClient)) |
457 | + |
458 | + |
459 | class GitHostingFixture(fixtures.Fixture): |
460 | """A fixture that temporarily registers a fake Git hosting client.""" |
461 | |
462 | |
463 | === modified file 'lib/lp/services/webapp/publisher.py' |
464 | --- lib/lp/services/webapp/publisher.py 2017-11-22 13:05:11 +0000 |
465 | +++ lib/lp/services/webapp/publisher.py 2018-06-22 10:03:28 +0000 |
466 | @@ -29,6 +29,7 @@ |
467 | from cgi import FieldStorage |
468 | import httplib |
469 | import re |
470 | +from wsgiref.headers import Headers |
471 | |
472 | from lazr.restful import ( |
473 | EntryResource, |
474 | @@ -233,9 +234,12 @@ |
475 | class DataDownloadView: |
476 | """Download data without templating. |
477 | |
478 | - Subclasses must provide getBody, content_type and filename. |
479 | + Subclasses must provide getBody, content_type and filename, and may |
480 | + provide charset. |
481 | """ |
482 | |
483 | + charset = None |
484 | + |
485 | def __init__(self, context, request): |
486 | self.context = context |
487 | self.request = request |
488 | @@ -246,10 +250,16 @@ |
489 | It is not necessary to supply Content-length, because this is added by |
490 | the caller. |
491 | """ |
492 | - self.request.response.setHeader('Content-Type', self.content_type) |
493 | - self.request.response.setHeader( |
494 | - 'Content-Disposition', 'attachment; filename="%s"' % ( |
495 | - self.filename)) |
496 | + headers = Headers([]) |
497 | + content_type_params = {} |
498 | + if self.charset is not None: |
499 | + content_type_params['charset'] = self.charset |
500 | + headers.add_header( |
501 | + 'Content-Type', self.content_type, **content_type_params) |
502 | + headers.add_header( |
503 | + 'Content-Disposition', 'attachment', filename=self.filename) |
504 | + for key, value in headers.items(): |
505 | + self.request.response.setHeader(key, value) |
506 | return self.getBody() |
507 | |
508 |