Merge lp:~abentley/launchpad/attachment-timeout into lp:launchpad
- attachment-timeout
- Merge into devel
| Status: | Merged | ||||
|---|---|---|---|---|---|
| Approved by: | Brad Crittenden on 2012-01-27 | ||||
| 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 | 2012-01-27 | Approve on 2012-01-27 |
|
Review via email:
|
|||
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