Merge lp:~abentley/launchpad/attachment-timeout into lp:launchpad
- attachment-timeout
- Merge into devel
Status: | Merged | ||||
---|---|---|---|---|---|
Approved by: | Brad Crittenden | ||||
Approved revision: | no longer in the source branch. | ||||
Merged at revision: | 14739 | ||||
Proposed branch: | lp:~abentley/launchpad/attachment-timeout | ||||
Merge into: | lp:launchpad | ||||
Prerequisite: | lp:~abentley/launchpad/data-download-view | ||||
Diff against target: |
1927 lines (+1549/-77) 20 files modified
lib/lp/app/browser/tales.py (+12/-5) lib/lp/bugs/browser/bugcomment.py (+14/-0) lib/lp/bugs/browser/configure.zcml (+7/-0) lib/lp/bugs/browser/tests/test_bugcomment.py (+28/-0) lib/lp/bugs/interfaces/bugmessage.py (+0/-1) lib/lp/bugs/stories/bugs/xx-bug-comments-truncated.txt (+4/-0) lib/lp/code/browser/branchmergeproposal.py (+6/-0) lib/lp/code/browser/codereviewcomment.py (+19/-0) lib/lp/code/browser/configure.zcml (+4/-0) lib/lp/code/browser/tests/test_branchmergeproposal.py (+20/-0) lib/lp/code/browser/tests/test_codereviewcomment.py (+35/-0) lib/lp/registry/browser/distroseriesdifference.py (+4/-0) lib/lp/registry/browser/tests/test_person.py (+1281/-0) lib/lp/registry/browser/tests/test_person.py.OTHER (+0/-69) lib/lp/services/comments/browser/comment.py (+53/-0) lib/lp/services/comments/browser/configure.zcml (+1/-0) lib/lp/services/comments/browser/messagecomment.py (+5/-0) lib/lp/services/comments/browser/tests/test_comment.py (+40/-0) lib/lp/services/comments/interfaces/conversation.py (+11/-0) lib/lp/services/comments/templates/comment-body.pt (+5/-2) |
||||
To merge this branch: | bzr merge lp:~abentley/launchpad/attachment-timeout | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Brad Crittenden (community) | code | Approve | |
Review via email: mp+90514@code.launchpad.net |
Commit message
Excessive-length comments are provided as full-text download.
Description of the change
= Summary =
Finish fixing bug #911090: merge proposal is broken with consistent timeouts
== Proposed fix ==
This branch adds a "Download full text" link to long comments.
It also disables the "Read More" link for comments that are so long that they cannot be rendered in a reasonable time.
Finally, the index page for comments (still linked to, and available to URL hackers), will redirect to the download if the comment is unrenderable.
== Pre-implementation notes ==
Discussed with deryck
== Implementation details ==
Extract download_link from PackageDiffForm
IComment now demands
- too_long_to_render, so behaviour for those comments can vary
- download_url, to determine the url for downloading a comment's body
- index, (migrated from IBugComment) for use in generating a filename for the comment's body
== Tests ==
bin/test -vt test_comment -t test_codereview
== Demo and Q/A ==
For both bugs and merge proposals, create a comment slightly smaller than 10000 characters. When viewed in the context of other comments, it should have a "Read more" link and a "Download full text" link. Clicking the permalink should display the comment normally.
For both bugs and merge proposals, create a comment larger than 10000 characters. When viewed in the context of other comments, it should have no "Read more" link, but a "Download full text" link. Clicking the permalink should download the full text.
= Launchpad lint =
Checking for conflicts and issues in changed files.
Linting changed files:
lib/lp/
lib/lp/
lib/lp/
lib/lp/
lib/lp/
lib/lp/
lib/lp/
lib/lp/
lib/lp/
lib/lp/
lib/lp/
lib/lp/
lib/lp/
lib/lp/
lib/lp/
lib/lp/
lib/lp/
lib/lp/
lib/lp/
lib/lp/
./lib/lp/
35: want exceeds 78 characters.
44: want exceeds 78 characters.
Preview Diff
1 | === modified file 'lib/lp/app/browser/tales.py' |
2 | --- lib/lp/app/browser/tales.py 2011-12-30 08:13:14 +0000 |
3 | +++ lib/lp/app/browser/tales.py 2012-01-31 14:46:26 +0000 |
4 | @@ -90,6 +90,7 @@ |
5 | from lp.services.webapp.menu import ( |
6 | get_current_view, |
7 | get_facet, |
8 | + structured, |
9 | ) |
10 | from lp.services.webapp.publisher import ( |
11 | get_current_browser_request, |
12 | @@ -2680,6 +2681,14 @@ |
13 | return self._context.title |
14 | |
15 | |
16 | +def download_link(url, description, file_size): |
17 | + """Return HTML for downloading an item.""" |
18 | + file_size = NumberFormatterAPI(file_size).bytes() |
19 | + formatted = structured( |
20 | + '<a href="%s">%s</a> (%s)', url, description, file_size) |
21 | + return formatted.escapedtext |
22 | + |
23 | + |
24 | class PackageDiffFormatterAPI(ObjectFormatterAPI): |
25 | |
26 | def link(self, view_name, rootsite=None): |
27 | @@ -2687,11 +2696,9 @@ |
28 | if not diff.date_fulfilled: |
29 | return '%s (pending)' % cgi.escape(diff.title) |
30 | else: |
31 | - file_size = NumberFormatterAPI( |
32 | - diff.diff_content.content.filesize).bytes() |
33 | - return '<a href="%s">%s</a> (%s)' % ( |
34 | - cgi.escape(diff.diff_content.http_url), |
35 | - cgi.escape(diff.title), file_size) |
36 | + return download_link( |
37 | + diff.diff_content.http_url, diff.title, |
38 | + diff.diff_content.content.filesize) |
39 | |
40 | |
41 | class CSSFormatter: |
42 | |
43 | === modified file 'lib/lp/bugs/browser/bugcomment.py' |
44 | --- lib/lp/bugs/browser/bugcomment.py 2012-01-31 14:46:25 +0000 |
45 | +++ lib/lp/bugs/browser/bugcomment.py 2012-01-31 14:46:26 +0000 |
46 | @@ -41,6 +41,7 @@ |
47 | |
48 | from lp.bugs.interfaces.bugattachment import BugAttachmentType |
49 | from lp.bugs.interfaces.bugmessage import IBugComment |
50 | +from lp.services.comments.browser.comment import download_body |
51 | from lp.services.comments.browser.messagecomment import MessageComment |
52 | from lp.services.config import config |
53 | from lp.services.features import getFeatureFlag |
54 | @@ -276,6 +277,10 @@ |
55 | return canonical_url(self.bugtask, view_name='+addcomment') |
56 | |
57 | @property |
58 | + def download_url(self): |
59 | + return canonical_url(self, view_name='+download') |
60 | + |
61 | + @property |
62 | def show_footer(self): |
63 | """Return True if the footer should be shown for this comment.""" |
64 | return bool( |
65 | @@ -334,6 +339,15 @@ |
66 | LaunchpadView.__init__(self, bugtask, request) |
67 | self.comment = context |
68 | |
69 | + def __call__(self): |
70 | + """View redirects to +download if comment is too long to render.""" |
71 | + if self.comment.too_long_to_render: |
72 | + return self.request.response.redirect(self.comment.download_url) |
73 | + return super(BugCommentView, self).__call__() |
74 | + |
75 | + def download(self): |
76 | + return download_body(self.comment, self.request) |
77 | + |
78 | @property |
79 | def show_spam_controls(self): |
80 | return self.comment.show_spam_controls |
81 | |
82 | === modified file 'lib/lp/bugs/browser/configure.zcml' |
83 | --- lib/lp/bugs/browser/configure.zcml 2012-01-10 14:43:54 +0000 |
84 | +++ lib/lp/bugs/browser/configure.zcml 2012-01-31 14:46:26 +0000 |
85 | @@ -177,6 +177,13 @@ |
86 | permission="launchpad.View"/> |
87 | <browser:page |
88 | for="lp.bugs.interfaces.bugmessage.IBugComment" |
89 | + name="+download" |
90 | + class="lp.bugs.browser.bugcomment.BugCommentView" |
91 | + attribute="download" |
92 | + permission="launchpad.View" |
93 | + /> |
94 | + <browser:page |
95 | + for="lp.bugs.interfaces.bugmessage.IBugComment" |
96 | name="+box" |
97 | template="../templates/bugcomment-box.pt" |
98 | class="lp.bugs.browser.bugcomment.BugCommentBoxView" |
99 | |
100 | === modified file 'lib/lp/bugs/browser/tests/test_bugcomment.py' |
101 | --- lib/lp/bugs/browser/tests/test_bugcomment.py 2012-01-31 14:46:25 +0000 |
102 | +++ lib/lp/bugs/browser/tests/test_bugcomment.py 2012-01-31 14:46:26 +0000 |
103 | @@ -30,6 +30,7 @@ |
104 | TestMessageVisibilityMixin, |
105 | ) |
106 | from lp.services.features.testing import FeatureFixture |
107 | +from lp.services.webapp.publisher import canonical_url |
108 | from lp.services.webapp.testing import verifyObject |
109 | from lp.testing import ( |
110 | BrowserTestCase, |
111 | @@ -331,3 +332,30 @@ |
112 | bugtask = bug_message.bugs[0].bugtasks[0] |
113 | bug_comment = BugComment(1, bug_message, bugtask) |
114 | verifyObject(IBugComment, bug_comment) |
115 | + |
116 | + def test_download_url(self): |
117 | + """download_url is provided and works as expected.""" |
118 | + bug_comment = make_bug_comment(self.factory) |
119 | + url = canonical_url(bug_comment, view_name='+download') |
120 | + self.assertEqual(url, bug_comment.download_url) |
121 | + |
122 | + |
123 | +def make_bug_comment(factory, *args, **kwargs): |
124 | + bug_message = factory.makeBugComment(*args, **kwargs) |
125 | + bugtask = bug_message.bugs[0].bugtasks[0] |
126 | + return BugComment(1, bug_message, bugtask) |
127 | + |
128 | + |
129 | +class TestBugCommentInBrowser(BrowserTestCase): |
130 | + |
131 | + layer = DatabaseFunctionalLayer |
132 | + |
133 | + def test_excessive_comments_redirect_to_download(self): |
134 | + """View for excessive comments redirects to download page.""" |
135 | + comment = make_bug_comment(self.factory, body='x ' * 5001) |
136 | + view_url = canonical_url(comment) |
137 | + download_url = canonical_url(comment, view_name='+download') |
138 | + browser = self.getUserBrowser(view_url) |
139 | + self.assertNotEqual(view_url, browser.url) |
140 | + self.assertEqual(download_url, browser.url) |
141 | + self.assertEqual('x ' * 5001, browser.contents) |
142 | |
143 | === modified file 'lib/lp/bugs/interfaces/bugmessage.py' |
144 | --- lib/lp/bugs/interfaces/bugmessage.py 2012-01-31 14:46:25 +0000 |
145 | +++ lib/lp/bugs/interfaces/bugmessage.py 2012-01-31 14:46:26 +0000 |
146 | @@ -127,7 +127,6 @@ |
147 | show_for_admin = Bool( |
148 | title=u'A hidden comment still displayed for admins.', |
149 | readonly=True) |
150 | - index = Int(title=u'The comment number', required=True, readonly=True) |
151 | display_title = Attribute('Whether or not to show the title.') |
152 | synchronized = Attribute( |
153 | 'Has the comment been synchronized with a remote bug tracker?') |
154 | |
155 | === modified file 'lib/lp/bugs/stories/bugs/xx-bug-comments-truncated.txt' |
156 | --- lib/lp/bugs/stories/bugs/xx-bug-comments-truncated.txt 2012-01-31 14:46:25 +0000 |
157 | +++ lib/lp/bugs/stories/bugs/xx-bug-comments-truncated.txt 2012-01-31 14:46:26 +0000 |
158 | @@ -32,6 +32,8 @@ |
159 | ... print div_tag |
160 | >>> print_comments(browser.contents) #doctest: -ELLIPSIS |
161 | <div class="boardCommentBody"> |
162 | + <a href="http://bugs.launchpad.dev/tomcat/+bug/2/comments/1/+download">Download |
163 | + full text</a> (363 bytes) |
164 | <div class="comment-text" itemprop="commentText"><p>This |
165 | would be a real killer feature. If there...</p></div> |
166 | <p> |
167 | @@ -39,6 +41,8 @@ |
168 | </p> |
169 | </div> |
170 | <div class="boardCommentBody"> |
171 | + <a href="http://bugs.launchpad.dev/tomcat/+bug/2/comments/2/+download">Download |
172 | + full text</a> (364 bytes) |
173 | <div class="comment-text" itemprop="commentText"><p>Oddly |
174 | enough the bug system seems only capabl...</p></div> |
175 | <p> |
176 | |
177 | === modified file 'lib/lp/code/browser/branchmergeproposal.py' |
178 | --- lib/lp/code/browser/branchmergeproposal.py 2012-01-31 14:46:25 +0000 |
179 | +++ lib/lp/code/browser/branchmergeproposal.py 2012-01-31 14:46:26 +0000 |
180 | @@ -574,9 +574,15 @@ |
181 | self.comment_author = None |
182 | self.body_text = None |
183 | self.text_for_display = None |
184 | + self.download_url = None |
185 | self.too_long = False |
186 | + self.too_long_to_render = False |
187 | self.comment_date = None |
188 | self.display_attachments = False |
189 | + self.index = None |
190 | + |
191 | + def download(self, request): |
192 | + pass |
193 | |
194 | |
195 | class CodeReviewNewRevisionsView(LaunchpadView): |
196 | |
197 | === modified file 'lib/lp/code/browser/codereviewcomment.py' |
198 | --- lib/lp/code/browser/codereviewcomment.py 2012-01-31 14:46:25 +0000 |
199 | +++ lib/lp/code/browser/codereviewcomment.py 2012-01-31 14:46:26 +0000 |
200 | @@ -33,6 +33,7 @@ |
201 | from lp.code.interfaces.codereviewcomment import ICodeReviewComment |
202 | from lp.code.interfaces.codereviewvote import ICodeReviewVoteReference |
203 | from lp.services.comments.interfaces.conversation import IComment |
204 | +from lp.services.comments.browser.comment import download_body |
205 | from lp.services.comments.browser.messagecomment import MessageComment |
206 | from lp.services.config import config |
207 | from lp.services.librarian.interfaces import ILibraryFileAlias |
208 | @@ -79,6 +80,10 @@ |
209 | self.from_superseded = from_superseded |
210 | |
211 | @property |
212 | + def index(self): |
213 | + return self.comment.id |
214 | + |
215 | + @property |
216 | def extra_css_class(self): |
217 | if self.from_superseded: |
218 | return 'from-superseded' |
219 | @@ -104,6 +109,10 @@ |
220 | # Attachments to not show. |
221 | return self.all_attachments[1] |
222 | |
223 | + @property |
224 | + def download_url(self): |
225 | + return canonical_url(self.comment, view_name='+download') |
226 | + |
227 | |
228 | def get_message(display_comment): |
229 | """Adapt an ICodeReviwComment to an IMessage.""" |
230 | @@ -175,6 +184,16 @@ |
231 | def page_description(self): |
232 | return self.context.message_body |
233 | |
234 | + def download(self): |
235 | + return download_body( |
236 | + CodeReviewDisplayComment(self.context), self.request) |
237 | + |
238 | + def __call__(self): |
239 | + """View redirects to +download if comment is too long to render.""" |
240 | + if self.comment.too_long_to_render: |
241 | + return self.request.response.redirect(self.comment.download_url) |
242 | + return super(CodeReviewCommentView, self).__call__() |
243 | + |
244 | # Should the comment be shown in full? |
245 | full_comment = True |
246 | # Show comment expanders? |
247 | |
248 | === modified file 'lib/lp/code/browser/configure.zcml' |
249 | --- lib/lp/code/browser/configure.zcml 2012-01-31 14:46:25 +0000 |
250 | +++ lib/lp/code/browser/configure.zcml 2012-01-31 14:46:26 +0000 |
251 | @@ -650,6 +650,10 @@ |
252 | <browser:page |
253 | name="+fragment" |
254 | template="../templates/codereviewcomment-fragment.pt"/> |
255 | + <browser:page |
256 | + name="+download" |
257 | + attribute="download" |
258 | + /> |
259 | </browser:pages> |
260 | <browser:pages |
261 | for="lp.code.browser.codereviewcomment.ICodeReviewDisplayComment" |
262 | |
263 | === modified file 'lib/lp/code/browser/tests/test_branchmergeproposal.py' |
264 | --- lib/lp/code/browser/tests/test_branchmergeproposal.py 2012-01-31 14:46:25 +0000 |
265 | +++ lib/lp/code/browser/tests/test_branchmergeproposal.py 2012-01-31 14:46:26 +0000 |
266 | @@ -1130,6 +1130,26 @@ |
267 | 'Read more link', 'a', {'href': url}, text='Read more...') |
268 | self.assertThat(browser.contents, HTMLContains(read_more)) |
269 | |
270 | + def test_short_conversation_comments_no_download(self): |
271 | + """Short comments should not have a download link.""" |
272 | + comment = self.factory.makeCodeReviewComment(body='x y' * 100) |
273 | + download_url = canonical_url(comment, view_name='+download') |
274 | + browser = self.getViewBrowser(comment.branch_merge_proposal) |
275 | + body = Tag( |
276 | + 'Download', 'a', {'href': download_url}, |
277 | + text='Download full text') |
278 | + self.assertThat(browser.contents, Not(HTMLContains(body))) |
279 | + |
280 | + def test_long_conversation_comments_download_link(self): |
281 | + """Long comments in a conversation should be truncated.""" |
282 | + comment = self.factory.makeCodeReviewComment(body='x y' * 2000) |
283 | + download_url = canonical_url(comment, view_name='+download') |
284 | + browser = self.getViewBrowser(comment.branch_merge_proposal) |
285 | + body = Tag( |
286 | + 'Download', 'a', {'href': download_url}, |
287 | + text='Download full text') |
288 | + self.assertThat(browser.contents, HTMLContains(body)) |
289 | + |
290 | |
291 | class TestLatestProposalsForEachBranch(TestCaseWithFactory): |
292 | """Confirm that the latest branch is returned.""" |
293 | |
294 | === modified file 'lib/lp/code/browser/tests/test_codereviewcomment.py' |
295 | --- lib/lp/code/browser/tests/test_codereviewcomment.py 2012-01-31 14:46:25 +0000 |
296 | +++ lib/lp/code/browser/tests/test_codereviewcomment.py 2012-01-31 14:46:26 +0000 |
297 | @@ -5,6 +5,7 @@ |
298 | |
299 | __metaclass__ = type |
300 | |
301 | +from testtools.matchers import Not |
302 | from soupmatchers import ( |
303 | HTMLContains, |
304 | Tag, |
305 | @@ -14,6 +15,7 @@ |
306 | CodeReviewDisplayComment, |
307 | ICodeReviewDisplayComment, |
308 | ) |
309 | +from lp.services.webapp import canonical_url |
310 | from lp.services.webapp.interfaces import IPrimaryContext |
311 | from lp.services.webapp.testing import verifyObject |
312 | from lp.testing import ( |
313 | @@ -77,3 +79,36 @@ |
314 | browser = self.getViewBrowser(comment) |
315 | body = Tag('Body text', 'p', text='x y' * 2000) |
316 | self.assertThat(browser.contents, HTMLContains(body)) |
317 | + |
318 | + def test_excessive_comments_redirect_to_download(self): |
319 | + """View for excessive comments redirects to download page.""" |
320 | + comment = self.factory.makeCodeReviewComment(body='x ' * 5001) |
321 | + view_url = canonical_url(comment) |
322 | + download_url = canonical_url(comment, view_name='+download') |
323 | + browser = self.getUserBrowser(view_url) |
324 | + self.assertNotEqual(view_url, browser.url) |
325 | + self.assertEqual(download_url, browser.url) |
326 | + self.assertEqual('x ' * 5001, browser.contents) |
327 | + |
328 | + def test_short_comment_no_download_link(self): |
329 | + """Long comments displayed by themselves are not truncated.""" |
330 | + comment = self.factory.makeCodeReviewComment(body='x ' * 5000) |
331 | + download_url = canonical_url(comment, view_name='+download') |
332 | + browser = self.getViewBrowser(comment) |
333 | + body = Tag( |
334 | + 'Download', 'a', {'href': download_url}, |
335 | + text='Download full text') |
336 | + self.assertThat(browser.contents, Not(HTMLContains(body))) |
337 | + |
338 | + def test_download_view(self): |
339 | + """The download view has the expected contents and header.""" |
340 | + comment = self.factory.makeCodeReviewComment(body=u'\u1234') |
341 | + browser = self.getViewBrowser(comment, view_name='+download') |
342 | + contents = u'\u1234'.encode('utf-8') |
343 | + self.assertEqual(contents, browser.contents) |
344 | + self.assertEqual( |
345 | + 'text/plain;charset=utf-8', browser.headers['Content-type']) |
346 | + self.assertEqual( |
347 | + '%d' % len(contents), browser.headers['Content-length']) |
348 | + disposition = 'attachment; filename="comment-%d.txt"' % comment.id |
349 | + self.assertEqual(disposition, browser.headers['Content-disposition']) |
350 | |
351 | === modified file 'lib/lp/registry/browser/distroseriesdifference.py' |
352 | --- lib/lp/registry/browser/distroseriesdifference.py 2012-01-31 14:46:25 +0000 |
353 | +++ lib/lp/registry/browser/distroseriesdifference.py 2012-01-31 14:46:26 +0000 |
354 | @@ -250,6 +250,10 @@ |
355 | """Used simply to provide `IComment` for rendering.""" |
356 | implements(IDistroSeriesDifferenceDisplayComment) |
357 | |
358 | + index = None |
359 | + |
360 | + download_url = None |
361 | + |
362 | def __init__(self, comment): |
363 | """Setup the attributes required by `IComment`.""" |
364 | super(DistroSeriesDifferenceDisplayComment, self).__init__(None) |
365 | |
366 | === added file 'lib/lp/registry/browser/tests/test_person.py' |
367 | --- lib/lp/registry/browser/tests/test_person.py 1970-01-01 00:00:00 +0000 |
368 | +++ lib/lp/registry/browser/tests/test_person.py 2012-01-31 14:46:26 +0000 |
369 | @@ -0,0 +1,1281 @@ |
370 | +# Copyright 2009-2012 Canonical Ltd. This software is licensed under the |
371 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
372 | + |
373 | +__metaclass__ = type |
374 | + |
375 | +from textwrap import dedent |
376 | +import doctest |
377 | + |
378 | +import soupmatchers |
379 | +from storm.expr import LeftJoin |
380 | +from storm.store import Store |
381 | +from testtools.matchers import ( |
382 | + DocTestMatches, |
383 | + Equals, |
384 | + LessThan, |
385 | + Not, |
386 | + ) |
387 | +import transaction |
388 | +from zope.component import getUtility |
389 | + |
390 | +from lp.app.errors import NotFoundError |
391 | +from lp.app.interfaces.launchpad import ILaunchpadCelebrities |
392 | +from lp.bugs.model.bugtask import BugTask |
393 | +from lp.buildmaster.enums import BuildStatus |
394 | +from lp.registry.browser.person import PersonView |
395 | +from lp.registry.browser.team import TeamInvitationView |
396 | +from lp.registry.interfaces.karma import IKarmaCacheManager |
397 | +from lp.registry.interfaces.person import ( |
398 | + IPersonSet, |
399 | + PersonVisibility, |
400 | + ) |
401 | +from lp.registry.interfaces.persontransferjob import IPersonMergeJobSource |
402 | +from lp.registry.interfaces.pocket import PackagePublishingPocket |
403 | +from lp.registry.interfaces.teammembership import ( |
404 | + ITeamMembershipSet, |
405 | + TeamMembershipStatus, |
406 | + ) |
407 | +from lp.registry.model.karma import KarmaCategory |
408 | +from lp.registry.model.milestone import milestone_sort_key |
409 | +from lp.registry.model.person import Person |
410 | +from lp.services.config import config |
411 | +from lp.services.identity.interfaces.account import AccountStatus |
412 | +from lp.services.verification.interfaces.authtoken import LoginTokenType |
413 | +from lp.services.verification.interfaces.logintoken import ILoginTokenSet |
414 | +from lp.services.webapp import canonical_url |
415 | +from lp.services.webapp.interfaces import ILaunchBag |
416 | +from lp.services.webapp.servers import LaunchpadTestRequest |
417 | +from lp.soyuz.enums import ( |
418 | + ArchivePurpose, |
419 | + ArchiveStatus, |
420 | + PackagePublishingStatus, |
421 | + ) |
422 | +from lp.soyuz.tests.test_publishing import SoyuzTestPublisher |
423 | +from lp.testing import ( |
424 | + ANONYMOUS, |
425 | + BrowserTestCase, |
426 | + login, |
427 | + login_celebrity, |
428 | + login_person, |
429 | + person_logged_in, |
430 | + StormStatementRecorder, |
431 | + TestCaseWithFactory, |
432 | + ) |
433 | +from lp.testing.dbuser import switch_dbuser |
434 | +from lp.testing.layers import ( |
435 | + DatabaseFunctionalLayer, |
436 | + LaunchpadFunctionalLayer, |
437 | + LaunchpadZopelessLayer, |
438 | + ) |
439 | +from lp.testing.matchers import HasQueryCount |
440 | +from lp.testing.pages import ( |
441 | + extract_text, |
442 | + ) |
443 | +from lp.testing.views import ( |
444 | + create_initialized_view, |
445 | + create_view, |
446 | + ) |
447 | + |
448 | + |
449 | +class PersonViewOpenidIdentityUrlTestCase(TestCaseWithFactory): |
450 | + """Tests for the public OpenID identifier shown on the profile page.""" |
451 | + |
452 | + layer = DatabaseFunctionalLayer |
453 | + |
454 | + def setUp(self): |
455 | + TestCaseWithFactory.setUp(self) |
456 | + self.user = self.factory.makePerson(name='eris') |
457 | + self.request = LaunchpadTestRequest( |
458 | + SERVER_URL="http://launchpad.dev/") |
459 | + login_person(self.user, self.request) |
460 | + self.view = PersonView(self.user, self.request) |
461 | + # Marker allowing us to reset the config. |
462 | + config.push(self.id(), '') |
463 | + self.addCleanup(config.pop, self.id()) |
464 | + |
465 | + def test_should_be_profile_page_when_delegating(self): |
466 | + """The profile page is the OpenID identifier in normal situation.""" |
467 | + self.assertEquals( |
468 | + 'http://launchpad.dev/~eris', self.view.openid_identity_url) |
469 | + |
470 | + def test_should_be_production_profile_page_when_not_delegating(self): |
471 | + """When the profile page is not delegated, the OpenID identity URL |
472 | + should be the one on the main production site.""" |
473 | + config.push('non-delegating', dedent(''' |
474 | + [vhost.mainsite] |
475 | + openid_delegate_profile: False |
476 | + |
477 | + [launchpad] |
478 | + non_restricted_hostname: prod.launchpad.dev |
479 | + ''')) |
480 | + self.assertEquals( |
481 | + 'http://prod.launchpad.dev/~eris', self.view.openid_identity_url) |
482 | + |
483 | + |
484 | +class TestPersonIndexView(TestCaseWithFactory): |
485 | + |
486 | + layer = DatabaseFunctionalLayer |
487 | + |
488 | + def test_is_merge_pending(self): |
489 | + dupe_person = self.factory.makePerson(name='finch') |
490 | + target_person = self.factory.makePerson() |
491 | + job_source = getUtility(IPersonMergeJobSource) |
492 | + job_source.create(from_person=dupe_person, to_person=target_person) |
493 | + view = create_initialized_view(dupe_person, name="+index") |
494 | + notifications = view.request.response.notifications |
495 | + message = 'Finch is queued to be be merged in a few minutes.' |
496 | + self.assertEqual(1, len(notifications)) |
497 | + self.assertEqual(message, notifications[0].message) |
498 | + |
499 | + def test_display_utcoffset(self): |
500 | + person = self.factory.makePerson(time_zone='Asia/Kolkata') |
501 | + html = create_initialized_view(person, '+portlet-contact-details')() |
502 | + self.assertThat(extract_text(html), DocTestMatches(extract_text( |
503 | + "... Asia/Kolkata (UTC+0530) ..."), doctest.ELLIPSIS |
504 | + | doctest.NORMALIZE_WHITESPACE | doctest.REPORT_NDIFF)) |
505 | + |
506 | + def test_person_view_page_description(self): |
507 | + person_description = self.factory.getUniqueString() |
508 | + person = self.factory.makePerson( |
509 | + homepage_content=person_description) |
510 | + view = create_initialized_view(person, '+index') |
511 | + self.assertThat(view.page_description, |
512 | + Equals(person_description)) |
513 | + |
514 | + def test_team_page_description(self): |
515 | + description = self.factory.getUniqueString() |
516 | + person = self.factory.makeTeam( |
517 | + description=description) |
518 | + view = create_initialized_view(person, '+index') |
519 | + self.assertThat( |
520 | + view.page_description, |
521 | + Equals(description)) |
522 | + |
523 | + |
524 | +class TestPersonViewKarma(TestCaseWithFactory): |
525 | + |
526 | + layer = LaunchpadZopelessLayer |
527 | + |
528 | + def setUp(self): |
529 | + super(TestPersonViewKarma, self).setUp() |
530 | + person = self.factory.makePerson() |
531 | + product = self.factory.makeProduct() |
532 | + transaction.commit() |
533 | + self.view = PersonView( |
534 | + person, LaunchpadTestRequest()) |
535 | + self._makeKarmaCache( |
536 | + person, product, KarmaCategory.byName('bugs')) |
537 | + self._makeKarmaCache( |
538 | + person, product, KarmaCategory.byName('answers')) |
539 | + self._makeKarmaCache( |
540 | + person, product, KarmaCategory.byName('code')) |
541 | + |
542 | + def test_karma_category_sort(self): |
543 | + categories = self.view.contributed_categories |
544 | + category_names = [] |
545 | + for category in categories: |
546 | + category_names.append(category.name) |
547 | + |
548 | + self.assertEqual(category_names, [u'code', u'bugs', u'answers'], |
549 | + 'Categories are not sorted correctly') |
550 | + |
551 | + def _makeKarmaCache(self, person, product, category, value=10): |
552 | + """ Create and return a KarmaCache entry with the given arguments. |
553 | + |
554 | + In order to create the KarmaCache record we must switch to the DB |
555 | + user 'karma', so tests that need a different user after calling |
556 | + this method should run switch_dbuser() themselves. |
557 | + """ |
558 | + |
559 | + switch_dbuser('karma') |
560 | + |
561 | + cache_manager = getUtility(IKarmaCacheManager) |
562 | + karmacache = cache_manager.new( |
563 | + value, person.id, category.id, product_id=product.id) |
564 | + |
565 | + try: |
566 | + cache_manager.updateKarmaValue( |
567 | + value, person.id, category_id=None, product_id=product.id) |
568 | + except NotFoundError: |
569 | + cache_manager.new( |
570 | + value, person.id, category_id=None, product_id=product.id) |
571 | + |
572 | + # We must commit here so that the change is seen in other transactions |
573 | + # (e.g. when the callsite issues a switch_dbuser() after we return). |
574 | + transaction.commit() |
575 | + return karmacache |
576 | + |
577 | + |
578 | +class TestShouldShowPpaSection(TestCaseWithFactory): |
579 | + |
580 | + layer = LaunchpadFunctionalLayer |
581 | + |
582 | + def setUp(self): |
583 | + TestCaseWithFactory.setUp(self) |
584 | + self.owner = self.factory.makePerson(name='mowgli') |
585 | + self.person_ppa = self.factory.makeArchive(owner=self.owner) |
586 | + self.team = self.factory.makeTeam(name='jbook', owner=self.owner) |
587 | + |
588 | + # The team is the owner of the PPA. |
589 | + self.team_ppa = self.factory.makeArchive(owner=self.team) |
590 | + self.team_view = PersonView(self.team, LaunchpadTestRequest()) |
591 | + |
592 | + def make_ppa_private(self, ppa): |
593 | + """Helper method to privatise a ppa.""" |
594 | + login('foo.bar@canonical.com') |
595 | + ppa.private = True |
596 | + ppa.buildd_secret = "secret" |
597 | + login(ANONYMOUS) |
598 | + |
599 | + def test_viewing_person_with_public_ppa(self): |
600 | + # Show PPA section only if context has at least one PPA the user is |
601 | + # authorised to view the PPA. |
602 | + login(ANONYMOUS) |
603 | + person_view = PersonView(self.owner, LaunchpadTestRequest()) |
604 | + self.failUnless(person_view.should_show_ppa_section) |
605 | + |
606 | + def test_viewing_person_without_ppa(self): |
607 | + # If the context person does not have a ppa then the section |
608 | + # should not display. |
609 | + login(ANONYMOUS) |
610 | + person_without_ppa = self.factory.makePerson() |
611 | + person_view = PersonView(person_without_ppa, LaunchpadTestRequest()) |
612 | + self.failIf(person_view.should_show_ppa_section) |
613 | + |
614 | + def test_viewing_self(self): |
615 | + # If the current user has edit access to the context person then |
616 | + # the section should always display. |
617 | + login_person(self.owner) |
618 | + person_view = PersonView(self.owner, LaunchpadTestRequest()) |
619 | + self.failUnless(person_view.should_show_ppa_section) |
620 | + |
621 | + # If the ppa is private, the section is still displayed to |
622 | + # a user with edit access to the person. |
623 | + self.make_ppa_private(self.person_ppa) |
624 | + login_person(self.owner) |
625 | + person_view = PersonView(self.owner, LaunchpadTestRequest()) |
626 | + self.failUnless(person_view.should_show_ppa_section) |
627 | + |
628 | + # Even a person without a PPA will see the section when viewing |
629 | + # themselves. |
630 | + person_without_ppa = self.factory.makePerson() |
631 | + login_person(person_without_ppa) |
632 | + person_view = PersonView(person_without_ppa, LaunchpadTestRequest()) |
633 | + self.failUnless(person_view.should_show_ppa_section) |
634 | + |
635 | + def test_anon_viewing_person_with_private_ppa(self): |
636 | + # If the ppa is private, the ppa section will not be displayed |
637 | + # to users without view access to the ppa. |
638 | + self.make_ppa_private(self.person_ppa) |
639 | + login(ANONYMOUS) |
640 | + person_view = PersonView(self.owner, LaunchpadTestRequest()) |
641 | + self.failIf(person_view.should_show_ppa_section) |
642 | + |
643 | + # But if the context person has a second ppa that is public, |
644 | + # then anon users will see the section. |
645 | + self.factory.makeArchive(owner=self.owner) |
646 | + person_view = PersonView(self.owner, LaunchpadTestRequest()) |
647 | + self.failUnless(person_view.should_show_ppa_section) |
648 | + |
649 | + def test_viewing_team_with_private_ppa(self): |
650 | + # If a team PPA is private, the ppa section will be displayed |
651 | + # to team members. |
652 | + self.make_ppa_private(self.team_ppa) |
653 | + member = self.factory.makePerson() |
654 | + login_person(self.owner) |
655 | + self.team.addMember(member, self.owner) |
656 | + login_person(member) |
657 | + |
658 | + # So the member will see the section. |
659 | + person_view = PersonView(self.team, LaunchpadTestRequest()) |
660 | + self.failUnless(person_view.should_show_ppa_section) |
661 | + |
662 | + # But other users who are not members will not. |
663 | + non_member = self.factory.makePerson() |
664 | + login_person(non_member) |
665 | + person_view = PersonView(self.team, LaunchpadTestRequest()) |
666 | + self.failIf(person_view.should_show_ppa_section) |
667 | + |
668 | + # Unless the team also has another ppa which is public. |
669 | + self.factory.makeArchive(owner=self.team) |
670 | + person_view = PersonView(self.team, LaunchpadTestRequest()) |
671 | + self.failUnless(person_view.should_show_ppa_section) |
672 | + |
673 | + |
674 | +class TestPersonRenameFormMixin: |
675 | + |
676 | + def test_can_rename_with_empty_PPA(self): |
677 | + # If a PPA exists but has no packages, we can rename the |
678 | + # person. |
679 | + self.view.initialize() |
680 | + self.assertFalse(self.view.form_fields['name'].for_display) |
681 | + |
682 | + def _publishPPAPackage(self): |
683 | + stp = SoyuzTestPublisher() |
684 | + stp.setUpDefaultDistroSeries() |
685 | + stp.getPubSource(archive=self.ppa) |
686 | + |
687 | + def test_cannot_rename_with_non_empty_PPA(self): |
688 | + # Publish some packages in the PPA and test that we can't rename |
689 | + # the person. |
690 | + self._publishPPAPackage() |
691 | + self.view.initialize() |
692 | + self.assertTrue(self.view.form_fields['name'].for_display) |
693 | + self.assertEqual( |
694 | + self.view.widgets['name'].hint, |
695 | + "This person has an active PPA with packages published and " |
696 | + "may not be renamed.") |
697 | + |
698 | + def test_cannot_rename_with_deleting_PPA(self): |
699 | + # When a PPA is in the DELETING state we should not allow |
700 | + # renaming just yet. |
701 | + self._publishPPAPackage() |
702 | + self.view.initialize() |
703 | + self.ppa.delete(self.person) |
704 | + self.assertEqual(self.ppa.status, ArchiveStatus.DELETING) |
705 | + self.assertTrue(self.view.form_fields['name'].for_display) |
706 | + |
707 | + def test_can_rename_with_deleted_PPA(self): |
708 | + # Delete a PPA and test that the person can be renamed. |
709 | + self._publishPPAPackage() |
710 | + # Deleting the PPA will remove the publications, which is |
711 | + # necessary for the renaming check. |
712 | + self.ppa.delete(self.person) |
713 | + # Simulate the external script running and finalising the |
714 | + # DELETED status. |
715 | + self.ppa.status = ArchiveStatus.DELETED |
716 | + self.view.initialize() |
717 | + self.assertFalse(self.view.form_fields['name'].for_display) |
718 | + |
719 | + |
720 | +class TestPersonEditView(TestPersonRenameFormMixin, TestCaseWithFactory): |
721 | + |
722 | + layer = LaunchpadFunctionalLayer |
723 | + |
724 | + def setUp(self): |
725 | + super(TestPersonEditView, self).setUp() |
726 | + self.valid_email_address = self.factory.getUniqueEmailAddress() |
727 | + self.person = self.factory.makePerson(email=self.valid_email_address) |
728 | + login_person(self.person) |
729 | + self.ppa = self.factory.makeArchive(owner=self.person) |
730 | + self.view = create_initialized_view(self.person, '+edit') |
731 | + |
732 | + def test_add_email_good_data(self): |
733 | + email_address = self.factory.getUniqueEmailAddress() |
734 | + form = { |
735 | + 'field.VALIDATED_SELECTED': self.valid_email_address, |
736 | + 'field.VALIDATED_SELECTED-empty-marker': 1, |
737 | + 'field.actions.add_email': 'Add', |
738 | + 'field.newemail': email_address, |
739 | + } |
740 | + create_initialized_view(self.person, "+editemails", form=form) |
741 | + |
742 | + # If everything worked, there should now be a login token to validate |
743 | + # this email address for this user. |
744 | + token = getUtility(ILoginTokenSet).searchByEmailRequesterAndType( |
745 | + email_address, |
746 | + self.person, |
747 | + LoginTokenType.VALIDATEEMAIL) |
748 | + self.assertTrue(token is not None) |
749 | + |
750 | + def test_add_email_address_taken(self): |
751 | + email_address = self.factory.getUniqueEmailAddress() |
752 | + self.factory.makePerson( |
753 | + name='deadaccount', |
754 | + displayname='deadaccount', |
755 | + email=email_address, |
756 | + account_status=AccountStatus.NOACCOUNT) |
757 | + form = { |
758 | + 'field.VALIDATED_SELECTED': self.valid_email_address, |
759 | + 'field.VALIDATED_SELECTED-empty-marker': 1, |
760 | + 'field.actions.add_email': 'Add', |
761 | + 'field.newemail': email_address, |
762 | + } |
763 | + view = create_initialized_view(self.person, "+editemails", form=form) |
764 | + error_msg = view.errors[0] |
765 | + expected_msg = ( |
766 | + "The email address '%s' is already registered to " |
767 | + "<a href=\"http://launchpad.dev/~deadaccount\">deadaccount</a>. " |
768 | + "If you think that is a duplicated account, you can " |
769 | + "<a href=\"http://launchpad.dev/people/+requestmerge?" |
770 | + "field.dupe_person=deadaccount\">merge it</a> into your account." |
771 | + % email_address) |
772 | + self.assertEqual(expected_msg, error_msg) |
773 | + |
774 | + |
775 | +class PersonAdministerViewTestCase(TestPersonRenameFormMixin, |
776 | + TestCaseWithFactory): |
777 | + layer = LaunchpadFunctionalLayer |
778 | + |
779 | + def setUp(self): |
780 | + super(PersonAdministerViewTestCase, self).setUp() |
781 | + self.person = self.factory.makePerson() |
782 | + login_celebrity('admin') |
783 | + self.ppa = self.factory.makeArchive(owner=self.person) |
784 | + self.view = create_initialized_view(self.person, '+review') |
785 | + |
786 | + def test_init_admin(self): |
787 | + # An admin sees all the fields. |
788 | + self.assertEqual('Review person', self.view.label) |
789 | + self.assertEqual( |
790 | + ['name', 'displayname', 'personal_standing', |
791 | + 'personal_standing_reason'], |
792 | + self.view.field_names) |
793 | + |
794 | + def test_init_registry_expert(self): |
795 | + # Registry experts do not see the the displayname field. |
796 | + login_celebrity('registry_experts') |
797 | + self.view.setUpFields() |
798 | + self.assertEqual( |
799 | + ['name', 'personal_standing', 'personal_standing_reason'], |
800 | + self.view.field_names) |
801 | + |
802 | + |
803 | +class TestTeamCreationView(TestCaseWithFactory): |
804 | + |
805 | + layer = DatabaseFunctionalLayer |
806 | + |
807 | + def setUp(self): |
808 | + super(TestTeamCreationView, self).setUp() |
809 | + person = self.factory.makePerson() |
810 | + login_person(person) |
811 | + |
812 | + def test_team_creation_good_data(self): |
813 | + form = { |
814 | + 'field.actions.create': 'Create Team', |
815 | + 'field.contactemail': 'contactemail@example.com', |
816 | + 'field.displayname': 'liberty-land', |
817 | + 'field.name': 'libertyland', |
818 | + 'field.renewal_policy': 'NONE', |
819 | + 'field.renewal_policy-empty-marker': 1, |
820 | + 'field.subscriptionpolicy': 'RESTRICTED', |
821 | + 'field.subscriptionpolicy-empty-marker': 1, |
822 | + } |
823 | + person_set = getUtility(IPersonSet) |
824 | + create_initialized_view( |
825 | + person_set, '+newteam', form=form) |
826 | + team = person_set.getByName('libertyland') |
827 | + self.assertTrue(team is not None) |
828 | + self.assertEqual('libertyland', team.name) |
829 | + |
830 | + def test_validate_email_catches_taken_emails(self): |
831 | + email_address = self.factory.getUniqueEmailAddress() |
832 | + self.factory.makePerson( |
833 | + name='libertylandaccount', |
834 | + displayname='libertylandaccount', |
835 | + email=email_address, |
836 | + account_status=AccountStatus.NOACCOUNT) |
837 | + form = { |
838 | + 'field.actions.create': 'Create Team', |
839 | + 'field.contactemail': email_address, |
840 | + 'field.displayname': 'liberty-land', |
841 | + 'field.name': 'libertyland', |
842 | + 'field.renewal_policy': 'NONE', |
843 | + 'field.renewal_policy-empty-marker': 1, |
844 | + 'field.subscriptionpolicy': 'RESTRICTED', |
845 | + 'field.subscriptionpolicy-empty-marker': 1, |
846 | + } |
847 | + person_set = getUtility(IPersonSet) |
848 | + view = create_initialized_view(person_set, '+newteam', form=form) |
849 | + expected_msg = ( |
850 | + '%s is already registered in Launchpad and is associated with ' |
851 | + '<a href="http://launchpad.dev/~libertylandaccount">' |
852 | + 'libertylandaccount</a>.' % email_address) |
853 | + error_msg = view.errors[0].errors[0] |
854 | + self.assertEqual(expected_msg, error_msg) |
855 | + |
856 | + |
857 | +class TestPersonParticipationView(TestCaseWithFactory): |
858 | + |
859 | + layer = DatabaseFunctionalLayer |
860 | + |
861 | + def setUp(self): |
862 | + super(TestPersonParticipationView, self).setUp() |
863 | + self.user = self.factory.makePerson() |
864 | + self.view = create_view(self.user, name='+participation') |
865 | + |
866 | + def test__asParticpation_owner(self): |
867 | + # Team owners have the role of 'Owner'. |
868 | + self.factory.makeTeam(owner=self.user) |
869 | + [participation] = self.view.active_participations |
870 | + self.assertEqual('Owner', participation['role']) |
871 | + |
872 | + def test__asParticpation_admin(self): |
873 | + # Team admins have the role of 'Admin'. |
874 | + team = self.factory.makeTeam() |
875 | + login_person(team.teamowner) |
876 | + team.addMember(self.user, team.teamowner) |
877 | + for membership in self.user.team_memberships: |
878 | + membership.setStatus( |
879 | + TeamMembershipStatus.ADMIN, team.teamowner) |
880 | + [participation] = self.view.active_participations |
881 | + self.assertEqual('Admin', participation['role']) |
882 | + |
883 | + def test__asParticpation_member(self): |
884 | + # The default team role is 'Member'. |
885 | + team = self.factory.makeTeam() |
886 | + login_person(team.teamowner) |
887 | + team.addMember(self.user, team.teamowner) |
888 | + [participation] = self.view.active_participations |
889 | + self.assertEqual('Member', participation['role']) |
890 | + |
891 | + def test__asParticpation_without_mailing_list(self): |
892 | + # The default team role is 'Member'. |
893 | + team = self.factory.makeTeam() |
894 | + login_person(team.teamowner) |
895 | + team.addMember(self.user, team.teamowner) |
896 | + [participation] = self.view.active_participations |
897 | + self.assertEqual('—', participation['subscribed']) |
898 | + |
899 | + def test__asParticpation_unsubscribed_to_mailing_list(self): |
900 | + # The default team role is 'Member'. |
901 | + team = self.factory.makeTeam() |
902 | + self.factory.makeMailingList(team, team.teamowner) |
903 | + login_person(team.teamowner) |
904 | + team.addMember(self.user, team.teamowner) |
905 | + [participation] = self.view.active_participations |
906 | + self.assertEqual('Not subscribed', participation['subscribed']) |
907 | + |
908 | + def test__asParticpation_subscribed_to_mailing_list(self): |
909 | + # The default team role is 'Member'. |
910 | + team = self.factory.makeTeam() |
911 | + mailing_list = self.factory.makeMailingList(team, team.teamowner) |
912 | + mailing_list.subscribe(self.user) |
913 | + login_person(team.teamowner) |
914 | + team.addMember(self.user, team.teamowner) |
915 | + [participation] = self.view.active_participations |
916 | + self.assertEqual('Subscribed', participation['subscribed']) |
917 | + |
918 | + def test_active_participations_with_direct_private_team(self): |
919 | + # Users cannot see private teams that they are not members of. |
920 | + owner = self.factory.makePerson() |
921 | + team = self.factory.makeTeam( |
922 | + owner=owner, visibility=PersonVisibility.PRIVATE) |
923 | + login_person(owner) |
924 | + team.addMember(self.user, owner) |
925 | + # The team is included in active_participations. |
926 | + login_person(self.user) |
927 | + view = create_view( |
928 | + self.user, name='+participation', principal=self.user) |
929 | + self.assertEqual(1, len(view.active_participations)) |
930 | + # The team is not included in active_participations. |
931 | + observer = self.factory.makePerson() |
932 | + login_person(observer) |
933 | + view = create_view( |
934 | + self.user, name='+participation', principal=observer) |
935 | + self.assertEqual(0, len(view.active_participations)) |
936 | + |
937 | + def test_active_participations_with_indirect_private_team(self): |
938 | + # Users cannot see private teams that they are not members of. |
939 | + owner = self.factory.makePerson() |
940 | + team = self.factory.makeTeam( |
941 | + owner=owner, visibility=PersonVisibility.PRIVATE) |
942 | + direct_team = self.factory.makeTeam(owner=owner) |
943 | + login_person(owner) |
944 | + direct_team.addMember(self.user, owner) |
945 | + team.addMember(direct_team, owner) |
946 | + # The team is included in active_participations. |
947 | + login_person(self.user) |
948 | + view = create_view( |
949 | + self.user, name='+participation', principal=self.user) |
950 | + self.assertEqual(2, len(view.active_participations)) |
951 | + # The team is not included in active_participations. |
952 | + observer = self.factory.makePerson() |
953 | + login_person(observer) |
954 | + view = create_view( |
955 | + self.user, name='+participation', principal=observer) |
956 | + self.assertEqual(1, len(view.active_participations)) |
957 | + |
958 | + def test_active_participations_indirect_membership(self): |
959 | + # Verify the path of indirect membership. |
960 | + a_team = self.factory.makeTeam(name='a') |
961 | + b_team = self.factory.makeTeam(name='b', owner=a_team) |
962 | + self.factory.makeTeam(name='c', owner=b_team) |
963 | + login_person(a_team.teamowner) |
964 | + a_team.addMember(self.user, a_team.teamowner) |
965 | + transaction.commit() |
966 | + participations = self.view.active_participations |
967 | + self.assertEqual(3, len(participations)) |
968 | + display_names = [ |
969 | + participation['displayname'] for participation in participations] |
970 | + self.assertEqual(['A', 'B', 'C'], display_names) |
971 | + self.assertEqual(None, participations[0]['via']) |
972 | + self.assertEqual('A', participations[1]['via']) |
973 | + self.assertEqual('B, A', participations[2]['via']) |
974 | + |
975 | + def test_has_participations_false(self): |
976 | + participations = self.view.active_participations |
977 | + self.assertEqual(0, len(participations)) |
978 | + self.assertEqual(False, self.view.has_participations) |
979 | + |
980 | + def test_has_participations_true(self): |
981 | + self.factory.makeTeam(owner=self.user) |
982 | + participations = self.view.active_participations |
983 | + self.assertEqual(1, len(participations)) |
984 | + self.assertEqual(True, self.view.has_participations) |
985 | + |
986 | + |
987 | +class TestPersonRelatedSoftwareView(TestCaseWithFactory): |
988 | + """Test the related software view.""" |
989 | + |
990 | + layer = LaunchpadFunctionalLayer |
991 | + |
992 | + def setUp(self): |
993 | + super(TestPersonRelatedSoftwareView, self).setUp() |
994 | + self.user = self.factory.makePerson() |
995 | + self.factory.makeGPGKey(self.user) |
996 | + self.ubuntu = getUtility(ILaunchpadCelebrities).ubuntu |
997 | + self.warty = self.ubuntu.getSeries('warty') |
998 | + self.view = create_initialized_view(self.user, '+related-software') |
999 | + |
1000 | + def publishSources(self, archive, maintainer): |
1001 | + publisher = SoyuzTestPublisher() |
1002 | + publisher.person = self.user |
1003 | + login('foo.bar@canonical.com') |
1004 | + spphs = [] |
1005 | + for count in range(0, self.view.max_results_to_display + 3): |
1006 | + source_name = "foo" + str(count) |
1007 | + spph = publisher.getPubSource( |
1008 | + sourcename=source_name, |
1009 | + status=PackagePublishingStatus.PUBLISHED, |
1010 | + archive=archive, |
1011 | + maintainer=maintainer, |
1012 | + creator=self.user, |
1013 | + distroseries=self.warty) |
1014 | + spphs.append(spph) |
1015 | + login(ANONYMOUS) |
1016 | + return spphs |
1017 | + |
1018 | + def copySources(self, spphs, copier, dest_distroseries): |
1019 | + self.copier = self.factory.makePerson() |
1020 | + for spph in spphs: |
1021 | + spph.copyTo( |
1022 | + dest_distroseries, creator=copier, |
1023 | + pocket=PackagePublishingPocket.UPDATES, |
1024 | + archive=dest_distroseries.main_archive) |
1025 | + |
1026 | + def test_view_helper_attributes(self): |
1027 | + # Verify view helper attributes. |
1028 | + self.assertEqual('Related software', self.view.page_title) |
1029 | + self.assertEqual('summary_list_size', self.view._max_results_key) |
1030 | + self.assertEqual( |
1031 | + config.launchpad.summary_list_size, |
1032 | + self.view.max_results_to_display) |
1033 | + |
1034 | + def test_tableHeaderMessage(self): |
1035 | + limit = self.view.max_results_to_display |
1036 | + expected = 'Displaying first %s packages out of 100 total' % limit |
1037 | + self.assertEqual(expected, self.view._tableHeaderMessage(100)) |
1038 | + expected = '%s packages' % limit |
1039 | + self.assertEqual(expected, self.view._tableHeaderMessage(limit)) |
1040 | + expected = '1 package' |
1041 | + self.assertEqual(expected, self.view._tableHeaderMessage(1)) |
1042 | + |
1043 | + def test_latest_uploaded_ppa_packages_with_stats(self): |
1044 | + # Verify number of PPA packages to display. |
1045 | + ppa = self.factory.makeArchive(owner=self.user) |
1046 | + self.publishSources(ppa, self.user) |
1047 | + count = len(self.view.latest_uploaded_ppa_packages_with_stats) |
1048 | + self.assertEqual(self.view.max_results_to_display, count) |
1049 | + |
1050 | + def test_latest_maintained_packages_with_stats(self): |
1051 | + # Verify number of maintained packages to display. |
1052 | + self.publishSources(self.warty.main_archive, self.user) |
1053 | + count = len(self.view.latest_maintained_packages_with_stats) |
1054 | + self.assertEqual(self.view.max_results_to_display, count) |
1055 | + |
1056 | + def test_latest_uploaded_nonmaintained_packages_with_stats(self): |
1057 | + # Verify number of non maintained packages to display. |
1058 | + maintainer = self.factory.makePerson() |
1059 | + self.publishSources(self.warty.main_archive, maintainer) |
1060 | + count = len( |
1061 | + self.view.latest_uploaded_but_not_maintained_packages_with_stats) |
1062 | + self.assertEqual(self.view.max_results_to_display, count) |
1063 | + |
1064 | + def test_latest_synchronised_publishings_with_stats(self): |
1065 | + # Verify number of non synchronised publishings to display. |
1066 | + creator = self.factory.makePerson() |
1067 | + spphs = self.publishSources(self.warty.main_archive, creator) |
1068 | + dest_distroseries = self.factory.makeDistroSeries() |
1069 | + self.copySources(spphs, self.user, dest_distroseries) |
1070 | + count = len( |
1071 | + self.view.latest_synchronised_publishings_with_stats) |
1072 | + self.assertEqual(self.view.max_results_to_display, count) |
1073 | + |
1074 | + |
1075 | +class TestPersonMaintainedPackagesView(TestCaseWithFactory): |
1076 | + """Test the maintained packages view.""" |
1077 | + |
1078 | + layer = DatabaseFunctionalLayer |
1079 | + |
1080 | + def setUp(self): |
1081 | + super(TestPersonMaintainedPackagesView, self).setUp() |
1082 | + self.user = self.factory.makePerson() |
1083 | + self.view = create_initialized_view(self.user, '+maintained-packages') |
1084 | + |
1085 | + def test_view_helper_attributes(self): |
1086 | + # Verify view helper attributes. |
1087 | + self.assertEqual('Maintained Packages', self.view.page_title) |
1088 | + self.assertEqual('default_batch_size', self.view._max_results_key) |
1089 | + self.assertEqual( |
1090 | + config.launchpad.default_batch_size, |
1091 | + self.view.max_results_to_display) |
1092 | + |
1093 | + |
1094 | +class TestPersonUploadedPackagesView(TestCaseWithFactory): |
1095 | + """Test the maintained packages view.""" |
1096 | + |
1097 | + layer = DatabaseFunctionalLayer |
1098 | + |
1099 | + def setUp(self): |
1100 | + super(TestPersonUploadedPackagesView, self).setUp() |
1101 | + self.user = self.factory.makePerson() |
1102 | + archive = self.factory.makeArchive(purpose=ArchivePurpose.PRIMARY) |
1103 | + spr = self.factory.makeSourcePackageRelease( |
1104 | + creator=self.user, archive=archive) |
1105 | + self.spph = self.factory.makeSourcePackagePublishingHistory( |
1106 | + sourcepackagerelease=spr, archive=archive) |
1107 | + self.view = create_initialized_view(self.user, '+uploaded-packages') |
1108 | + |
1109 | + def test_view_helper_attributes(self): |
1110 | + # Verify view helper attributes. |
1111 | + self.assertEqual('Uploaded packages', self.view.page_title) |
1112 | + self.assertEqual('default_batch_size', self.view._max_results_key) |
1113 | + self.assertEqual( |
1114 | + config.launchpad.default_batch_size, |
1115 | + self.view.max_results_to_display) |
1116 | + |
1117 | + def test_verify_bugs_and_answers_links(self): |
1118 | + # Verify the links for bugs and answers point to locations that |
1119 | + # exist. |
1120 | + html = self.view() |
1121 | + expected_base = '/%s/+source/%s' % ( |
1122 | + self.spph.distroseries.distribution.name, |
1123 | + self.spph.source_package_name) |
1124 | + self.assertIn('<a href="%s/+bugs">' % expected_base, html) |
1125 | + self.assertIn('<a href="%s/+questions">' % expected_base, html) |
1126 | + |
1127 | + |
1128 | +class TestPersonPPAPackagesView(TestCaseWithFactory): |
1129 | + """Test the maintained packages view.""" |
1130 | + |
1131 | + layer = DatabaseFunctionalLayer |
1132 | + |
1133 | + def setUp(self): |
1134 | + super(TestPersonPPAPackagesView, self).setUp() |
1135 | + self.user = self.factory.makePerson() |
1136 | + self.view = create_initialized_view(self.user, '+ppa-packages') |
1137 | + |
1138 | + def test_view_helper_attributes(self): |
1139 | + # Verify view helper attributes. |
1140 | + self.assertEqual('PPA packages', self.view.page_title) |
1141 | + self.assertEqual('default_batch_size', self.view._max_results_key) |
1142 | + self.assertEqual( |
1143 | + config.launchpad.default_batch_size, |
1144 | + self.view.max_results_to_display) |
1145 | + |
1146 | + |
1147 | +class TestPersonSynchronisedPackagesView(TestCaseWithFactory): |
1148 | + """Test the synchronised packages view.""" |
1149 | + |
1150 | + layer = DatabaseFunctionalLayer |
1151 | + |
1152 | + def setUp(self): |
1153 | + super(TestPersonSynchronisedPackagesView, self).setUp() |
1154 | + user = self.factory.makePerson() |
1155 | + archive = self.factory.makeArchive(purpose=ArchivePurpose.PRIMARY) |
1156 | + spr = self.factory.makeSourcePackageRelease( |
1157 | + creator=user, archive=archive) |
1158 | + spph = self.factory.makeSourcePackagePublishingHistory( |
1159 | + sourcepackagerelease=spr, archive=archive) |
1160 | + self.copier = self.factory.makePerson() |
1161 | + dest_distroseries = self.factory.makeDistroSeries() |
1162 | + self.copied_spph = spph.copyTo( |
1163 | + dest_distroseries, creator=self.copier, |
1164 | + pocket=PackagePublishingPocket.UPDATES, |
1165 | + archive=dest_distroseries.main_archive) |
1166 | + self.view = create_initialized_view( |
1167 | + self.copier, '+synchronised-packages') |
1168 | + |
1169 | + def test_view_helper_attributes(self): |
1170 | + # Verify view helper attributes. |
1171 | + self.assertEqual('Synchronised packages', self.view.page_title) |
1172 | + self.assertEqual('default_batch_size', self.view._max_results_key) |
1173 | + self.assertEqual( |
1174 | + config.launchpad.default_batch_size, |
1175 | + self.view.max_results_to_display) |
1176 | + |
1177 | + def test_verify_bugs_and_answers_links(self): |
1178 | + # Verify the links for bugs and answers point to locations that |
1179 | + # exist. |
1180 | + html = self.view() |
1181 | + expected_base = '/%s/+source/%s' % ( |
1182 | + self.copied_spph.distroseries.distribution.name, |
1183 | + self.copied_spph.source_package_name) |
1184 | + bug_matcher = soupmatchers.HTMLContains( |
1185 | + soupmatchers.Tag( |
1186 | + 'Bugs link', 'a', |
1187 | + attrs={'href': expected_base + '/+bugs'})) |
1188 | + question_matcher = soupmatchers.HTMLContains( |
1189 | + soupmatchers.Tag( |
1190 | + 'Questions link', 'a', |
1191 | + attrs={'href': expected_base + '/+questions'})) |
1192 | + self.assertThat(html, bug_matcher) |
1193 | + self.assertThat(html, question_matcher) |
1194 | + |
1195 | + |
1196 | +class TestPersonRelatedProjectsView(TestCaseWithFactory): |
1197 | + """Test the maintained packages view.""" |
1198 | + |
1199 | + layer = DatabaseFunctionalLayer |
1200 | + |
1201 | + def setUp(self): |
1202 | + super(TestPersonRelatedProjectsView, self).setUp() |
1203 | + self.user = self.factory.makePerson() |
1204 | + self.view = create_initialized_view(self.user, '+related-projects') |
1205 | + |
1206 | + def test_view_helper_attributes(self): |
1207 | + # Verify view helper attributes. |
1208 | + self.assertEqual('Related projects', self.view.page_title) |
1209 | + self.assertEqual('default_batch_size', self.view._max_results_key) |
1210 | + self.assertEqual( |
1211 | + config.launchpad.default_batch_size, |
1212 | + self.view.max_results_to_display) |
1213 | + |
1214 | + |
1215 | +class TestPersonRelatedSoftwareFailedBuild(TestCaseWithFactory): |
1216 | + """The related software views display links to failed builds.""" |
1217 | + |
1218 | + layer = LaunchpadFunctionalLayer |
1219 | + |
1220 | + def setUp(self): |
1221 | + super(TestPersonRelatedSoftwareFailedBuild, self).setUp() |
1222 | + self.user = self.factory.makePerson() |
1223 | + |
1224 | + # First we need to publish some PPA packages with failed builds |
1225 | + # for this person. |
1226 | + # XXX michaeln 2010-06-10 bug=592050. |
1227 | + # Strangely, the builds need to be built in the context of a |
1228 | + # main archive to reproduce bug 591010 for which this test was |
1229 | + # written to demonstrate. |
1230 | + login('foo.bar@canonical.com') |
1231 | + publisher = SoyuzTestPublisher() |
1232 | + publisher.prepareBreezyAutotest() |
1233 | + ppa = self.factory.makeArchive(owner=self.user) |
1234 | + src_pub = publisher.getPubSource( |
1235 | + creator=self.user, maintainer=self.user, archive=ppa) |
1236 | + binaries = publisher.getPubBinaries( |
1237 | + pub_source=src_pub) |
1238 | + self.build = binaries[0].binarypackagerelease.build |
1239 | + self.build.status = BuildStatus.FAILEDTOBUILD |
1240 | + self.build.archive = publisher.distroseries.main_archive |
1241 | + login(ANONYMOUS) |
1242 | + |
1243 | + def test_related_software_with_failed_build(self): |
1244 | + # The link to the failed build is displayed. |
1245 | + self.view = create_view(self.user, name='+related-software') |
1246 | + html = self.view() |
1247 | + self.assertTrue( |
1248 | + '<a href="/ubuntutest/+source/foo/666/+build/%d">i386</a>' % ( |
1249 | + self.build.id) in html) |
1250 | + |
1251 | + def test_related_ppa_packages_with_failed_build(self): |
1252 | + # The link to the failed build is displayed. |
1253 | + self.view = create_view(self.user, name='+ppa-packages') |
1254 | + html = self.view() |
1255 | + self.assertTrue( |
1256 | + '<a href="/ubuntutest/+source/foo/666/+build/%d">i386</a>' % ( |
1257 | + self.build.id) in html) |
1258 | + |
1259 | + |
1260 | +class TestPersonRelatedSoftwareSynchronisedPackages(TestCaseWithFactory): |
1261 | + """The related software views display links to synchronised packages.""" |
1262 | + |
1263 | + layer = LaunchpadFunctionalLayer |
1264 | + |
1265 | + def setUp(self): |
1266 | + super(TestPersonRelatedSoftwareSynchronisedPackages, self).setUp() |
1267 | + self.user = self.factory.makePerson() |
1268 | + self.spph = self.factory.makeSourcePackagePublishingHistory() |
1269 | + |
1270 | + def createCopiedSource(self, copier, spph): |
1271 | + self.copier = self.factory.makePerson() |
1272 | + dest_distroseries = self.factory.makeDistroSeries() |
1273 | + return spph.copyTo( |
1274 | + dest_distroseries, creator=copier, |
1275 | + pocket=PackagePublishingPocket.UPDATES, |
1276 | + archive=dest_distroseries.main_archive) |
1277 | + |
1278 | + def getLinkToSynchronisedMatcher(self): |
1279 | + person_url = canonical_url(self.user) |
1280 | + return soupmatchers.HTMLContains( |
1281 | + soupmatchers.Tag( |
1282 | + 'Synchronised packages link', 'a', |
1283 | + attrs={'href': person_url + '/+synchronised-packages'}, |
1284 | + text='Synchronised packages')) |
1285 | + |
1286 | + def test_related_software_no_link_synchronised_packages(self): |
1287 | + # No link to the synchronised packages page if no synchronised |
1288 | + # packages. |
1289 | + view = create_view(self.user, name='+related-software') |
1290 | + synced_package_link_matcher = self.getLinkToSynchronisedMatcher() |
1291 | + self.assertThat(view(), Not(synced_package_link_matcher)) |
1292 | + |
1293 | + def test_related_software_link_synchronised_packages(self): |
1294 | + # If this person has synced packages, the link to the synchronised |
1295 | + # packages page is present. |
1296 | + self.createCopiedSource(self.user, self.spph) |
1297 | + view = create_view(self.user, name='+related-software') |
1298 | + synced_package_link_matcher = self.getLinkToSynchronisedMatcher() |
1299 | + self.assertThat(view(), synced_package_link_matcher) |
1300 | + |
1301 | + def test_related_software_displays_synchronised_packages(self): |
1302 | + copied_spph = self.createCopiedSource(self.user, self.spph) |
1303 | + view = create_view(self.user, name='+related-software') |
1304 | + synced_packages_title = soupmatchers.HTMLContains( |
1305 | + soupmatchers.Tag( |
1306 | + 'Synchronised packages title', 'h2', |
1307 | + text='Synchronised packages')) |
1308 | + expected_base = '/%s/+source/%s' % ( |
1309 | + copied_spph.distroseries.distribution.name, |
1310 | + copied_spph.source_package_name) |
1311 | + source_link = soupmatchers.HTMLContains( |
1312 | + soupmatchers.Tag( |
1313 | + 'Source package link', 'a', |
1314 | + text=copied_spph.sourcepackagerelease.name, |
1315 | + attrs={'href': expected_base})) |
1316 | + version_url = (expected_base + '/%s' % |
1317 | + copied_spph.sourcepackagerelease.version) |
1318 | + version_link = soupmatchers.HTMLContains( |
1319 | + soupmatchers.Tag( |
1320 | + 'Source package version link', 'a', |
1321 | + text=copied_spph.sourcepackagerelease.version, |
1322 | + attrs={'href': version_url})) |
1323 | + |
1324 | + self.assertThat(view(), synced_packages_title) |
1325 | + self.assertThat(view(), source_link) |
1326 | + self.assertThat(view(), version_link) |
1327 | + |
1328 | + |
1329 | +class TestPersonDeactivateAccountView(TestCaseWithFactory): |
1330 | + """Tests for the PersonDeactivateAccountView.""" |
1331 | + |
1332 | + layer = DatabaseFunctionalLayer |
1333 | + form = { |
1334 | + 'field.comment': 'Gotta go.', |
1335 | + 'field.actions.deactivate': 'Deactivate My Account', |
1336 | + } |
1337 | + |
1338 | + def test_deactivate_user_active(self): |
1339 | + user = self.factory.makePerson() |
1340 | + login_person(user) |
1341 | + view = create_initialized_view( |
1342 | + user, '+deactivate-account', form=self.form) |
1343 | + self.assertEqual([], view.errors) |
1344 | + notifications = view.request.response.notifications |
1345 | + self.assertEqual(1, len(notifications)) |
1346 | + self.assertEqual( |
1347 | + 'Your account has been deactivated.', notifications[0].message) |
1348 | + self.assertEqual(AccountStatus.DEACTIVATED, user.account_status) |
1349 | + |
1350 | + def test_deactivate_user_already_deactivated(self): |
1351 | + deactivated_user = self.factory.makePerson() |
1352 | + login_person(deactivated_user) |
1353 | + deactivated_user.deactivateAccount('going.') |
1354 | + view = create_initialized_view( |
1355 | + deactivated_user, '+deactivate-account', form=self.form) |
1356 | + self.assertEqual(1, len(view.errors)) |
1357 | + self.assertEqual( |
1358 | + 'This account is already deactivated.', view.errors[0]) |
1359 | + self.assertEqual( |
1360 | + None, view.page_description) |
1361 | + |
1362 | + |
1363 | +class TestTeamInvitationView(TestCaseWithFactory): |
1364 | + """Tests for TeamInvitationView.""" |
1365 | + |
1366 | + layer = DatabaseFunctionalLayer |
1367 | + |
1368 | + def setUp(self): |
1369 | + super(TestTeamInvitationView, self).setUp() |
1370 | + self.a_team = self.factory.makeTeam(name="team-a", |
1371 | + displayname="A-Team") |
1372 | + self.b_team = self.factory.makeTeam(name="team-b", |
1373 | + displayname="B-Team") |
1374 | + transaction.commit() |
1375 | + |
1376 | + def test_circular_invite(self): |
1377 | + """Two teams can invite each other without horrifying results.""" |
1378 | + |
1379 | + # Make the criss-cross invitations. |
1380 | + # A invites B. |
1381 | + login_person(self.a_team.teamowner) |
1382 | + form = { |
1383 | + 'field.newmember': 'team-b', |
1384 | + 'field.actions.add': 'Add Member', |
1385 | + } |
1386 | + view = create_initialized_view( |
1387 | + self.a_team, "+addmember", form=form) |
1388 | + self.assertEqual([], view.errors) |
1389 | + notifications = view.request.response.notifications |
1390 | + self.assertEqual(1, len(notifications)) |
1391 | + self.assertEqual( |
1392 | + u'B-Team (team-b) has been invited to join this team.', |
1393 | + notifications[0].message) |
1394 | + |
1395 | + # B invites A. |
1396 | + login_person(self.b_team.teamowner) |
1397 | + form['field.newmember'] = 'team-a' |
1398 | + view = create_initialized_view( |
1399 | + self.b_team, "+addmember", form=form) |
1400 | + self.assertEqual([], view.errors) |
1401 | + notifications = view.request.response.notifications |
1402 | + self.assertEqual(1, len(notifications)) |
1403 | + self.assertEqual( |
1404 | + u'A-Team (team-a) has been invited to join this team.', |
1405 | + notifications[0].message) |
1406 | + |
1407 | + # Team A accepts the invitation. |
1408 | + login_person(self.a_team.teamowner) |
1409 | + form = { |
1410 | + 'field.actions.accept': 'Accept', |
1411 | + 'field.acknowledger_comment': 'Thanks for inviting us.', |
1412 | + } |
1413 | + request = LaunchpadTestRequest(form=form, method='POST') |
1414 | + request.setPrincipal(self.a_team.teamowner) |
1415 | + membership_set = getUtility(ITeamMembershipSet) |
1416 | + membership = membership_set.getByPersonAndTeam(self.a_team, |
1417 | + self.b_team) |
1418 | + view = TeamInvitationView(membership, request) |
1419 | + view.initialize() |
1420 | + self.assertEqual([], view.errors) |
1421 | + notifications = view.request.response.notifications |
1422 | + self.assertEqual(1, len(notifications)) |
1423 | + self.assertEqual( |
1424 | + u'This team is now a member of B-Team.', |
1425 | + notifications[0].message) |
1426 | + |
1427 | + # Team B attempts to accept the invitation. |
1428 | + login_person(self.b_team.teamowner) |
1429 | + request = LaunchpadTestRequest(form=form, method='POST') |
1430 | + request.setPrincipal(self.b_team.teamowner) |
1431 | + membership = membership_set.getByPersonAndTeam(self.b_team, |
1432 | + self.a_team) |
1433 | + view = TeamInvitationView(membership, request) |
1434 | + view.initialize() |
1435 | + self.assertEqual([], view.errors) |
1436 | + notifications = view.request.response.notifications |
1437 | + self.assertEqual(1, len(notifications)) |
1438 | + expected = ( |
1439 | + u'This team may not be added to A-Team because it is a member ' |
1440 | + 'of B-Team.') |
1441 | + self.assertEqual( |
1442 | + expected, |
1443 | + notifications[0].message) |
1444 | + |
1445 | + |
1446 | +class TestSubscriptionsView(TestCaseWithFactory): |
1447 | + |
1448 | + layer = LaunchpadFunctionalLayer |
1449 | + |
1450 | + def setUp(self): |
1451 | + super(TestSubscriptionsView, self).setUp( |
1452 | + user='test@canonical.com') |
1453 | + self.user = getUtility(ILaunchBag).user |
1454 | + self.person = self.factory.makePerson() |
1455 | + self.other_person = self.factory.makePerson() |
1456 | + self.team = self.factory.makeTeam(owner=self.user) |
1457 | + self.team.addMember(self.person, self.user) |
1458 | + |
1459 | + def test_unsubscribe_link_appears_for_user(self): |
1460 | + login_person(self.person) |
1461 | + view = create_view(self.person, '+subscriptions') |
1462 | + self.assertTrue(view.canUnsubscribeFromBugTasks()) |
1463 | + |
1464 | + def test_unsubscribe_link_does_not_appear_for_not_user(self): |
1465 | + login_person(self.other_person) |
1466 | + view = create_view(self.person, '+subscriptions') |
1467 | + self.assertFalse(view.canUnsubscribeFromBugTasks()) |
1468 | + |
1469 | + def test_unsubscribe_link_appears_for_team_member(self): |
1470 | + login_person(self.person) |
1471 | + view = create_initialized_view(self.team, '+subscriptions') |
1472 | + self.assertTrue(view.canUnsubscribeFromBugTasks()) |
1473 | + |
1474 | + |
1475 | +class BugTaskViewsTestBase: |
1476 | + """A base class for bugtask search related tests.""" |
1477 | + |
1478 | + layer = DatabaseFunctionalLayer |
1479 | + |
1480 | + def setUp(self): |
1481 | + super(BugTaskViewsTestBase, self).setUp() |
1482 | + self.person = self.factory.makePerson() |
1483 | + with person_logged_in(self.person): |
1484 | + self.subscribed_bug = self.factory.makeBug() |
1485 | + self.subscribed_bug.subscribe( |
1486 | + self.person, subscribed_by=self.person) |
1487 | + self.assigned_bug = self.factory.makeBug() |
1488 | + self.assigned_bug.default_bugtask.transitionToAssignee( |
1489 | + self.person) |
1490 | + self.owned_bug = self.factory.makeBug(owner=self.person) |
1491 | + self.commented_bug = self.factory.makeBug() |
1492 | + self.commented_bug.newMessage(owner=self.person) |
1493 | + self.affecting_bug = self.factory.makeBug() |
1494 | + self.affecting_bug.markUserAffected(self.person) |
1495 | + |
1496 | + for bug in (self.subscribed_bug, self.assigned_bug, self.owned_bug, |
1497 | + self.commented_bug, self.affecting_bug): |
1498 | + with person_logged_in(bug.default_bugtask.product.owner): |
1499 | + milestone = self.factory.makeMilestone( |
1500 | + product=bug.default_bugtask.product) |
1501 | + bug.default_bugtask.transitionToMilestone( |
1502 | + milestone, bug.default_bugtask.product.owner) |
1503 | + |
1504 | + def test_searchUnbatched(self): |
1505 | + view = create_initialized_view(self.person, self.view_name) |
1506 | + self.assertEqual( |
1507 | + self.expected_for_search_unbatched, list(view.searchUnbatched())) |
1508 | + |
1509 | + def test_searchUnbatched_with_prejoins(self): |
1510 | + view = create_initialized_view(self.person, self.view_name) |
1511 | + Store.of(self.subscribed_bug).invalidate() |
1512 | + with StormStatementRecorder() as recorder: |
1513 | + prejoins = [ |
1514 | + (Person, LeftJoin(Person, BugTask.owner == Person.id))] |
1515 | + bugtasks = view.searchUnbatched(prejoins=prejoins) |
1516 | + [bugtask.owner for bugtask in bugtasks] |
1517 | + self.assertThat(recorder, HasQueryCount(LessThan(3))) |
1518 | + |
1519 | + def test_getMilestoneWidgetValues(self): |
1520 | + view = create_initialized_view(self.person, self.view_name) |
1521 | + milestones = [ |
1522 | + bugtask.milestone |
1523 | + for bugtask in self.expected_for_search_unbatched] |
1524 | + milestones = sorted(milestones, key=milestone_sort_key, reverse=True) |
1525 | + expected = [ |
1526 | + { |
1527 | + 'title': milestone.title, |
1528 | + 'value': milestone.id, |
1529 | + 'checked': False, |
1530 | + } |
1531 | + for milestone in milestones] |
1532 | + Store.of(milestones[0]).invalidate() |
1533 | + with StormStatementRecorder() as recorder: |
1534 | + self.assertEqual(expected, view.getMilestoneWidgetValues()) |
1535 | + self.assertThat(recorder, HasQueryCount(LessThan(3))) |
1536 | + |
1537 | + def test_context_description(self): |
1538 | + # view.context_description returns a string that can be used |
1539 | + # in texts like "Bugs in $context_descirption" |
1540 | + view = create_initialized_view(self.person, self.view_name) |
1541 | + self.assertEqual( |
1542 | + self.expected_context_description % self.person.displayname, |
1543 | + view.context_description) |
1544 | + |
1545 | + |
1546 | +class TestPersonRelatedBugTaskSearchListingView( |
1547 | + BugTaskViewsTestBase, TestCaseWithFactory): |
1548 | + """Tests for PersonRelatedBugTaskSearchListingView.""" |
1549 | + |
1550 | + view_name = '+bugs' |
1551 | + expected_context_description = 'related to %s' |
1552 | + |
1553 | + def setUp(self): |
1554 | + super(TestPersonRelatedBugTaskSearchListingView, self).setUp() |
1555 | + self.expected_for_search_unbatched = [ |
1556 | + self.subscribed_bug.default_bugtask, |
1557 | + self.assigned_bug.default_bugtask, |
1558 | + self.owned_bug.default_bugtask, |
1559 | + self.commented_bug.default_bugtask, |
1560 | + ] |
1561 | + |
1562 | + |
1563 | +class TestPersonAssignedBugTaskSearchListingView( |
1564 | + BugTaskViewsTestBase, TestCaseWithFactory): |
1565 | + """Tests for PersonAssignedBugTaskSearchListingView.""" |
1566 | + |
1567 | + view_name = '+assignedbugs' |
1568 | + expected_context_description = 'assigned to %s' |
1569 | + |
1570 | + def setUp(self): |
1571 | + super(TestPersonAssignedBugTaskSearchListingView, self).setUp() |
1572 | + self.expected_for_search_unbatched = [ |
1573 | + self.assigned_bug.default_bugtask, |
1574 | + ] |
1575 | + |
1576 | + |
1577 | +class TestPersonCommentedBugTaskSearchListingView( |
1578 | + BugTaskViewsTestBase, TestCaseWithFactory): |
1579 | + """Tests for PersonAssignedBugTaskSearchListingView.""" |
1580 | + |
1581 | + view_name = '+commentedbugs' |
1582 | + expected_context_description = 'commented on by %s' |
1583 | + |
1584 | + def setUp(self): |
1585 | + super(TestPersonCommentedBugTaskSearchListingView, self).setUp() |
1586 | + self.expected_for_search_unbatched = [ |
1587 | + self.commented_bug.default_bugtask, |
1588 | + ] |
1589 | + |
1590 | + |
1591 | +class TestPersonReportedBugTaskSearchListingView( |
1592 | + BugTaskViewsTestBase, TestCaseWithFactory): |
1593 | + """Tests for PersonAssignedBugTaskSearchListingView.""" |
1594 | + |
1595 | + view_name = '+reportedbugs' |
1596 | + expected_context_description = 'reported by %s' |
1597 | + |
1598 | + def setUp(self): |
1599 | + super(TestPersonReportedBugTaskSearchListingView, self).setUp() |
1600 | + self.expected_for_search_unbatched = [ |
1601 | + self.owned_bug.default_bugtask, |
1602 | + ] |
1603 | + |
1604 | + |
1605 | +class TestPersonSubscribedBugTaskSearchListingView( |
1606 | + BugTaskViewsTestBase, TestCaseWithFactory): |
1607 | + """Tests for PersonAssignedBugTaskSearchListingView.""" |
1608 | + |
1609 | + view_name = '+subscribedbugs' |
1610 | + expected_context_description = '%s is subscribed to' |
1611 | + |
1612 | + def setUp(self): |
1613 | + super(TestPersonSubscribedBugTaskSearchListingView, self).setUp() |
1614 | + self.expected_for_search_unbatched = [ |
1615 | + self.subscribed_bug.default_bugtask, |
1616 | + self.owned_bug.default_bugtask, |
1617 | + ] |
1618 | + |
1619 | + |
1620 | +class TestPersonAffectingBugTaskSearchListingView( |
1621 | + BugTaskViewsTestBase, TestCaseWithFactory): |
1622 | + """Tests for PersonAffectingBugTaskSearchListingView.""" |
1623 | + |
1624 | + view_name = '+affectingbugs' |
1625 | + expected_context_description = 'affecting %s' |
1626 | + |
1627 | + def setUp(self): |
1628 | + super(TestPersonAffectingBugTaskSearchListingView, self).setUp() |
1629 | + # Bugs filed by this user are marked as affecting them by default, so |
1630 | + # the bug we filed is returned. |
1631 | + self.expected_for_search_unbatched = [ |
1632 | + self.owned_bug.default_bugtask, |
1633 | + self.affecting_bug.default_bugtask, |
1634 | + ] |
1635 | + |
1636 | + |
1637 | +class TestPersonRdfView(BrowserTestCase): |
1638 | + """Test the RDF view.""" |
1639 | + |
1640 | + layer = DatabaseFunctionalLayer |
1641 | + |
1642 | + def test_headers(self): |
1643 | + """The headers for the RDF view of a person should be as expected.""" |
1644 | + person = self.factory.makePerson() |
1645 | + content_disposition = 'attachment; filename="%s.rdf"' % person.name |
1646 | + browser = self.getViewBrowser(person, view_name='+rdf') |
1647 | + self.assertEqual( |
1648 | + content_disposition, browser.headers['Content-disposition']) |
1649 | + self.assertEqual( |
1650 | + 'application/rdf+xml', browser.headers['Content-type']) |
1651 | |
1652 | === removed file 'lib/lp/registry/browser/tests/test_person.py.OTHER' |
1653 | --- lib/lp/registry/browser/tests/test_person.py.OTHER 2012-01-31 14:46:25 +0000 |
1654 | +++ lib/lp/registry/browser/tests/test_person.py.OTHER 1970-01-01 00:00:00 +0000 |
1655 | @@ -1,69 +0,0 @@ |
1656 | -# Copyright 2009 Canonical Ltd. This software is licensed under the |
1657 | -# GNU Affero General Public License version 3 (see the file LICENSE). |
1658 | - |
1659 | -"""Test harness for person views unit tests.""" |
1660 | - |
1661 | -__metaclass__ = type |
1662 | - |
1663 | -from textwrap import dedent |
1664 | - |
1665 | -from lp.registry.browser.person import PersonView |
1666 | -from lp.services.config import config |
1667 | -from lp.services.webapp.servers import LaunchpadTestRequest |
1668 | -from lp.testing import ( |
1669 | - BrowserTestCase, |
1670 | - login_person, |
1671 | - TestCaseWithFactory, |
1672 | - ) |
1673 | -from lp.testing.layers import DatabaseFunctionalLayer |
1674 | - |
1675 | - |
1676 | -class PersonView_openid_identity_url_TestCase(TestCaseWithFactory): |
1677 | - """Tests for the public OpenID identifier shown on the profile page.""" |
1678 | - |
1679 | - layer = DatabaseFunctionalLayer |
1680 | - |
1681 | - def setUp(self): |
1682 | - TestCaseWithFactory.setUp(self) |
1683 | - self.user = self.factory.makePerson(name='eris') |
1684 | - self.request = LaunchpadTestRequest( |
1685 | - SERVER_URL="http://launchpad.dev/") |
1686 | - login_person(self.user, self.request) |
1687 | - self.view = PersonView(self.user, self.request) |
1688 | - # Marker allowing us to reset the config. |
1689 | - config.push(self.id(), '') |
1690 | - self.addCleanup(config.pop, self.id()) |
1691 | - |
1692 | - def test_should_be_profile_page_when_delegating(self): |
1693 | - """The profile page is the OpenID identifier in normal situation.""" |
1694 | - self.assertEquals( |
1695 | - 'http://launchpad.dev/~eris', self.view.openid_identity_url) |
1696 | - |
1697 | - def test_should_be_production_profile_page_when_not_delegating(self): |
1698 | - """When the profile page is not delegated, the OpenID identity URL |
1699 | - should be the one on the main production site.""" |
1700 | - config.push('non-delegating', dedent(''' |
1701 | - [vhost.mainsite] |
1702 | - openid_delegate_profile: False |
1703 | - |
1704 | - [launchpad] |
1705 | - non_restricted_hostname: prod.launchpad.dev |
1706 | - ''')) |
1707 | - self.assertEquals( |
1708 | - 'http://prod.launchpad.dev/~eris', self.view.openid_identity_url) |
1709 | - |
1710 | - |
1711 | -class TestPersonRdfView(BrowserTestCase): |
1712 | - """Test the RDF view.""" |
1713 | - |
1714 | - layer = DatabaseFunctionalLayer |
1715 | - |
1716 | - def test_headers(self): |
1717 | - """The headers for the RDF view of a person should be as expected.""" |
1718 | - person = self.factory.makePerson() |
1719 | - content_disposition = 'attachment; filename="%s.rdf"' % person.name |
1720 | - browser = self.getViewBrowser(person, view_name='+rdf') |
1721 | - self.assertEqual( |
1722 | - content_disposition, browser.headers['Content-disposition']) |
1723 | - self.assertEqual( |
1724 | - 'application/rdf+xml', browser.headers['Content-type']) |
1725 | |
1726 | === added file 'lib/lp/services/comments/browser/comment.py' |
1727 | --- lib/lp/services/comments/browser/comment.py 1970-01-01 00:00:00 +0000 |
1728 | +++ lib/lp/services/comments/browser/comment.py 2012-01-31 14:46:26 +0000 |
1729 | @@ -0,0 +1,53 @@ |
1730 | +# Copyright 2012 Canonical Ltd. This software is licensed under the |
1731 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
1732 | + |
1733 | +__metaclass__ = type |
1734 | + |
1735 | +__all__ = [ |
1736 | + 'download_body', |
1737 | + 'MAX_RENDERABLE', |
1738 | + ] |
1739 | + |
1740 | + |
1741 | +from lp.app.browser.tales import download_link |
1742 | +from lp.services.utils import obfuscate_email |
1743 | +from lp.services.webapp.publisher import ( |
1744 | + DataDownloadView, |
1745 | + LaunchpadView, |
1746 | + UserAttributeCache |
1747 | + ) |
1748 | + |
1749 | + |
1750 | +MAX_RENDERABLE = 10000 |
1751 | + |
1752 | + |
1753 | +class CommentBodyDownloadView(DataDownloadView, UserAttributeCache): |
1754 | + """Download the body text of a comment.""" |
1755 | + |
1756 | + content_type = 'text/plain' |
1757 | + |
1758 | + @property |
1759 | + def filename(self): |
1760 | + return 'comment-%d.txt' % self.context.index |
1761 | + |
1762 | + def getBody(self): |
1763 | + """The body of the HTTP response is the message body.""" |
1764 | + text = self.context.body_text |
1765 | + if self.user is None: |
1766 | + text = obfuscate_email(text) |
1767 | + return text |
1768 | + |
1769 | + |
1770 | +class CommentView(LaunchpadView): |
1771 | + """Base class for viewing IComment implementations.""" |
1772 | + |
1773 | + def download_link(self): |
1774 | + """Return an HTML link to download this comment's body.""" |
1775 | + url = self.context.download_url |
1776 | + length = len(self.context.body_text) |
1777 | + return download_link(url, "Download full text", length) |
1778 | + |
1779 | + |
1780 | +def download_body(comment, request): |
1781 | + """Respond to a request with the full message body as a download.""" |
1782 | + return CommentBodyDownloadView(comment, request)() |
1783 | |
1784 | === modified file 'lib/lp/services/comments/browser/configure.zcml' |
1785 | --- lib/lp/services/comments/browser/configure.zcml 2012-01-31 14:46:25 +0000 |
1786 | +++ lib/lp/services/comments/browser/configure.zcml 2012-01-31 14:46:26 +0000 |
1787 | @@ -18,6 +18,7 @@ |
1788 | |
1789 | <browser:pages |
1790 | for="lp.services.comments.interfaces.conversation.IComment" |
1791 | + class="lp.services.comments.browser.comment.CommentView" |
1792 | permission="zope.Public"> |
1793 | <browser:page |
1794 | name="+render" |
1795 | |
1796 | === modified file 'lib/lp/services/comments/browser/messagecomment.py' |
1797 | --- lib/lp/services/comments/browser/messagecomment.py 2012-01-31 14:46:25 +0000 |
1798 | +++ lib/lp/services/comments/browser/messagecomment.py 2012-01-31 14:46:26 +0000 |
1799 | @@ -6,6 +6,7 @@ |
1800 | __all__ = ['MessageComment'] |
1801 | |
1802 | |
1803 | +from lp.services.comments.browser.comment import MAX_RENDERABLE |
1804 | from lp.services.messages.interfaces.message import IMessage |
1805 | from lp.services.propertycache import cachedproperty |
1806 | |
1807 | @@ -49,6 +50,10 @@ |
1808 | return False |
1809 | return len(self.body_text) > self.comment_limit |
1810 | |
1811 | + @property |
1812 | + def too_long_to_render(self): |
1813 | + return len(self.body_text) > MAX_RENDERABLE |
1814 | + |
1815 | @cachedproperty |
1816 | def text_for_display(self): |
1817 | if not self.too_long: |
1818 | |
1819 | === added directory 'lib/lp/services/comments/browser/tests' |
1820 | === added file 'lib/lp/services/comments/browser/tests/__init__.py' |
1821 | === added file 'lib/lp/services/comments/browser/tests/test_comment.py' |
1822 | --- lib/lp/services/comments/browser/tests/test_comment.py 1970-01-01 00:00:00 +0000 |
1823 | +++ lib/lp/services/comments/browser/tests/test_comment.py 2012-01-31 14:46:26 +0000 |
1824 | @@ -0,0 +1,40 @@ |
1825 | +from lp.testing import ( |
1826 | + person_logged_in, |
1827 | + TestCaseWithFactory, |
1828 | +) |
1829 | +from lp.testing.layers import DatabaseFunctionalLayer |
1830 | +from lp.services.comments.browser.comment import CommentBodyDownloadView |
1831 | +from lp.services.webapp.servers import LaunchpadTestRequest |
1832 | + |
1833 | + |
1834 | +class FakeComment: |
1835 | + """Fake to avoid depending on a particular implementation.""" |
1836 | + |
1837 | + def __init__(self, body_text): |
1838 | + self.body_text = body_text |
1839 | + self.index = 5 |
1840 | + |
1841 | + |
1842 | +class TestCommentBodyDownloadView(TestCaseWithFactory): |
1843 | + """Test the CommentBodyDownloadView.""" |
1844 | + |
1845 | + layer = DatabaseFunctionalLayer |
1846 | + |
1847 | + def view(self, body): |
1848 | + comment = FakeComment(body) |
1849 | + request = LaunchpadTestRequest() |
1850 | + view = CommentBodyDownloadView(comment, request) |
1851 | + return view() |
1852 | + |
1853 | + def test_anonymous_body_obfuscated(self): |
1854 | + """For anonymous users, email addresses are obfuscated.""" |
1855 | + output = self.view('example@example.org') |
1856 | + self.assertNotIn(output, 'example@example.org') |
1857 | + self.assertIn(output, '<email address hidden>') |
1858 | + |
1859 | + def test_logged_in_not_obfuscated(self): |
1860 | + """For logged-in users, email addresses are not obfuscated.""" |
1861 | + with person_logged_in(self.factory.makePerson()): |
1862 | + output = self.view('example@example.org') |
1863 | + self.assertIn(output, 'example@example.org') |
1864 | + self.assertNotIn(output, '<email address hidden>') |
1865 | |
1866 | === modified file 'lib/lp/services/comments/interfaces/conversation.py' |
1867 | --- lib/lp/services/comments/interfaces/conversation.py 2012-01-31 14:46:25 +0000 |
1868 | +++ lib/lp/services/comments/interfaces/conversation.py 2012-01-31 14:46:26 +0000 |
1869 | @@ -18,6 +18,7 @@ |
1870 | from zope.schema import ( |
1871 | Bool, |
1872 | Datetime, |
1873 | + Int, |
1874 | Text, |
1875 | TextLine, |
1876 | ) |
1877 | @@ -28,6 +29,8 @@ |
1878 | class IComment(Interface): |
1879 | """A comment which may have a body or footer.""" |
1880 | |
1881 | + index = Int(title=u'The comment number', required=True, readonly=True) |
1882 | + |
1883 | extra_css_class = TextLine( |
1884 | description=_("A css class to apply to the comment's outer div.")) |
1885 | |
1886 | @@ -43,6 +46,10 @@ |
1887 | title=u'Whether the comment body is too long to display in full.', |
1888 | readonly=True) |
1889 | |
1890 | + too_long_to_render = Bool( |
1891 | + title=(u'Whether the comment body is so long that rendering is' |
1892 | + ' inappropriate.'), readonly=True) |
1893 | + |
1894 | text_for_display = Text( |
1895 | title=u'The comment text to be displayed in the UI.', readonly=True) |
1896 | |
1897 | @@ -50,6 +57,10 @@ |
1898 | description=_("The body text of the comment."), |
1899 | readonly=True) |
1900 | |
1901 | + download_url = Text( |
1902 | + description=_("URL for downloading full text."), |
1903 | + readonly=True) |
1904 | + |
1905 | comment_author = Reference( |
1906 | # Really IPerson. |
1907 | Interface, title=_("The author of the comment."), |
1908 | |
1909 | === modified file 'lib/lp/services/comments/templates/comment-body.pt' |
1910 | --- lib/lp/services/comments/templates/comment-body.pt 2012-01-31 14:46:25 +0000 |
1911 | +++ lib/lp/services/comments/templates/comment-body.pt 2012-01-31 14:46:26 +0000 |
1912 | @@ -3,10 +3,13 @@ |
1913 | xmlns:metal="http://xml.zope.org/namespaces/metal" |
1914 | omit-tag=""> |
1915 | |
1916 | + <a tal:replace="structure view/download_link" |
1917 | + tal:condition="context/too_long">Download full text</a> |
1918 | <div class="comment-text" itemprop="commentText" tal:content="structure |
1919 | - context/text_for_display/fmt:obfuscate-email/fmt:email-to-html" /> |
1920 | + context/text_for_display/fmt:obfuscate-email/fmt:email-to-html" /> |
1921 | + <tal:renderable condition="not: context/too_long_to_render"> |
1922 | <p tal:condition="context/too_long"> |
1923 | <a tal:attributes="href context/fmt:url">Read more...</a> |
1924 | </p> |
1925 | - |
1926 | + </tal:renderable > |
1927 | </tal:root> |
Aaron,
I've looked at the code changes thoroughly, and ran it locally to see how it behaved, and it looks good. I had a question about the placement of the download link but your rationale convinced me.
Thanks for clearing up my confusion about the page template.
--Brad