Merge lp:~abentley/launchpad/attachment-timeout into lp:launchpad

Proposed by Aaron Bentley
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
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 PackageDiffFormatterAPI to support reuse, and change it to use a "structured" string.

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_codereviewcomment -t test_branchmergeproposal -t xx-bug-comments-truncated.txt -t test_bugcomment -t

== 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/app/browser/tales.py
  lib/lp/bugs/browser/bugcomment.py
  lib/lp/bugs/browser/configure.zcml
  lib/lp/bugs/browser/tests/test_bugcomment.py
  lib/lp/bugs/interfaces/bugmessage.py
  lib/lp/bugs/stories/bugs/xx-bug-comments-truncated.txt
  lib/lp/code/browser/branchmergeproposal.py
  lib/lp/code/browser/codereviewcomment.py
  lib/lp/code/browser/configure.zcml
  lib/lp/code/browser/tests/test_branchmergeproposal.py
  lib/lp/code/browser/tests/test_codereviewcomment.py
  lib/lp/registry/browser/distroseriesdifference.py
  lib/lp/services/comments/browser/comment.py
  lib/lp/services/comments/browser/configure.zcml
  lib/lp/services/comments/browser/messagecomment.py
  lib/lp/services/comments/browser/tests/
  lib/lp/services/comments/browser/tests/__init__.py
  lib/lp/services/comments/browser/tests/test_comment.py
  lib/lp/services/comments/interfaces/conversation.py
  lib/lp/services/comments/templates/comment-body.pt

./lib/lp/bugs/stories/bugs/xx-bug-comments-truncated.txt
      35: want exceeds 78 characters.
      44: want exceeds 78 characters.

To post a comment you must log in.
Revision history for this message
Brad Crittenden (bac) wrote :

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

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/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('&mdash;', 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>