Merge lp:~sinzui/launchpad/team-without-admin into lp:launchpad

Proposed by Curtis Hovey on 2012-11-26
Status: Merged
Approved by: Curtis Hovey on 2012-11-27
Approved revision: no longer in the source branch.
Merged at revision: 16317
Proposed branch: lp:~sinzui/launchpad/team-without-admin
Merge into: lp:launchpad
Diff against target: 1546 lines (+629/-781)
7 files modified
lib/lp/registry/browser/person.py (+72/-58)
lib/lp/registry/browser/tests/person-views.txt (+0/-219)
lib/lp/registry/browser/tests/test_person_contact.py (+438/-0)
lib/lp/registry/browser/tests/user-to-user-views.txt (+0/-491)
lib/lp/registry/mail/notification.py (+4/-12)
lib/lp/registry/templates/contact-user.pt (+1/-1)
lib/lp/registry/tests/test_notification.py (+114/-0)
To merge this branch: bzr merge lp:~sinzui/launchpad/team-without-admin
Reviewer Review Type Date Requested Status
Benji York (community) code 2012-11-26 Approve on 2012-11-26
Review via email: mp+136274@code.launchpad.net

Commit Message

Explain when a team has no admins to contact.

Description of the Change

The "Contact this team's admins" form oopses when the team does not
have any admins. This can happen when the team owner leaves the team,
but fails to delegate the admin responsibility to others.

--------------------------------------------------------------------

RULES

    Pre-implementation: no one
    * Update send_direct_contact_email() to only record the message
      was sent if there was a message.
    * Update page to explain that the team team does not have any
      admins and advise the team owner to delegate one or more
      people to be admins.

    ADDENDUM
    * The view and the recipient set do check if there are people to send
      the email to, but the rule is broken. The recipient set used to
      send to the owner, now it sends to admins. The owner rules were
      removed, but the admin case needed to be handled.

QA

    * Visit https://qastaging.launchpad.net/~motu/+contactuser
    * Verify the page explains that the team has no admins so the
      owner should be contacted instead.

LoC

    I have more than 16,000 lines of credit.

LINT

    lib/lp/registry/browser/person.py
    lib/lp/registry/browser/tests/test_person_contact.py
    lib/lp/registry/mail/notification.py
    lib/lp/registry/templates/contact-user.pt
    lib/lp/registry/tests/test_notification.py

TEST

    ./bin/test -vvc lp.registry.tests.test_notification
    ./bin/test -vvc lp.registry.browser.tests.test_person_contact
    ./bin/test -vvc -t user-to-user -t person-views lp.registry.browser.tests

    ^ These last tests have lot over overlap. I think we want to move
    all the overlap into user-to-user.txt, then rewrite the tests as
    unit tests to horrendous reduce the duplication the doctests.

IMPLEMENTATION

I updated the send_direct_contact_email() to not assume that a message was
sent. I created a new test module for this code. It duplicates quota testing
in doctests. I will try to remove the duplication, or move the doctests into
the unit test before I land this branch.
    lib/lp/registry/mail/notification.py
    lib/lp/registry/tests/test_notification.py

I fixed the len() check on the recipient set. I choose to use a common
cached property that is already confirmed to select the correct recipients
instead of adding duplicate code to determine the length of the set for
TO_ADMIN and TO_MEMBER cases. The text shown when the there is no-one to
contact must have been wrong, it was just the same message shown in the form.
I added a property to the view to explain why the user or team could not
be contacted. I made the recipient set's primary_reason attribute public
so that the view did not need to duplicate code.
    lib/lp/registry/browser/person.py
    lib/lp/registry/browser/tests/test_person_contact.py
    lib/lp/registry/templates/contact-user.pt

To post a comment you must log in.
Benji York (benji) wrote :

This branch looks good. I had a couple of small questions/suggestions:

On line 83 of the diff, I don't understand why the outer parenthesis
exist on the right hand side of the equals:

    self._count_recipients = (len(self._all_recipients))

It would be nice if the tests in
lib/lp/registry/browser/tests/test_person_contact.py were arranged in
smaller test cases instead of one big test case per class.

review: Approve (code)
Curtis Hovey (sinzui) wrote :

On 11/26/2012 04:47 PM, Benji York wrote:
> Review: Approve code
>
> This branch looks good. I had a couple of small questions/suggestions:
>
> On line 83 of the diff, I don't understand why the outer parenthesis
> exist on the right hand side of the equals:
>
> self._count_recipients = (len(self._all_recipients))

The outer parens are cruft from the old code. I will remove them.

> It would be nice if the tests in
> lib/lp/registry/browser/tests/test_person_contact.py were arranged in
> smaller test cases instead of one big test case per class.

I will break these down.

--
Curtis Hovey
http://launchpad.net/~sinzui

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/registry/browser/person.py'
2--- lib/lp/registry/browser/person.py 2012-11-19 21:59:03 +0000
3+++ lib/lp/registry/browser/person.py 2012-11-27 20:11:21 +0000
4@@ -1504,7 +1504,54 @@
5 return self.context.latestKarma().count() > 0
6
7
8-class PersonView(LaunchpadView, FeedsMixin):
9+class ContactViaWebLinksMixin:
10+
11+ @cachedproperty
12+ def group_to_contact(self):
13+ """Contacting a team may contact different email addresses.
14+
15+ :return: the recipients of the message.
16+ :rtype: `ContactViaWebNotificationRecipientSet` constant:
17+ TO_USER
18+ TO_ADMINS
19+ TO_MEMBERS
20+ """
21+ return ContactViaWebNotificationRecipientSet(
22+ self.user, self.context).primary_reason
23+
24+ @property
25+ def contact_link_title(self):
26+ """Return the appropriate +contactuser link title for the tooltip."""
27+ ContactViaWeb = ContactViaWebNotificationRecipientSet
28+ if self.group_to_contact == ContactViaWeb.TO_USER:
29+ if self.viewing_own_page:
30+ return 'Send an email to yourself through Launchpad'
31+ else:
32+ return 'Send an email to this user through Launchpad'
33+ elif self.group_to_contact == ContactViaWeb.TO_MEMBERS:
34+ return "Send an email to your team's members through Launchpad"
35+ elif self.group_to_contact == ContactViaWeb.TO_ADMINS:
36+ return "Send an email to this team's admins through Launchpad"
37+ else:
38+ raise AssertionError('Unknown group to contact.')
39+
40+ @property
41+ def specific_contact_text(self):
42+ """Return the appropriate link text."""
43+ ContactViaWeb = ContactViaWebNotificationRecipientSet
44+ if self.group_to_contact == ContactViaWeb.TO_USER:
45+ # Note that we explicitly do not change the text to "Contact
46+ # yourself" when viewing your own page.
47+ return 'Contact this user'
48+ elif self.group_to_contact == ContactViaWeb.TO_MEMBERS:
49+ return "Contact this team's members"
50+ elif self.group_to_contact == ContactViaWeb.TO_ADMINS:
51+ return "Contact this team's admins"
52+ else:
53+ raise AssertionError('Unknown group to contact.')
54+
55+
56+class PersonView(LaunchpadView, FeedsMixin, ContactViaWebLinksMixin):
57 """A View class used in almost all Person's pages."""
58
59 @property
60@@ -1739,50 +1786,6 @@
61 return (
62 self.user is not None and self.context.is_valid_person_or_team)
63
64- @cachedproperty
65- def group_to_contact(self):
66- """Contacting a team may contact different email addresses.
67-
68- :return: the recipients of the message.
69- :rtype: `ContactViaWebNotificationRecipientSet` constant:
70- TO_USER
71- TO_ADMINS
72- TO_MEMBERS
73- """
74- return ContactViaWebNotificationRecipientSet(
75- self.user, self.context)._primary_reason
76-
77- @property
78- def contact_link_title(self):
79- """Return the appropriate +contactuser link title for the tooltip."""
80- ContactViaWeb = ContactViaWebNotificationRecipientSet
81- if self.group_to_contact == ContactViaWeb.TO_USER:
82- if self.viewing_own_page:
83- return 'Send an email to yourself through Launchpad'
84- else:
85- return 'Send an email to this user through Launchpad'
86- elif self.group_to_contact == ContactViaWeb.TO_MEMBERS:
87- return "Send an email to your team's members through Launchpad"
88- elif self.group_to_contact == ContactViaWeb.TO_ADMINS:
89- return "Send an email to this team's admins through Launchpad"
90- else:
91- raise AssertionError('Unknown group to contact.')
92-
93- @property
94- def specific_contact_text(self):
95- """Return the appropriate link text."""
96- ContactViaWeb = ContactViaWebNotificationRecipientSet
97- if self.group_to_contact == ContactViaWeb.TO_USER:
98- # Note that we explicitly do not change the text to "Contact
99- # yourself" when viewing your own page.
100- return 'Contact this user'
101- elif self.group_to_contact == ContactViaWeb.TO_MEMBERS:
102- return "Contact this team's members"
103- elif self.group_to_contact == ContactViaWeb.TO_ADMINS:
104- return "Contact this team's admins"
105- else:
106- raise AssertionError('Unknown group to contact.')
107-
108 @property
109 def should_show_polls_portlet(self):
110 # Circular imports.
111@@ -3919,7 +3922,7 @@
112 """
113 self.user = user
114 self.description = None
115- self._primary_reason = None
116+ self.primary_reason = None
117 self._primary_recipient = None
118 self._reason = None
119 self._header = None
120@@ -3955,12 +3958,12 @@
121 :param person_or_team: The party that is the context of the email.
122 :type person_or_team: `IPerson`.
123 """
124- if self._primary_reason is self.TO_USER:
125+ if self.primary_reason is self.TO_USER:
126 reason = (
127 'using the "Contact this user" link on your profile page\n'
128 '(%s)' % canonical_url(person_or_team))
129 header = 'ContactViaWeb user'
130- elif self._primary_reason is self.TO_ADMINS:
131+ elif self.primary_reason is self.TO_ADMINS:
132 reason = (
133 'using the "Contact this team\'s admins" link on the '
134 '%s team page\n(%s)' % (
135@@ -3968,7 +3971,7 @@
136 canonical_url(person_or_team)))
137 header = 'ContactViaWeb owner (%s team)' % person_or_team.name
138 else:
139- # self._primary_reason is self.TO_MEMBERS.
140+ # self.primary_reason is self.TO_MEMBERS.
141 reason = (
142 'to each member of the %s team using the '
143 '"Contact this team" link on the %s team page\n(%s)' % (
144@@ -3984,11 +3987,11 @@
145 :param person_or_team: The party that is the context of the email.
146 :type person_or_team: `IPerson`.
147 """
148- if self._primary_reason is self.TO_USER:
149+ if self.primary_reason is self.TO_USER:
150 return (
151 'You are contacting %s (%s).' %
152 (person_or_team.displayname, person_or_team.name))
153- elif self._primary_reason is self.TO_ADMINS:
154+ elif self.primary_reason is self.TO_ADMINS:
155 return (
156 'You are contacting the %s (%s) team admins.' %
157 (person_or_team.displayname, person_or_team.name))
158@@ -4008,12 +4011,12 @@
159 def _all_recipients(self):
160 """Set the cache of all recipients."""
161 all_recipients = {}
162- if self._primary_reason is self.TO_MEMBERS:
163+ if self.primary_reason is self.TO_MEMBERS:
164 team = self._primary_recipient
165 for recipient in team.getMembersWithPreferredEmails():
166 email = removeSecurityProxy(recipient).preferredemail.email
167 all_recipients[email] = recipient
168- elif self._primary_reason is self.TO_ADMINS:
169+ elif self.primary_reason is self.TO_ADMINS:
170 team = self._primary_recipient
171 for admin in team.adminmembers:
172 # This method is similar to getTeamAdminsEmailAddresses, but
173@@ -4063,13 +4066,12 @@
174 """The number of recipients in the set."""
175 if self._count_recipients is None:
176 recipient = self._primary_recipient
177- if self._primary_reason is self.TO_MEMBERS:
178- self._count_recipients = (
179- recipient.getMembersWithPreferredEmailsCount())
180+ if self.primary_reason in (self.TO_MEMBERS, self.TO_ADMINS):
181+ self._count_recipients = len(self._all_recipients)
182 elif recipient.is_valid_person_or_team:
183 self._count_recipients = 1
184 else:
185- # The user or team owner is deactivated.
186+ # The user is deactivated.
187 self._count_recipients = 0
188 return self._count_recipients
189
190@@ -4094,7 +4096,7 @@
191 recipients.
192 """
193 self._reset_state()
194- self._primary_reason = self._getPrimaryReason(person)
195+ self.primary_reason = self._getPrimaryReason(person)
196 self._primary_recipient = person
197 if reason is None:
198 reason, header = self._getReasonAndHeader(person)
199@@ -4216,6 +4218,18 @@
200 return throttle_date + interval
201
202 @property
203+ def contact_not_possible_reason(self):
204+ """The reason the person cannot be contacted."""
205+ if self.has_valid_email_address:
206+ return None
207+ elif self.recipients.primary_reason is self.recipients.TO_USER:
208+ return "The user is not active."
209+ elif self.recipients.primary_reason is self.recipients.TO_ADMINS:
210+ return "The team has no admins. Contact the team owner instead."
211+ else:
212+ return "The team has no members."
213+
214+ @property
215 def page_title(self):
216 """Return the appropriate pagetitle."""
217 if self.context.is_team:
218
219=== modified file 'lib/lp/registry/browser/tests/person-views.txt'
220--- lib/lp/registry/browser/tests/person-views.txt 2012-08-15 17:10:57 +0000
221+++ lib/lp/registry/browser/tests/person-views.txt 2012-11-27 20:11:21 +0000
222@@ -374,223 +374,6 @@
223 1
224
225
226-Person contacting another person
227---------------------------------
228-
229-The PersonView provides information to make the link to contact a user.
230-No Privileges Person can send a message to Sample Person, even though
231-Sample Person has hidden his email addresses.
232-
233- >>> login('no-priv@canonical.com')
234- >>> sample_person.hide_email_addresses
235- True
236-
237- >>> view = create_initialized_view(sample_person, '+index')
238- >>> print view.contact_link_title
239- Send an email to this user through Launchpad
240-
241-The EmailToPersonView provides many properties to the page template to
242-explain exactly who is being contacted.
243-
244- >>> view = create_initialized_view(sample_person, '+contactuser')
245- >>> print view.label
246- Contact user
247-
248- >>> print view.page_title
249- Contact this user
250-
251- >>> print view.recipients.description
252- You are contacting Sample Person (name12).
253-
254- >>> [recipient.name for recipient in view.recipients]
255- [u'name12']
256-
257-
258-Person contacting himself
259--------------------------
260-
261-For consistency and testing purposes, the "+contactuser" page is
262-available even when someone is looking at his own profile page. The
263-wording on the tooltip is different though. No Privileges Person can
264-send a message to himself.
265-
266- >>> no_priv = person_set.getByEmail('no-priv@canonical.com')
267- >>> view = create_initialized_view(no_priv, '+index')
268- >>> print view.contact_link_title
269- Send an email to yourself through Launchpad
270-
271-The EmailToPersonView provides the explanation about who is being
272-contacted.
273-
274- >>> view = create_initialized_view(no_priv, '+contactuser')
275- >>> print view.label
276- Contact user
277-
278- >>> print view.page_title
279- Contact yourself
280-
281- >>> print view.recipients.description
282- You are contacting No Privileges Person (no-priv).
283-
284- >>> [recipient.name for recipient in view.recipients]
285- [u'no-priv']
286-
287-
288-Non-team-admin contacting a Team
289---------------------------------
290-
291-The EmailToPersonView can be used by non-team-admins to contact the team
292-admins.
293-
294- >>> view = create_initialized_view(landscape_developers, '+contactuser')
295- >>> print view.label
296- Contact user
297-
298- >>> print view.page_title
299- Contact this team
300-
301- >>> print view.recipients.description
302- You are contacting the Landscape Developers (landscape-developers) team
303- admins.
304-
305- >>> [recipient.name for recipient in view.recipients]
306- [u'name12']
307-
308-
309-Team admin contacting a Team
310-----------------------------
311-
312-Team admins can contact their team to broadcast a message to all members.
313-Sample Person can contact his team, Landscape developers, even though they do
314-not have a contact address.
315-
316- >>> team_admin = login_person(landscape_developers.adminmembers[0])
317- >>> view = create_initialized_view(landscape_developers, '+index')
318- >>> print view.contact_link_title
319- Send an email to your team's members through Launchpad
320-
321-The EmailToPersonView can be used by admins to contact their team.
322-
323- >>> view = create_initialized_view(landscape_developers, '+contactuser')
324- >>> print view.label
325- Contact user
326-
327- >>> print view.page_title
328- Contact your team
329-
330- >>> print view.recipients.description
331- You are contacting 2 members of the Landscape Developers
332- (landscape-developers) team directly.
333-
334- >>> [recipient.name for recipient in view.recipients]
335- [u'salgado', u'name12']
336-
337-There are 2 recipients, so the object is treats as True.
338-
339- >>> recipients = view.recipients
340- >>> len(recipients)
341- 2
342-
343- >>> bool(recipients)
344- True
345-
346-If there is only one member of the team, who must therefore be the user
347-sending the email, and also be the team owner, The view provides a
348-special message just for him.
349-
350- >>> vanity_team = factory.makeTeam(
351- ... sample_person, displayname='Vanity', name='vanity')
352- >>> view = create_initialized_view(vanity_team, '+contactuser')
353- >>> print view.label
354- Contact user
355-
356- >>> print view.page_title
357- Contact your team
358-
359- >>> print view.recipients.description
360- You are contacting 1 member of the Vanity (vanity) team directly.
361-
362- >>> [recipient.name for recipient in view.recipients]
363- [u'name12']
364-
365-
366-Contact this user/team valid addresses and quotas
367--------------------------------------------------
368-
369-The EmailToPersonView has_valid_email_address property is normally True.
370-The is_possible property is True when contact_is_allowed and
371-has_valid_email_address are both True.
372-
373- >>> view = create_initialized_view(landscape_developers, '+contactuser')
374- >>> view.has_valid_email_address
375- True
376-
377- >>> view.contact_is_possible
378- True
379-
380-The EmailToPersonView provides two properties that check that the user
381-is_allowed to send emails because he has not exceeded the daily quota.
382-The next_try property is the date when the user will be allowed to send
383-emails again. The is_possible property is True when both
384-contact_is_allowed and as_valid_email_address are True.
385-
386-The daily quota is set to 3 emails per day. See the "Message quota" in
387-`doc/user-to-user.txt` to see how these two attributes are used.
388-
389-
390-Invalid users and anonymous contacters
391---------------------------------------
392-
393-Inactive users and users without a preferred email address are invalid
394-and cannot be contacted.
395-
396- >>> former_user = person_set.getByEmail('former-user@canonical.com',
397- ... filter_status=False)
398- >>> view = create_initialized_view(former_user, '+contactuser')
399- >>> view.request.response.getStatus()
400- 302
401-
402- >>> print view.request.response.getHeader('Location')
403- http://launchpad.dev/~former-user-deactivatedaccount
404-
405- >>> recipients = view.recipients
406- >>> len(recipients)
407- 0
408-
409- >>> bool(recipients)
410- False
411-
412-Anonymous users cannot contact anyone, they are redirected to the person
413-or team's profile page. This can happen when off-site links point to a
414-person or team's contact page.
415-
416- >>> login(ANONYMOUS)
417- >>> view = create_initialized_view(landscape_developers, '+contactuser')
418- >>> view.request.response.getStatus()
419- 302
420-
421- >>> print view.request.response.getHeader('Location')
422- http://launchpad.dev/~landscape-developers
423-
424-
425-Messages and subjects cannot be empty
426--------------------------------------
427-
428-Messages or subjects that contain only whitespace are treated as an
429-error that the user must fix.
430-
431- >>> login('test@canonical.com')
432- >>> view = create_initialized_view(
433- ... landscape_developers, '+contactuser', form={
434- ... 'field.field.from_': 'test@canonical.com',
435- ... 'field.subject': ' ',
436- ... 'field.message': ' ',
437- ... 'field.actions.send': 'Send',
438- ... })
439- >>> view.errors
440- [u'You must provide a subject and a message.']
441-
442-
443 Person +index "Personal package archives" section
444 -------------------------------------------------
445
446@@ -685,5 +468,3 @@
447 >>> view = create_initialized_view(sample_person, "+index")
448 >>> view.should_show_ppa_section
449 False
450-
451-
452
453=== added file 'lib/lp/registry/browser/tests/test_person_contact.py'
454--- lib/lp/registry/browser/tests/test_person_contact.py 1970-01-01 00:00:00 +0000
455+++ lib/lp/registry/browser/tests/test_person_contact.py 2012-11-27 20:11:21 +0000
456@@ -0,0 +1,438 @@
457+# Copyright 2012 Canonical Ltd. This software is licensed under the
458+# GNU Affero General Public License version 3 (see the file LICENSE).
459+"""Test views and helpers related to the contact person feature."""
460+
461+__metaclass__ = type
462+
463+from lp.app.browser.tales import DateTimeFormatterAPI
464+from lp.registry.browser.person import (
465+ ContactViaWebLinksMixin,
466+ ContactViaWebNotificationRecipientSet,
467+ )
468+from lp.services.identity.interfaces.emailaddress import EmailAddressStatus
469+from lp.services.messages.interfaces.message import IDirectEmailAuthorization
470+from lp.testing import (
471+ person_logged_in,
472+ TestCaseWithFactory,
473+ )
474+from lp.testing.layers import DatabaseFunctionalLayer
475+from lp.testing.views import create_initialized_view
476+
477+
478+class ContactViaWebNotificationRecipientSetTestCase(TestCaseWithFactory):
479+ """Tests the behaviour of ContactViaWebNotificationRecipientSet."""
480+
481+ layer = DatabaseFunctionalLayer
482+
483+ def test_len_to_user(self):
484+ # The recipient set length is based on the user activity.
485+ sender = self.factory.makePerson()
486+ user = self.factory.makePerson(email='him@eg.dom')
487+ self.assertEqual(
488+ 1, len(ContactViaWebNotificationRecipientSet(sender, user)))
489+ inactive_user = self.factory.makePerson(
490+ email_address_status=EmailAddressStatus.NEW)
491+ self.assertEqual(
492+ 0, len(
493+ ContactViaWebNotificationRecipientSet(sender, inactive_user)))
494+
495+ def test_len_to_admins(self):
496+ # The recipient set length is based on the number of admins.
497+ sender = self.factory.makePerson()
498+ team = self.factory.makeTeam()
499+ self.assertEqual(
500+ 1, len(ContactViaWebNotificationRecipientSet(sender, team)))
501+ with person_logged_in(team.teamowner):
502+ team.teamowner.leave(team)
503+ self.assertEqual(
504+ 0, len(ContactViaWebNotificationRecipientSet(sender, team)))
505+
506+ def test_len_to_members(self):
507+ # The recipient set length is based on the number members.
508+ member = self.factory.makePerson()
509+ sender_team = self.factory.makeTeam(members=[member])
510+ owner = sender_team.teamowner
511+ self.assertEqual(
512+ 2, len(ContactViaWebNotificationRecipientSet(owner, sender_team)))
513+ with person_logged_in(owner):
514+ owner.leave(sender_team)
515+ self.assertEqual(
516+ 1, len(ContactViaWebNotificationRecipientSet(owner, sender_team)))
517+
518+ def test_nonzero(self):
519+ # The recipient set can be used in boolean conditions.
520+ sender = self.factory.makePerson()
521+ user = self.factory.makePerson(email='him@eg.dom')
522+ self.assertTrue(
523+ bool(ContactViaWebNotificationRecipientSet(sender, user)))
524+ inactive_user = self.factory.makePerson(
525+ email_address_status=EmailAddressStatus.NEW)
526+ self.assertFalse(
527+ bool(ContactViaWebNotificationRecipientSet(sender, inactive_user)))
528+
529+ def test_getRecipientPersons_to_user(self):
530+ # The recipient set only contains the user.
531+ sender = self.factory.makePerson()
532+ user = self.factory.makePerson(email='him@eg.dom')
533+ recipient_set = ContactViaWebNotificationRecipientSet(sender, user)
534+ self.assertContentEqual(
535+ [('him@eg.dom', user)],
536+ list(recipient_set.getRecipientPersons()))
537+
538+ def test_getRecipientPersons_to_admins(self):
539+ # The recipient set only contains the team admins when the user
540+ # is not an admin of the team the user is contacting
541+ admin = self.factory.makePerson(email='admin@eg.dom')
542+ member = self.factory.makePerson(email='member@eg.dom')
543+ team = self.factory.makeTeam(owner=admin, members=[member])
544+ recipient_set = ContactViaWebNotificationRecipientSet(member, team)
545+ self.assertContentEqual(
546+ [('admin@eg.dom', admin)],
547+ list(recipient_set.getRecipientPersons()))
548+
549+ def test_getRecipientPersons_to_members(self):
550+ # The recipient set contains all the team members when the admin
551+ # is contacting the team.
552+ admin = self.factory.makePerson(email='admin@eg.dom')
553+ member = self.factory.makePerson(email='member@eg.dom')
554+ team = self.factory.makeTeam(owner=admin, members=[member])
555+ recipient_set = ContactViaWebNotificationRecipientSet(admin, team)
556+ self.assertContentEqual(
557+ [('admin@eg.dom', admin), ('member@eg.dom', member)],
558+ list(recipient_set.getRecipientPersons()))
559+
560+ def test_description_to_user(self):
561+ sender = self.factory.makePerson()
562+ user = self.factory.makePerson(name='pting')
563+ recipient_set = ContactViaWebNotificationRecipientSet(sender, user)
564+ self.assertEqual(
565+ 'You are contacting Pting (pting).',
566+ recipient_set.description)
567+
568+ def test_description_to_admin(self):
569+ member = self.factory.makePerson()
570+ team = self.factory.makeTeam(name='pting', members=[member])
571+ recipient_set = ContactViaWebNotificationRecipientSet(member, team)
572+ self.assertEqual(
573+ 'You are contacting the Pting (pting) team admins.',
574+ recipient_set.description)
575+
576+ def test_description_to_members(self):
577+ member = self.factory.makePerson()
578+ team = self.factory.makeTeam(name='pting', members=[member])
579+ admin = team.teamowner
580+ recipient_set = ContactViaWebNotificationRecipientSet(admin, team)
581+ self.assertEqual(
582+ 'You are contacting 2 members of the Pting (pting) team directly.',
583+ recipient_set.description)
584+
585+ def test_rationale_and_reason_user(self):
586+ sender = self.factory.makePerson()
587+ user = self.factory.makePerson(name='pting')
588+ recipient_set = ContactViaWebNotificationRecipientSet(sender, user)
589+ for email, recipient in recipient_set.getRecipientPersons():
590+ reason, rationale = recipient_set.getReason(email)
591+ self.assertEqual(
592+ 'using the "Contact this user" link on your profile page\n'
593+ '(http://launchpad.dev/~pting)',
594+ reason)
595+ self.assertEqual('ContactViaWeb user', rationale)
596+
597+ def test_rationale_and_reason_admin(self):
598+ sender = self.factory.makePerson()
599+ team = self.factory.makeTeam(name='pting')
600+ recipient_set = ContactViaWebNotificationRecipientSet(sender, team)
601+ for email, recipient in recipient_set.getRecipientPersons():
602+ reason, rationale = recipient_set.getReason(email)
603+ self.assertEqual(
604+ 'using the "Contact this team\'s admins" link '
605+ 'on the Pting team page\n'
606+ '(http://launchpad.dev/~pting)',
607+ reason)
608+ self.assertEqual('ContactViaWeb owner (pting team)', rationale)
609+
610+ def test_rationale_and_reason_members(self):
611+ team = self.factory.makeTeam(name='pting')
612+ sender = team.teamowner
613+ recipient_set = ContactViaWebNotificationRecipientSet(sender, team)
614+ for email, recipient in recipient_set.getRecipientPersons():
615+ reason, rationale = recipient_set.getReason(email)
616+ self.assertEqual(
617+ 'to each member of the Pting team using the '
618+ '"Contact this team" link on the Pting team page\n'
619+ '(http://launchpad.dev/~pting)',
620+ reason)
621+ self.assertEqual('ContactViaWeb member (pting team)', rationale)
622+
623+
624+class ContactViaWebLinksMixinTestCase(TestCaseWithFactory):
625+ """Tests the behaviour of ContactViaWebLinksMixin."""
626+
627+ layer = DatabaseFunctionalLayer
628+
629+ def test_PersonView_composition(self):
630+ # PersonView uses the mixin.
631+ sender = self.factory.makePerson()
632+ user = self.factory.makePerson(name='pting')
633+ with person_logged_in(sender):
634+ view = create_initialized_view(user, '+index')
635+ self.assertTrue(issubclass(view.__class__, ContactViaWebLinksMixin))
636+
637+ def test_contact_self(self):
638+ sender = self.factory.makePerson()
639+ with person_logged_in(sender):
640+ view = create_initialized_view(sender, '+index')
641+ self.assertEqual(
642+ 'Send an email to yourself through Launchpad',
643+ view.contact_link_title)
644+ self.assertIs(
645+ ContactViaWebNotificationRecipientSet.TO_USER,
646+ view.group_to_contact)
647+ self.assertEqual('Contact this user', view.specific_contact_text)
648+
649+ def test_contact_user(self):
650+ sender = self.factory.makePerson()
651+ user = self.factory.makePerson()
652+ with person_logged_in(sender):
653+ view = create_initialized_view(user, '+index')
654+ self.assertIs(
655+ ContactViaWebNotificationRecipientSet.TO_USER,
656+ view.group_to_contact)
657+ self.assertEqual('Contact this user', view.specific_contact_text)
658+ self.assertEqual(
659+ 'Send an email to this user through Launchpad',
660+ view.contact_link_title)
661+
662+ def test_contact_admins(self):
663+ sender = self.factory.makePerson()
664+ team = self.factory.makeTeam()
665+ with person_logged_in(sender):
666+ view = create_initialized_view(team, '+index')
667+ self.assertIs(
668+ ContactViaWebNotificationRecipientSet.TO_ADMINS,
669+ view.group_to_contact)
670+ self.assertEqual(
671+ "Contact this team's admins", view.specific_contact_text)
672+ self.assertEqual(
673+ "Send an email to this team's admins through Launchpad",
674+ view.contact_link_title)
675+
676+ def test_contact_members(self):
677+ team = self.factory.makeTeam()
678+ admin = team.teamowner
679+ with person_logged_in(admin):
680+ view = create_initialized_view(team, '+index')
681+ self.assertIs(
682+ ContactViaWebNotificationRecipientSet.TO_MEMBERS,
683+ view.group_to_contact)
684+ self.assertEqual(
685+ "Contact this team's members", view.specific_contact_text)
686+ self.assertEqual(
687+ "Send an email to your team's members through Launchpad",
688+ view.contact_link_title)
689+
690+
691+class EmailToPersonViewTestCase(TestCaseWithFactory):
692+ """Tests the behaviour of EmailToPersonView."""
693+
694+ layer = DatabaseFunctionalLayer
695+
696+ def makeForm(self, email, subject='subject', message='body'):
697+ return {
698+ 'field.field.from_': email,
699+ 'field.subject': subject,
700+ 'field.message': message,
701+ 'field.actions.send': 'Send',
702+ }
703+
704+ def makeThrottledSender(self):
705+ sender = self.factory.makePerson(email='me@eg.dom')
706+ old_message = self.factory.makeSignedMessage(email_address='me@eg.dom')
707+ authorization = IDirectEmailAuthorization(sender)
708+ for action in xrange(authorization.message_quota):
709+ authorization.record(old_message)
710+ return sender
711+
712+ def test_anonymous_redirected(self):
713+ # Anonymous users cannot use the form.
714+ user = self.factory.makePerson(name='him')
715+ view = create_initialized_view(user, '+contactuser')
716+ response = view.request.response
717+ self.assertEqual(302, response.getStatus())
718+ self.assertEqual(
719+ 'http://launchpad.dev/~him', response.getHeader('Location'))
720+
721+ def test_inactive_user_redirects(self):
722+ # The view explains that the user is inactive.
723+ sender = self.factory.makePerson()
724+ inactive_user = self.factory.makePerson(
725+ name='him', email_address_status=EmailAddressStatus.NEW)
726+ with person_logged_in(sender):
727+ view = create_initialized_view(inactive_user, '+contactuser')
728+ response = view.request.response
729+ self.assertEqual(302, response.getStatus())
730+ self.assertEqual(
731+ 'http://launchpad.dev/~him', response.getHeader('Location'))
732+
733+ def test_contact_not_possible_reason_to_user(self):
734+ # The view explains that the user is inactive.
735+ inactive_user = self.factory.makePerson(
736+ email_address_status=EmailAddressStatus.NEW)
737+ user = self.factory.makePerson()
738+ with person_logged_in(user):
739+ view = create_initialized_view(inactive_user, '+contactuser')
740+ self.assertEqual(
741+ "The user is not active.", view.contact_not_possible_reason)
742+
743+ def test_contact_not_possible_reason_to_admins(self):
744+ # The view explains that the team has no admins.
745+ team = self.factory.makeTeam()
746+ with person_logged_in(team.teamowner):
747+ team.teamowner.leave(team)
748+ user = self.factory.makePerson()
749+ with person_logged_in(user):
750+ view = create_initialized_view(team, '+contactuser')
751+ self.assertEqual(
752+ "The team has no admins. Contact the team owner instead.",
753+ view.contact_not_possible_reason)
754+
755+ def test_contact_not_possible_reason_to_members(self):
756+ # The view explains the team has no members.
757+ team = self.factory.makeTeam()
758+ with person_logged_in(team.teamowner):
759+ team.teamowner.leave(team)
760+ with person_logged_in(team.teamowner):
761+ view = create_initialized_view(team, '+contactuser')
762+ self.assertEqual(
763+ "The team has no members.", view.contact_not_possible_reason)
764+
765+ def test_has_valid_email_address(self):
766+ # The has_valid_email_address property checks the len of the
767+ # recipient set.
768+ team = self.factory.makeTeam()
769+ sender = self.factory.makePerson()
770+ with person_logged_in(sender):
771+ view = create_initialized_view(team, '+contactuser')
772+ self.assertTrue(view.has_valid_email_address)
773+ with person_logged_in(team.teamowner):
774+ team.teamowner.leave(team)
775+ with person_logged_in(sender):
776+ view = create_initialized_view(team, '+contactuser')
777+ self.assertFalse(view.has_valid_email_address)
778+
779+ def test_contact_is_allowed(self):
780+ # The contact_is_allowed property checks if the user has not exceeded
781+ # the quota..
782+ team = self.factory.makeTeam()
783+ sender = self.factory.makePerson()
784+ with person_logged_in(sender):
785+ view = create_initialized_view(team, '+contactuser')
786+ self.assertTrue(view.contact_is_allowed)
787+
788+ other_sender = self.makeThrottledSender()
789+ with person_logged_in(other_sender):
790+ view = create_initialized_view(team, '+contactuser')
791+ self.assertFalse(view.contact_is_allowed)
792+
793+ def test_contact_is_possible(self):
794+ # The contact_is_possible property checks has_valid_email_address
795+ # and contact_is_allowed.
796+ team = self.factory.makeTeam()
797+ sender = self.factory.makePerson()
798+ with person_logged_in(sender):
799+ view = create_initialized_view(team, '+contactuser')
800+ self.assertTrue(view.has_valid_email_address)
801+ self.assertTrue(view.contact_is_allowed)
802+ self.assertTrue(view.contact_is_possible)
803+
804+ other_sender = self.makeThrottledSender()
805+ with person_logged_in(other_sender):
806+ view = create_initialized_view(team, '+contactuser')
807+ self.assertTrue(view.has_valid_email_address)
808+ self.assertFalse(view.contact_is_allowed)
809+ self.assertFalse(view.contact_is_possible)
810+
811+ with person_logged_in(team.teamowner):
812+ team.teamowner.leave(team)
813+ with person_logged_in(sender):
814+ view = create_initialized_view(team, '+contactuser')
815+ self.assertTrue(view.contact_is_allowed)
816+ self.assertFalse(view.has_valid_email_address)
817+ self.assertFalse(view.contact_is_possible)
818+
819+ def test_user_contacting_user(self):
820+ sender = self.factory.makePerson()
821+ user = self.factory.makePerson(name='pting')
822+ with person_logged_in(sender):
823+ view = create_initialized_view(user, '+contactuser')
824+ self.assertEqual('Contact user', view.label)
825+ self.assertEqual('Contact this user', view.page_title)
826+
827+ def test_user_contacting_self(self):
828+ sender = self.factory.makePerson()
829+ with person_logged_in(sender):
830+ view = create_initialized_view(sender, '+contactuser')
831+ self.assertEqual('Contact user', view.label)
832+ self.assertEqual('Contact yourself', view.page_title)
833+
834+ def test_user_contacting_team(self):
835+ sender = self.factory.makePerson()
836+ team = self.factory.makeTeam(name='pting')
837+ with person_logged_in(sender):
838+ view = create_initialized_view(team, '+contactuser')
839+ self.assertEqual('Contact user', view.label)
840+ self.assertEqual('Contact this team', view.page_title)
841+
842+ def test_member_contacting_team(self):
843+ member = self.factory.makePerson()
844+ team = self.factory.makeTeam(name='pting', members=[member])
845+ with person_logged_in(member):
846+ view = create_initialized_view(team, '+contactuser')
847+ self.assertEqual('Contact user', view.label)
848+ self.assertEqual('Contact your team', view.page_title)
849+
850+ def test_admin_contacting_team(self):
851+ member = self.factory.makePerson()
852+ team = self.factory.makeTeam(name='pting', members=[member])
853+ admin = team.teamowner
854+ with person_logged_in(admin):
855+ view = create_initialized_view(team, '+contactuser')
856+ self.assertEqual('Contact user', view.label)
857+ self.assertEqual('Contact your team', view.page_title)
858+
859+ def test_submit(self):
860+ # The subject and message fields are required.
861+ sender = self.factory.makePerson(email='me@eg.dom')
862+ user = self.factory.makePerson(name='pting')
863+ form = self.makeForm('me@eg.dom', 'subject', 'body')
864+ with person_logged_in(sender):
865+ view = create_initialized_view(user, '+contactuser', form=form)
866+ self.assertEqual([], view.errors)
867+ notes = [n.message for n in view.request.response.notifications]
868+ self.assertEqual(['Message sent to Pting'], notes)
869+
870+ def test_missing_subject_and_message(self):
871+ # The subject and message fields are required.
872+ sender = self.factory.makePerson(email='me@eg.dom')
873+ user = self.factory.makePerson()
874+ form = self.makeForm('me@eg.dom', ' ', ' ')
875+ with person_logged_in(sender):
876+ view = create_initialized_view(user, '+contactuser', form=form)
877+ self.assertEqual(
878+ [u'You must provide a subject and a message.'], view.errors)
879+
880+ def test_submitted_after_quota(self):
881+ # The view explains when a message was not sent because the quota
882+ # was exceeded.
883+ user = self.factory.makePerson()
884+ sender = self.makeThrottledSender()
885+ form = self.makeForm('me@eg.dom')
886+ with person_logged_in(sender):
887+ view = create_initialized_view(user, '+contactuser', form=form)
888+ notification = [
889+ 'Your message was not sent because you have exceeded your daily '
890+ 'quota of 3 messages to contact users. Try again %s.' %
891+ DateTimeFormatterAPI(view.next_try).approximatedate()
892+ ]
893+ notes = [n.message for n in view.request.response.notifications]
894+ self.assertContentEqual(notification, notes)
895
896=== removed file 'lib/lp/registry/browser/tests/user-to-user-views.txt'
897--- lib/lp/registry/browser/tests/user-to-user-views.txt 2012-04-10 14:01:17 +0000
898+++ lib/lp/registry/browser/tests/user-to-user-views.txt 1970-01-01 00:00:00 +0000
899@@ -1,491 +0,0 @@
900-User-to-user direct email contact
901-=================================
902-
903-A Launchpad user can contact another Launchpad user directly, even if
904-the recipient is hiding their email addresses.
905-
906- >>> def create_view(sender, recipient, form=None):
907- ... return create_initialized_view(
908- ... recipient, '+contactuser',
909- ... form=form, principal=sender)
910-
911- >>> def print_notifications(view):
912- ... for notification in view.request.notifications:
913- ... print notification.message
914-
915-For example, let's say No Privileges Person wants to contact Salgado...
916-
917- >>> from zope.component import getUtility
918- >>> from lp.registry.interfaces.person import IPersonSet
919- >>> person_set = getUtility(IPersonSet)
920- >>> no_priv = person_set.getByName('no-priv')
921- >>> salgado = person_set.getByName('salgado')
922-
923-...No Priv would start by going to Salgado's +contactuser page.
924-
925- >>> from lp.testing import login
926- >>> ignored = login_person(no_priv)
927- >>> view = create_view(no_priv, salgado)
928-
929-This contact is allowed.
930-
931- >>> print view.label
932- Contact user
933-
934- >>> view.contact_is_allowed
935- True
936-
937-No Priv changes her mind though.
938-
939- >>> print view.cancel_url
940- http://launchpad.dev/~salgado
941-
942-No Priv decides, what the heck, let's contact Salgado after all.
943-
944- >>> view = create_view(
945- ... no_priv, salgado, {
946- ... 'field.field.from_': 'no-priv@canonical.com',
947- ... 'field.subject': 'Hello Salgado',
948- ... 'field.message': 'Can you tell me about your project?',
949- ... 'field.actions.send': 'Send',
950- ... })
951- >>> print_notifications(view)
952- Message sent to Guilherme Salgado
953-
954- # Capture the date of the last contact for later.
955-
956- >>> from lp.services.config import config
957- >>> from lp.services.messages.model.message import UserToUserEmail
958- >>> from lazr.config import as_timedelta
959- >>> from storm.locals import Store
960- >>> first_contact = Store.of(no_priv).find(
961- ... UserToUserEmail,
962- ... UserToUserEmail.sender == no_priv).one()
963- >>> expires = first_contact.date_sent + as_timedelta(
964- ... config.launchpad.user_to_user_throttle_interval)
965-
966-No Priv sends two more messages to Salgado. Each of these are allowed
967-too.
968-
969- >>> view = create_view(
970- ... no_priv, salgado, {
971- ... 'field.field.from_': 'no-priv@canonical.com',
972- ... 'field.subject': 'Hello Salgado',
973- ... 'field.message': 'Can you tell me about your project?',
974- ... 'field.actions.send': 'Send',
975- ... })
976- >>> print_notifications(view)
977- Message sent to Guilherme Salgado
978-
979- >>> view = create_view(
980- ... no_priv, salgado, {
981- ... 'field.field.from_': 'no-priv@canonical.com',
982- ... 'field.subject': 'Hello Salgado',
983- ... 'field.message': 'Can you tell me about your project?',
984- ... 'field.actions.send': 'Send',
985- ... })
986- >>> print_notifications(view)
987- Message sent to Guilherme Salgado
988-
989-Now however, No Priv had reached her quota for direct user-to-user
990-contact and is not allowed to send a fourth message today.
991-
992- >>> view = create_view(no_priv, salgado)
993- >>> view.contact_is_allowed
994- False
995-
996-No Priv can try again later.
997-
998- >>> view.next_try == expires
999- True
1000-
1001-As a corner case, let's say the number of notifications allowed was
1002-greater yesterday than it was today.
1003-
1004- >>> config.push('seven_allowed', """\
1005- ... [launchpad]
1006- ... user_to_user_max_messages: 7
1007- ... """)
1008-
1009-No Priv can actually try again right now.
1010-
1011- >>> from datetime import datetime
1012- >>> import pytz
1013- >>> view.next_try <= datetime.now(pytz.timezone('UTC'))
1014- True
1015-
1016-So, No Priv sends four more emails.
1017-
1018- >>> for i in range(4):
1019- ... assert create_view(no_priv, salgado).contact_is_allowed, (
1020- ... 'Contact was not allowed? %s' % i)
1021- ... view = create_view(
1022- ... no_priv, salgado, {
1023- ... 'field.field.from_': 'no-priv@canonical.com',
1024- ... 'field.subject': 'Hello Salgado',
1025- ... 'field.message': 'Can you tell me about your project?',
1026- ... 'field.actions.send': 'Send',
1027- ... })
1028- ... print_notifications(view)
1029- Message sent to Guilherme Salgado
1030- Message sent to Guilherme Salgado
1031- Message sent to Guilherme Salgado
1032- Message sent to Guilherme Salgado
1033-
1034-No Priv has once again reached her limit of emails.
1035-
1036- >>> view = create_view(no_priv, salgado)
1037- >>> view.contact_is_allowed
1038- False
1039-
1040- >>> view.next_try == expires
1041- True
1042-
1043-The configuration changes back to allow only three emails.
1044-
1045- >>> config.pop('seven_allowed')
1046- (...)
1047-
1048- >>> contacts = Store.of(no_priv).find(
1049- ... UserToUserEmail,
1050- ... UserToUserEmail.sender == no_priv)
1051- >>> contact = list(contacts)[4]
1052- >>> expires = contact.date_sent + as_timedelta(
1053- ... config.launchpad.user_to_user_throttle_interval)
1054-
1055-
1056-Non-ASCII names
1057----------------
1058-
1059-Carlos has non-ASCII characters in his name. When he sends a message to
1060-a user, his real name will be properly RFC 2047 encoded.
1061-
1062- >>> transaction.abort()
1063- >>> from lp.services.mail import stub
1064- >>> del stub.test_emails[:]
1065- >>> len(stub.test_emails)
1066- 0
1067-
1068- >>> carlos = person_set.getByName('carlos')
1069- >>> login('carlos@canonical.com')
1070- >>> view = create_view(
1071- ... carlos, no_priv, {
1072- ... 'field.field.from_': 'carlos@canonical.com',
1073- ... 'field.subject': 'Hello No Priv',
1074- ... 'field.message': 'I see funny characters',
1075- ... 'field.actions.send': 'Send',
1076- ... })
1077- >>> transaction.commit()
1078-
1079- >>> len(stub.test_emails)
1080- 1
1081-
1082- >>> from_addr, to_addrs, raw_msg = stub.test_emails.pop()
1083- >>> print raw_msg
1084- Content-Type: text/plain; charset="us-ascii"
1085- ...
1086- From: =?utf-8?q?Carlos_Perell=C3=B3_Mar=C3=ADn?= <carlos@canonical.com>
1087- To: No Privileges Person <no-priv@canonical.com>
1088- ...
1089-
1090-Similarly, if Carlos is the recipient of a message, his real name will
1091-be properly RFC 2047 encoded as well.
1092-
1093- >>> del stub.test_emails[:]
1094-
1095- >>> login('no-priv@canonical.com')
1096- >>> view = create_view(
1097- ... no_priv, carlos, {
1098- ... 'field.field.from_': 'no-priv@canonical.com',
1099- ... 'field.subject': 'Hello Carlos',
1100- ... 'field.message': 'I see funny characters',
1101- ... 'field.actions.send': 'Send',
1102- ... })
1103- >>> transaction.commit()
1104-
1105- >>> len(stub.test_emails)
1106- 1
1107-
1108- >>> from_addr, to_addrs, raw_msg = stub.test_emails.pop()
1109- >>> print raw_msg
1110- Content-Type: text/plain; charset="us-ascii"
1111- ...
1112- From: No Privileges Person <no-priv@canonical.com>
1113- To: =?utf-8?q?Carlos_Perell=C3=B3_Mar=C3=ADn?= <carlos@canonical.com>
1114- ...
1115-
1116-
1117-Hidden addresses
1118-----------------
1119-
1120-Salgado decides to hide his email addresses.
1121-
1122- >>> ignored = login_person(salgado)
1123- >>> salgado.hide_email_addresses = True
1124-
1125-Anne contacts Salgado even though his email addresses are hidden.
1126-
1127- >>> anne = factory.makePerson(email='anne@example.com', name='anne')
1128- >>> ignored = login_person(anne)
1129-
1130- >>> view = create_view(
1131- ... anne, salgado, {
1132- ... 'field.field.from_': 'anne@example.com',
1133- ... 'field.subject': 'Hello Salgado',
1134- ... 'field.message': 'It is nice to meet you',
1135- ... 'field.actions.send': 'Send',
1136- ... })
1137- >>> print_notifications(view)
1138- Message sent to Guilherme Salgado
1139-
1140-
1141-Contacting teams
1142-----------------
1143-
1144-Teams can also be contacted directly, regardless of whether they have no
1145-official contact address, use a Launchpad mailing list, or have the
1146-contact address set to an explicit address.
1147-
1148- # Clear out left over crud.
1149-
1150- >>> transaction.commit()
1151- >>> del stub.test_emails[:]
1152-
1153- >>> from email import message_from_string
1154- >>> def print_messages():
1155- ... message_count = 0
1156- ... message_subjects = set()
1157- ... message_senders = set()
1158- ... message_recipients = set()
1159- ... message_bodies = set()
1160- ... while stub.test_emails:
1161- ... from_addr, to_addrs, raw_msg = stub.test_emails.pop()
1162- ... message = message_from_string(raw_msg)
1163- ... message_count += 1
1164- ... message_subjects.add(message['subject'])
1165- ... message_senders.add(message['from'])
1166- ... message_recipients.add(message['to'])
1167- ... message_bodies.add(message.get_payload())
1168- ... print 'Senders:', message_senders
1169- ... print 'Subjects:', message_subjects
1170- ... print 'Bodies:'
1171- ... for body in sorted(message_bodies):
1172- ... print body
1173- ... print '# of Messages:', message_count
1174- ... print 'Recipients:'
1175- ... for recipient in sorted(message_recipients):
1176- ... print ' ', recipient
1177-
1178-
1179-Non-member to team
1180-..................
1181-
1182-Non-members may only contact the team owner.
1183-
1184- >>> guadamen = person_set.getByName('guadamen')
1185- >>> bart = factory.makePerson(email='bart@example.com', name='bart')
1186- >>> ignored = login_person(bart)
1187-
1188- >>> view = create_view(
1189- ... bart, guadamen, {
1190- ... 'field.field.from_': 'bart@example.com',
1191- ... 'field.subject': 'Hello Guadamen',
1192- ... 'field.message': 'Can one of you help me?',
1193- ... 'field.actions.send': 'Send',
1194- ... })
1195- >>> print_notifications(view)
1196- Message sent to GuadaMen
1197-
1198- >>> transaction.commit()
1199- >>> print_messages()
1200- Senders: set(['Bart <bart@example.com>'])
1201- Subjects: set(['Hello Guadamen'])
1202- Bodies:
1203- Can one of you help me?
1204- --
1205- This message was sent from Launchpad by
1206- Bart (http://launchpad.dev/~bart)
1207- using the "Contact this team's admins" link on the GuadaMen team page
1208- (http://launchpad.dev/~guadamen).
1209- For more information see
1210- https://help.launchpad.net/YourAccount/ContactingPeople
1211- # of Messages: 2
1212- Recipients:
1213- Foo Bar <foo.bar@canonical.com>
1214- Ubuntu Team <support@ubuntu.com>
1215-
1216-
1217-Member to team
1218-..............
1219-
1220-Foo Bar is a member of Guadamen team, he is not restricted to contacting
1221-the team owner. The Guadamen team has no contact address, so contacting
1222-them contacts all its members directly.
1223-
1224- >>> login('foo.bar@canonical.com')
1225- >>> foo_bar = person_set.getByName('name16')
1226- >>> view = create_view(
1227- ... foo_bar, guadamen, {
1228- ... 'field.field.from_': 'foo.bar@canonical.com',
1229- ... 'field.subject': 'Hello Guadamen',
1230- ... 'field.message': 'Can one of you help me?',
1231- ... 'field.actions.send': 'Send',
1232- ... })
1233- >>> print_notifications(view)
1234- Message sent to GuadaMen
1235-
1236-There are 10 members of the team, so exactly 10 unique copies of the
1237-message are sent, one to each team member. Everyone gets a message with
1238-the same subject and body from the same sender.
1239-
1240- >>> transaction.commit()
1241- >>> print_messages()
1242- Senders: set(['Foo Bar <foo.bar@canonical.com>'])
1243- Subjects: set(['Hello Guadamen'])
1244- Bodies:
1245- Can one of you help me?
1246- --
1247- This message was sent from Launchpad by
1248- Foo Bar (http://launchpad.dev/~name16)
1249- to each member of the GuadaMen team using the "Contact this team" link on
1250- the GuadaMen team page (http://launchpad.dev/~guadamen).
1251- For more information see
1252- https://help.launchpad.net/YourAccount/ContactingPeople
1253- # of Messages: 10
1254- Recipients:
1255- Alexander Limi <limi@plone.org>
1256- Celso Providelo <celso.providelo@canonical.com>
1257- Colin Watson <colin.watson@ubuntulinux.com>
1258- Daniel Silverstone <daniel.silverstone@canonical.com>
1259- Edgar Bursic <edgar@monteparadiso.hr>
1260- Foo Bar <foo.bar@canonical.com>
1261- Jeff Waugh <jeff.waugh@ubuntulinux.com>
1262- Mark Shuttleworth <mark@example.com>
1263- Steve Alexander <steve.alexander@ubuntulinux.com>
1264- Ubuntu Team <support@ubuntu.com>
1265-
1266- >>> transaction.commit()
1267-
1268-Message quota
1269--------------
1270-
1271-The EmailToPersonView provides two properties that check that the user
1272-is_allowed to send emails because he has not exceeded the daily quota.
1273-The next_try property is the date when the user will be allowed to send
1274-emails again. The is_possible property will be False if is_allowed is
1275-False.
1276-
1277- >>> view = create_view(
1278- ... foo_bar, guadamen, {
1279- ... 'field.field.from_': 'foo.bar@canonical.com',
1280- ... 'field.subject': 'Hello Guadamen',
1281- ... 'field.message': 'Can one of you help me?',
1282- ... 'field.actions.send': 'Send',
1283- ... })
1284- >>> view.contact_is_allowed
1285- True
1286-
1287- >>> view.contact_is_possible
1288- True
1289-
1290- >>> view.initialize()
1291- >>> transaction.commit()
1292-
1293-Foo Bar has now reached his quota and can send no more contact messages
1294-today.
1295-
1296- >>> view = create_view(
1297- ... foo_bar, guadamen, {
1298- ... 'field.field.from_': 'foo.bar@canonical.com',
1299- ... 'field.subject': 'My last question for Guadamen',
1300- ... 'field.message': 'Really, can one of you help me!',
1301- ... 'field.actions.send': 'Send',
1302- ... })
1303- >>> view.contact_is_allowed
1304- False
1305-
1306- >>> view.next_try
1307- datetime.datetime...
1308-
1309- >>> view.contact_is_possible
1310- False
1311-
1312- >>> print_notifications(view)
1313- Your message was not sent because you have exceeded your daily quota of
1314- 3 messages to contact users. Try again in ...
1315-
1316-
1317-Identifying information
1318------------------------
1319-
1320-Every contact message has a special Launchpad header so that people can
1321-tell that the message came to them through Launchpad. It has a footer
1322-that contains an explanation as well.
1323-
1324- >>> cris = factory.makePerson(email='cris@example.com', name='cris')
1325- >>> dave = factory.makePerson(email='dave@example.com', name='dave')
1326- >>> ignored = login_person(cris)
1327-
1328- >>> view = create_view(
1329- ... cris, dave, {
1330- ... 'field.field.from_': 'cris@example.com',
1331- ... 'field.subject': 'Hi Dave',
1332- ... 'field.message': 'Can you help me?' ,
1333- ... 'field.actions.send': 'Send',
1334- ... })
1335- >>> transaction.commit()
1336- >>> from_addr, to_addrs, raw_msg = stub.test_emails.pop()
1337- >>> print raw_msg
1338- Content-Type: text/plain; charset="us-ascii"
1339- ...
1340- From: Cris <cris@example.com>
1341- To: Dave <dave@example.com>
1342- Subject: Hi Dave
1343- ...
1344- X-Launchpad-Message-Rationale: ContactViaWeb user
1345- ...
1346- <BLANKLINE>
1347- Can you help me?
1348- --
1349- This message was sent from Launchpad by
1350- Cris (http://launchpad.dev/~cris)
1351- using the "Contact this user" link on your profile page
1352- (http://launchpad.dev/~dave).
1353- For more information see
1354- https://help.launchpad.net/YourAccount/ContactingPeople
1355-
1356-
1357-Message wrapping
1358-----------------
1359-
1360-The message body is wrapped at 72 characters. The footer is not wrapped,
1361-but a new line is started after the names of the sender and the recient
1362-to minimise long lines.
1363-
1364- >>> login('test@canonical.com')
1365- >>> sample_person = person_set.getByEmail('test@canonical.com')
1366- >>> landscape_developers = person_set.getByName('landscape-developers')
1367- >>> view = create_view(
1368- ... sample_person, landscape_developers, {
1369- ... 'field.field.from_': 'test@canonical.com',
1370- ... 'field.subject': 'Wrapping test ',
1371- ... 'field.message': 'Can you help me? ' * 8,
1372- ... 'field.actions.send': 'Send',
1373- ... })
1374- >>> transaction.commit()
1375- >>> from_addr, to_addrs, raw_msg = stub.test_emails.pop()
1376- >>> header, body = raw_msg.split('\n\n')
1377- >>> for line in body.split('\n'):
1378- ... print "^%s$" % line
1379- ^Can you help me? Can you help me? Can you help me? Can you help me? Can$
1380- ^you help me? Can you help me? Can you help me? Can you help me?$
1381- ^-- $
1382- ^This message was sent from Launchpad by$
1383- ^Sample Person (http://launchpad.dev/~name12)$
1384- ^to each member of the Landscape Developers team using the "Contact this$
1385- ^team" link on the Landscape Developers team page$
1386- ^(http://launchpad.dev/~landscape-developers).$
1387- ^For more information see$
1388- ^https://help.launchpad.net/YourAccount/ContactingPeople$
1389-
1390-
1391
1392=== modified file 'lib/lp/registry/mail/notification.py'
1393--- lib/lp/registry/mail/notification.py 2012-08-14 23:27:07 +0000
1394+++ lib/lp/registry/mail/notification.py 2012-11-27 20:11:21 +0000
1395@@ -396,15 +396,7 @@
1396 message['X-Launchpad-Message-Rationale'] = rational_header
1397 # Send the message.
1398 sendmail(message, bulk=False)
1399- # BarryWarsaw 19-Nov-2008: If any messages were sent, record the fact that
1400- # the sender contacted the team. This is not perfect though because we're
1401- # really recording the fact that the person contacted the last member of
1402- # the team. There's little we can do better though because the team has
1403- # no contact address, and so there isn't actually an address to record as
1404- # the team's recipient. It currently doesn't matter though because we
1405- # don't actually do anything with the recipient information yet. All we
1406- # care about is the sender, for quota purposes. We definitely want to
1407- # record the contact outside the above loop though, because if there are
1408- # 10 members of the team with no contact address, one message should not
1409- # consume the sender's entire quota.
1410- authorization.record(message)
1411+ # Use the information from the last message sent to record the action
1412+ # taken. The record will be used to throttle user-to-user emails.
1413+ if message is not None:
1414+ authorization.record(message)
1415
1416=== modified file 'lib/lp/registry/templates/contact-user.pt'
1417--- lib/lp/registry/templates/contact-user.pt 2012-02-10 19:52:27 +0000
1418+++ lib/lp/registry/templates/contact-user.pt 2012-11-27 20:11:21 +0000
1419@@ -31,7 +31,7 @@
1420 </p>
1421 </tal:disallowed>
1422 <tal:disallowed tal:condition="not:view/has_valid_email_address">
1423- <p tal:content="view/recipients/description"/>
1424+ <p tal:content="view/contact_not_possible_reason"/>
1425 </tal:disallowed>
1426 </div>
1427 </body>
1428
1429=== added file 'lib/lp/registry/tests/test_notification.py'
1430--- lib/lp/registry/tests/test_notification.py 1970-01-01 00:00:00 +0000
1431+++ lib/lp/registry/tests/test_notification.py 2012-11-27 20:11:21 +0000
1432@@ -0,0 +1,114 @@
1433+# Copyright 2012 Canonical Ltd. This software is licensed under the
1434+# GNU Affero General Public License version 3 (see the file LICENSE).
1435+
1436+"""Test notification classes and functions."""
1437+
1438+__metaclass__ = type
1439+
1440+from lp.registry.mail.notification import send_direct_contact_email
1441+from lp.services.mail.notificationrecipientset import NotificationRecipientSet
1442+from lp.services.messages.interfaces.message import (
1443+ IDirectEmailAuthorization,
1444+ QuotaReachedError,
1445+ )
1446+from lp.testing import TestCaseWithFactory
1447+from lp.testing.layers import DatabaseFunctionalLayer
1448+from lp.testing.mail_helpers import pop_notifications
1449+
1450+
1451+class SendDirectContactEmailTestCase(TestCaseWithFactory):
1452+
1453+ layer = DatabaseFunctionalLayer
1454+
1455+ def test_send_message(self):
1456+ self.factory.makePerson(email='me@eg.dom', name='me')
1457+ user = self.factory.makePerson(email='him@eg.dom', name='him')
1458+ subject = 'test subject'
1459+ body = 'test body'
1460+ recipients_set = NotificationRecipientSet()
1461+ recipients_set.add(user, 'test reason', 'test rationale')
1462+ pop_notifications()
1463+ send_direct_contact_email('me@eg.dom', recipients_set, subject, body)
1464+ notifications = pop_notifications()
1465+ notification = notifications[0]
1466+ self.assertEqual(1, len(notifications))
1467+ self.assertEqual('Me <me@eg.dom>', notification['From'])
1468+ self.assertEqual('Him <him@eg.dom>', notification['To'])
1469+ self.assertEqual(subject, notification['Subject'])
1470+ self.assertEqual(
1471+ 'test rationale', notification['X-Launchpad-Message-Rationale'])
1472+ self.assertIs(None, notification['Precedence'])
1473+ self.assertTrue('launchpad' in notification['Message-ID'])
1474+ self.assertEqual(
1475+ '\n'.join([
1476+ '%s' % body,
1477+ '-- ',
1478+ 'This message was sent from Launchpad by',
1479+ 'Me (http://launchpad.dev/~me)',
1480+ 'test reason.',
1481+ 'For more information see',
1482+ 'https://help.launchpad.net/YourAccount/ContactingPeople']),
1483+ notification.get_payload())
1484+
1485+ def test_quota_reached_error(self):
1486+ # An error is raised if the user has reached the daily quota.
1487+ self.factory.makePerson(email='me@eg.dom', name='me')
1488+ user = self.factory.makePerson(email='him@eg.dom', name='him')
1489+ recipients_set = NotificationRecipientSet()
1490+ old_message = self.factory.makeSignedMessage(email_address='me@eg.dom')
1491+ authorization = IDirectEmailAuthorization(user)
1492+ for action in xrange(authorization.message_quota):
1493+ authorization.record(old_message)
1494+ self.assertRaises(
1495+ QuotaReachedError, send_direct_contact_email,
1496+ 'me@eg.dom', recipients_set, 'subject', 'body')
1497+
1498+ def test_empty_recipient_set(self):
1499+ # The recipient set can be empty. No messages are sent and the
1500+ # action does not count toward the daily quota.
1501+ self.factory.makePerson(email='me@eg.dom', name='me')
1502+ user = self.factory.makePerson(email='him@eg.dom', name='him')
1503+ recipients_set = NotificationRecipientSet()
1504+ old_message = self.factory.makeSignedMessage(email_address='me@eg.dom')
1505+ authorization = IDirectEmailAuthorization(user)
1506+ for action in xrange(authorization.message_quota - 1):
1507+ authorization.record(old_message)
1508+ pop_notifications()
1509+ send_direct_contact_email(
1510+ 'me@eg.dom', recipients_set, 'subject', 'body')
1511+ notifications = pop_notifications()
1512+ self.assertEqual(0, len(notifications))
1513+ self.assertTrue(authorization.is_allowed)
1514+
1515+ def test_wrapping(self):
1516+ self.factory.makePerson(email='me@eg.dom')
1517+ user = self.factory.makePerson()
1518+ recipients_set = NotificationRecipientSet()
1519+ recipients_set.add(user, 'test reason', 'test rationale')
1520+ pop_notifications()
1521+ body = 'Can you help me? ' * 8
1522+ send_direct_contact_email('me@eg.dom', recipients_set, 'subject', body)
1523+ notifications = pop_notifications()
1524+ body, footer = notifications[0].get_payload().split('-- ')
1525+ self.assertEqual(
1526+ 'Can you help me? Can you help me? Can you help me? '
1527+ 'Can you help me? Can\n'
1528+ 'you help me? Can you help me? Can you help me? '
1529+ 'Can you help me?\n',
1530+ body)
1531+
1532+ def test_name_utf8_encoding(self):
1533+ # Names are encoded in the From and To headers.
1534+ self.factory.makePerson(email='me@eg.dom', displayname=u'sn\xefrf')
1535+ user = self.factory.makePerson(
1536+ email='him@eg.dom', displayname=u'pti\xedng')
1537+ recipients_set = NotificationRecipientSet()
1538+ recipients_set.add(user, 'test reason', 'test rationale')
1539+ pop_notifications()
1540+ send_direct_contact_email('me@eg.dom', recipients_set, 'test', 'test')
1541+ notifications = pop_notifications()
1542+ notification = notifications[0]
1543+ self.assertEqual(
1544+ '=?utf-8?b?c27Dr3Jm?= <me@eg.dom>', notification['From'])
1545+ self.assertEqual(
1546+ '=?utf-8?q?pti=C3=ADng?= <him@eg.dom>', notification['To'])