~launchpad-pqm/launchpad/devel

8687.15.11 by Karl Fogel
Add the copyright header block to files under lib/lp/answers/.
1
# Copyright 2009 Canonical Ltd.  This software is licensed under the
2
# GNU Affero General Public License version 3 (see the file LICENSE).
7944.3.11 by Francis J. Lacoste
Moved ZCML, templates and adapters. Split out karma and notifications.
3
4
"""Notifications for the Answers system."""
5
6
__metaclass__ = type
7
__all__ = [
8
    'QuestionNotification',
9
    ]
10
11
import os
12
12952.5.1 by Curtis Hovey
Added recipient_set property to QuestionAddedNotification.
13
from zope.component import getUtility
14
14605.1.1 by Curtis Hovey
Moved canonical.config to lp.services.
15
from lp.services.config import config
14600.2.2 by Curtis Hovey
Moved webapp to lp.services.
16
from lp.services.webapp.publisher import canonical_url
12952.5.1 by Curtis Hovey
Added recipient_set property to QuestionAddedNotification.
17
from lp.answers.enums import (
18
    QuestionAction,
19
    QuestionRecipientSet,
20
    )
21
from lp.answers.interfaces.questionjob import IQuestionEmailJobSource
11403.1.4 by Henning Eggers
Reformatted imports using format-imports script r32.
22
from lp.registry.interfaces.person import IPerson
23
from lp.services.mail.mailwrapper import MailWrapper
11382.6.34 by Gavin Panella
Reformat imports in all files touched so far.
24
from lp.services.propertycache import cachedproperty
7944.3.11 by Francis J. Lacoste
Moved ZCML, templates and adapters. Split out karma and notifications.
25
26
27
def get_email_template(filename):
28
    """Returns the email template with the given file name.
29
30
    The templates are located in 'emailtemplates'.
31
    """
32
    base = os.path.dirname(__file__)
33
    fullpath = os.path.join(base, 'emailtemplates', filename)
34
    return open(fullpath).read()
35
36
37
class QuestionNotification:
38
    """Base class for a notification related to a question.
39
40
    Creating an instance of that class will build the notification and
41
    send it to the appropriate recipients. That way, subclasses of
42
    QuestionNotification can be registered as event subscribers.
43
    """
44
12952.5.9 by Curtis Hovey
Moved recipient_set to the base class.
45
    recipient_set = QuestionRecipientSet.ASKER_SUBSCRIBER
12952.5.1 by Curtis Hovey
Added recipient_set property to QuestionAddedNotification.
46
7944.3.11 by Francis J. Lacoste
Moved ZCML, templates and adapters. Split out karma and notifications.
47
    def __init__(self, question, event):
48
        """Base constructor.
49
50
        It saves the question and event in attributes and then call
51
        the initialize() and send() method.
52
        """
53
        self.question = question
54
        self.event = event
12906.1.2 by Curtis Hovey
Always send notfications about added questions as though they come from the owner.
55
        self._user = IPerson(self.event.user)
7944.3.11 by Francis J. Lacoste
Moved ZCML, templates and adapters. Split out karma and notifications.
56
        self.initialize()
12952.5.5 by Curtis Hovey
Save point for incomplete test. Need base branch merged to complete.
57
        self.job = None
7944.3.11 by Francis J. Lacoste
Moved ZCML, templates and adapters. Split out karma and notifications.
58
        if self.shouldNotify():
12952.5.5 by Curtis Hovey
Save point for incomplete test. Need base branch merged to complete.
59
            self.job = self.enqueue()
7944.3.11 by Francis J. Lacoste
Moved ZCML, templates and adapters. Split out karma and notifications.
60
12906.1.2 by Curtis Hovey
Always send notfications about added questions as though they come from the owner.
61
    @property
62
    def user(self):
63
        """Return the user from the event. """
64
        return self._user
65
7944.3.11 by Francis J. Lacoste
Moved ZCML, templates and adapters. Split out karma and notifications.
66
    def getSubject(self):
67
        """Return the subject of the notification.
68
69
        Default to [Question #dd]: Title
70
        """
71
        return '[Question #%s]: %s' % (self.question.id, self.question.title)
72
73
    def getBody(self):
74
        """Return the content of the notification message.
75
76
        This method must be implemented by a subclass.
77
        """
78
        raise NotImplementedError
79
80
    def getHeaders(self):
81
        """Return additional headers to add to the email.
82
83
        Default implementation adds a X-Launchpad-Question header.
84
        """
85
        question = self.question
86
        headers = dict()
87
        if self.question.distribution:
88
            if question.sourcepackagename:
89
                sourcepackage = question.sourcepackagename.name
90
            else:
91
                sourcepackage = 'None'
92
            target = 'distribution=%s; sourcepackage=%s;' % (
93
                question.distribution.name, sourcepackage)
94
        else:
95
            target = 'product=%s;' % question.product.name
96
        if question.assignee:
97
            assignee = question.assignee.name
98
        else:
99
            assignee = 'None'
100
101
        headers['X-Launchpad-Question'] = (
102
            '%s status=%s; assignee=%s; priority=%s; language=%s' % (
103
                target, question.status.title, assignee,
104
                question.priority.title, question.language.code))
105
        headers['Reply-To'] = 'question%s@%s' % (
106
            self.question.id, config.answertracker.email_domain)
107
108
        return headers
109
110
    def initialize(self):
111
        """Initialization hook for subclasses.
112
113
        This method is called before send() and can be use for any
114
        setup purpose.
115
116
        Default does nothing.
117
        """
118
        pass
119
120
    def shouldNotify(self):
121
        """Return if there is something to notify about.
122
123
        When this method returns False, no notification will be sent.
124
        By default, all event trigger a notification.
125
        """
126
        return True
127
12952.5.1 by Curtis Hovey
Added recipient_set property to QuestionAddedNotification.
128
    def enqueue(self):
129
        """Create a job to send email about the event."""
12952.5.5 by Curtis Hovey
Save point for incomplete test. Need base branch merged to complete.
130
        subject = self.getSubject()
131
        body = self.getBody()
132
        headers = self.getHeaders()
133
        job_source = getUtility(IQuestionEmailJobSource)
134
        job = job_source.create(
135
            self.question, self.user, self.recipient_set,
136
            subject, body, headers)
137
        return job
12952.5.1 by Curtis Hovey
Added recipient_set property to QuestionAddedNotification.
138
7944.3.11 by Francis J. Lacoste
Moved ZCML, templates and adapters. Split out karma and notifications.
139
    @property
140
    def unsupported_language(self):
141
        """Whether the question language is unsupported or not."""
142
        supported_languages = self.question.target.getSupportedLanguages()
143
        return self.question.language not in supported_languages
144
145
    @property
146
    def unsupported_language_warning(self):
147
        """Warning about the fact that the question is written in an
148
        unsupported language."""
149
        return get_email_template(
150
                'question-unsupported-language-warning.txt') % {
151
                'question_language': self.question.language.englishname,
152
                'target_name': self.question.target.displayname}
153
154
155
class QuestionAddedNotification(QuestionNotification):
156
    """Notification sent when a question is added."""
157
12906.1.2 by Curtis Hovey
Always send notfications about added questions as though they come from the owner.
158
    @property
159
    def user(self):
160
        """Return the question owner.
161
162
        Questions can be created by other users for the owner; the
163
        question is from the owner.
164
        """
165
        return self.question.owner
166
7944.3.11 by Francis J. Lacoste
Moved ZCML, templates and adapters. Split out karma and notifications.
167
    def getBody(self):
168
        """See QuestionNotification."""
169
        question = self.question
170
        body = get_email_template('question-added-notification.txt') % {
171
            'target_name': question.target.displayname,
172
            'question_id': question.id,
173
            'question_url': canonical_url(question),
174
            'comment': question.description}
175
        if self.unsupported_language:
176
            body += self.unsupported_language_warning
177
        return body
178
179
180
class QuestionModifiedDefaultNotification(QuestionNotification):
181
    """Base implementation of a notification when a question is modified."""
182
12952.5.2 by Curtis Hovey
Added recipient_set to QuestionModifiedDefaultNotification.
183
    recipient_set = QuestionRecipientSet.SUBSCRIBER
7944.3.11 by Francis J. Lacoste
Moved ZCML, templates and adapters. Split out karma and notifications.
184
    # Email template used to render the body.
185
    body_template = "question-modified-notification.txt"
186
187
    def initialize(self):
188
        """Save the old question for comparison. It also set the new_message
189
        attribute if a new message was added.
190
        """
191
        self.old_question = self.event.object_before_modification
192
193
        new_messages = set(
194
            self.question.messages).difference(self.old_question.messages)
195
        assert len(new_messages) <= 1, (
196
                "There shouldn't be more than one message for a "
197
                "notification.")
198
        if new_messages:
199
            self.new_message = new_messages.pop()
200
        else:
201
            self.new_message = None
202
203
        self.wrapper = MailWrapper()
204
205
    @cachedproperty
206
    def metadata_changes_text(self):
207
        """Textual representation of the changes to the question metadata."""
208
        question = self.question
209
        old_question = self.old_question
12906.1.2 by Curtis Hovey
Always send notfications about added questions as though they come from the owner.
210
        indent = 4 * ' '
7944.3.11 by Francis J. Lacoste
Moved ZCML, templates and adapters. Split out karma and notifications.
211
        info_fields = []
212
        if question.status != old_question.status:
213
            info_fields.append(indent + 'Status: %s => %s' % (
214
                old_question.status.title, question.status.title))
215
        if question.target != old_question.target:
216
            info_fields.append(
217
                indent + 'Project: %s => %s' % (
218
                old_question.target.displayname, question.target.displayname))
219
8711.2.2 by Curtis Hovey
Assigning a question send a notifcation message.
220
        if question.assignee != old_question.assignee:
221
            if old_question.assignee is None:
222
                old_assignee = None
223
            else:
224
                old_assignee = old_question.assignee.displayname
225
            if question.assignee is None:
226
                assignee = None
227
            else:
228
                assignee = question.assignee.displayname
229
            info_fields.append(indent + 'Assignee: %s => %s' % (
230
               old_assignee, assignee))
231
7944.3.11 by Francis J. Lacoste
Moved ZCML, templates and adapters. Split out karma and notifications.
232
        old_bugs = set(old_question.bugs)
233
        bugs = set(question.bugs)
234
        for linked_bug in bugs.difference(old_bugs):
235
            info_fields.append(
236
                indent + 'Linked to bug: #%s\n' % linked_bug.id +
237
                indent + '%s\n' % canonical_url(linked_bug) +
238
                indent + '"%s"' % linked_bug.title)
239
        for unlinked_bug in old_bugs.difference(bugs):
240
            info_fields.append(
241
                indent + 'Removed link to bug: #%s\n' % unlinked_bug.id +
242
                indent + '%s\n' % canonical_url(unlinked_bug) +
243
                indent + '"%s"' % unlinked_bug.title)
244
245
        if question.faq != old_question.faq:
246
            if question.faq is None:
247
                info_fields.append(
248
                    indent + 'Related FAQ was removed:\n' +
249
                    indent + old_question.faq.title + '\n' +
250
                    indent + canonical_url(old_question.faq))
251
            else:
252
                info_fields.append(
253
                    indent + 'Related FAQ set to:\n' +
254
                    indent + question.faq.title + '\n' +
255
                    indent + canonical_url(question.faq))
256
257
        if question.title != old_question.title:
258
            info_fields.append('Summary changed to:\n%s' % question.title)
259
        if question.description != old_question.description:
260
            info_fields.append(
261
                'Description changed to:\n%s' % (
262
                    self.wrapper.format(question.description)))
263
264
        question_changes = '\n\n'.join(info_fields)
265
        return question_changes
266
267
    def getSubject(self):
12906.2.4 by Curtis Hovey
Removed the rules to preserve or rewite a question subject line from email.
268
        """The reply subject line."""
269
        line = super(QuestionModifiedDefaultNotification, self).getSubject()
270
        return 'Re: %s' % line
7944.3.11 by Francis J. Lacoste
Moved ZCML, templates and adapters. Split out karma and notifications.
271
272
    def getHeaders(self):
273
        """Add a References header."""
274
        headers = QuestionNotification.getHeaders(self)
275
        if self.new_message:
276
            # XXX flacoste 2007-02-02 bug=83846:
277
            # The first message cannot contain a References
278
            # because we don't create a Message instance for the
279
            # question description, so we don't have a Message-ID.
280
            messages = list(self.question.messages)
281
            assert self.new_message in messages, (
282
                "Question %s: message id %s not in %s." % (
283
                    self.question.id, self.new_message.id,
284
                    [m.id for m in messages]))
285
            index = messages.index(self.new_message)
286
            if index > 0:
287
                headers['References'] = (
12906.1.2 by Curtis Hovey
Always send notfications about added questions as though they come from the owner.
288
                    self.question.messages[index - 1].rfc822msgid)
7944.3.11 by Francis J. Lacoste
Moved ZCML, templates and adapters. Split out karma and notifications.
289
        return headers
290
291
    def shouldNotify(self):
292
        """Only send a notification when a message was added or some
293
        metadata was changed.
294
        """
295
        return self.new_message or self.metadata_changes_text
296
297
    def getBody(self):
298
        """See QuestionNotification."""
299
        body = self.metadata_changes_text
300
        replacements = dict(
301
            question_id=self.question.id,
302
            target_name=self.question.target.displayname,
303
            question_url=canonical_url(self.question))
304
305
        if self.new_message:
306
            if body:
307
                body += '\n\n'
308
            body += self.getNewMessageText()
309
            replacements['new_message_id'] = list(
310
                self.question.messages).index(self.new_message)
311
312
        replacements['body'] = body
313
314
        return get_email_template(self.body_template) % replacements
315
316
    # Header template used when a new message is added to the question.
317
    action_header_template = {
318
        QuestionAction.REQUESTINFO:
12906.2.1 by Curtis Hovey
Fixed grammar.
319
            '%(person)s requested more information:',
7944.3.11 by Francis J. Lacoste
Moved ZCML, templates and adapters. Split out karma and notifications.
320
        QuestionAction.CONFIRM:
321
            '%(person)s confirmed that the question is solved:',
322
        QuestionAction.COMMENT:
323
            '%(person)s posted a new comment:',
324
        QuestionAction.GIVEINFO:
325
            '%(person)s gave more information on the question:',
326
        QuestionAction.REOPEN:
327
            '%(person)s is still having a problem:',
328
        QuestionAction.ANSWER:
329
            '%(person)s proposed the following answer:',
330
        QuestionAction.EXPIRE:
331
            '%(person)s expired the question:',
332
        QuestionAction.REJECT:
333
            '%(person)s rejected the question:',
334
        QuestionAction.SETSTATUS:
335
            '%(person)s changed the question status:',
336
    }
337
338
    def getNewMessageText(self):
339
        """Should return the notification text related to a new message."""
340
        if not self.new_message:
341
            return ''
342
343
        header = self.action_header_template.get(
344
            self.new_message.action, '%(person)s posted a new message:') % {
345
            'person': self.new_message.owner.displayname}
346
347
        return '\n'.join([
348
            header, self.wrapper.format(self.new_message.text_contents)])
349
350
351
class QuestionModifiedOwnerNotification(QuestionModifiedDefaultNotification):
352
    """Notification sent to the owner when his question is modified."""
353
12952.5.3 by Curtis Hovey
Added recipient_set to QuestionModifiedOwnerNotification.
354
    recipient_set = QuestionRecipientSet.ASKER
7944.3.11 by Francis J. Lacoste
Moved ZCML, templates and adapters. Split out karma and notifications.
355
    # These actions will be done by the owner, so use the second person.
356
    action_header_template = dict(
357
        QuestionModifiedDefaultNotification.action_header_template)
358
    action_header_template.update({
359
        QuestionAction.CONFIRM:
360
            'You confirmed that the question is solved:',
361
        QuestionAction.GIVEINFO:
362
            'You gave more information on the question:',
363
        QuestionAction.REOPEN:
364
            'You are still having a problem:',
365
        })
366
367
    body_template = 'question-modified-owner-notification.txt'
368
369
    body_template_by_action = {
370
        QuestionAction.ANSWER: "question-answered-owner-notification.txt",
371
        QuestionAction.EXPIRE: "question-expired-owner-notification.txt",
372
        QuestionAction.REJECT: "question-rejected-owner-notification.txt",
373
        QuestionAction.REQUESTINFO: (
374
            "question-info-requested-owner-notification.txt"),
375
    }
376
377
    def initialize(self):
378
        """Set the template based on the new comment action."""
379
        QuestionModifiedDefaultNotification.initialize(self)
380
        if self.new_message:
381
            self.body_template = self.body_template_by_action.get(
382
                self.new_message.action, self.body_template)
383
384
    def getBody(self):
385
        """See QuestionNotification."""
386
        body = QuestionModifiedDefaultNotification.getBody(self)
387
        if self.unsupported_language:
388
            body += self.unsupported_language_warning
389
        return body
390
391
392
class QuestionUnsupportedLanguageNotification(QuestionNotification):
393
    """Notification sent to answer contacts for unsupported languages."""
394
12952.5.4 by Curtis Hovey
Added recipient_set to QuestionUnsupportedLanguageNotification.
395
    recipient_set = QuestionRecipientSet.CONTACT
396
7944.3.11 by Francis J. Lacoste
Moved ZCML, templates and adapters. Split out karma and notifications.
397
    def getSubject(self):
398
        """See QuestionNotification."""
399
        return '[Question #%s]: (%s) %s' % (
400
            self.question.id, self.question.language.englishname,
401
            self.question.title)
402
403
    def shouldNotify(self):
404
        """Return True when the question is in an unsupported language."""
405
        return self.unsupported_language
406
407
    def getBody(self):
408
        """See QuestionNotification."""
409
        question = self.question
410
        return get_email_template(
411
                'question-unsupported-languages-added.txt') % {
412
            'target_name': question.target.displayname,
413
            'question_id': question.id,
414
            'question_url': canonical_url(question),
415
            'question_language': question.language.englishname,
416
            'comment': question.description}