Skip to content
Newer
Older
100644 511 lines (414 sloc) 21.4 KB
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
1 import random
24a13e5 @pcraciunoiu [bug 623982, bug 629520] Anonymous watches. Delete watch when deliver…
pcraciunoiu authored
2 from smtplib import SMTPException
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
3 from string import letters
e2ee36e @erikrose [bug 623644] Sketch notification system API.
erikrose authored
4
24a13e5 @pcraciunoiu [bug 623982, bug 629520] Anonymous watches. Delete watch when deliver…
pcraciunoiu authored
5 from django.conf import settings
5bf840f @erikrose [bug 626958] De-dupe email addresses within the scope of one call to …
erikrose authored
6 from django.contrib.auth.models import User
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
7 from django.contrib.contenttypes.models import ContentType
e2ee36e @erikrose [bug 623644] Sketch notification system API.
erikrose authored
8 from django.core import mail
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
9 from django.db.models import Q
10
a1d8f69 @lmorchard fix bug 766256: Add rabbitmq & celery to vagrant
lmorchard authored
11 from celery.task import task
c26bd30 @erikrose [bug 626957] Make fire() asynchronous.
erikrose authored
12
5bf840f @erikrose [bug 626958] De-dupe email addresses within the scope of one call to …
erikrose authored
13 from notifications.models import Watch, WatchFilter, EmailUser, multi_raw
82e7ca9 @erikrose [bug 630718] Hash string filter values down to ints. Recreate your ta…
erikrose authored
14 from notifications.utils import merge, hash_to_unsigned
e2ee36e @erikrose [bug 623644] Sketch notification system API.
erikrose authored
15
16
24a13e5 @pcraciunoiu [bug 623982, bug 629520] Anonymous watches. Delete watch when deliver…
pcraciunoiu authored
17 class ActivationRequestFailed(Exception):
18 """Raised when activation request fails, e.g. if email could not be sent"""
19 def __init__(self, msgs):
20 self.msgs = msgs
21
22
5bf840f @erikrose [bug 626958] De-dupe email addresses within the scope of one call to …
erikrose authored
23 def _unique_by_email(users_and_watches):
6f76593 @erikrose [bug 629515] Let us fire more than one event at a time, de-duping amo…
erikrose authored
24 """Given a sequence of (User/EmailUser, Watch) pairs clustered by email
5bf840f @erikrose [bug 626958] De-dupe email addresses within the scope of one call to …
erikrose authored
25 address (which is never ''), yield from each cluster...
26
6f76593 @erikrose [bug 629515] Let us fire more than one event at a time, de-duping amo…
erikrose authored
27 (1) the first pair where the User has an email and is not anonymous, or, if
28 there isn't such a user...
5bf840f @erikrose [bug 626958] De-dupe email addresses within the scope of one call to …
erikrose authored
29 (2) the first pair.
30
c28c1aa @erikrose [bug 630116] Compare email addresses case-insensitively when de-duping.
erikrose authored
31 Compares email addresses case-insensitively.
32
5bf840f @erikrose [bug 626958] De-dupe email addresses within the scope of one call to …
erikrose authored
33 """
6f76593 @erikrose [bug 629515] Let us fire more than one event at a time, de-duping amo…
erikrose authored
34 def ensure_user_has_email(user, watch):
35 """Make sure the user in the user-watch pair has an email address.
36
37 The caller guarantees us an email from either the user or the watch. If
38 the passed-in user has no email, we return an EmailUser instead having
39 the email address from the watch.
40
41 """
42 # Some of these cases shouldn't happen, but we're tolerant.
43 if not getattr(user, 'email', ''):
44 user = EmailUser(watch.email)
45 return user, watch
46
47 # TODO: Do this instead with clever SQL that somehow returns just the
48 # best row for each email.
5bf840f @erikrose [bug 626958] De-dupe email addresses within the scope of one call to …
erikrose authored
49
50 # Email of current cluster:
51 email = ''
52 # Best pairs in cluster so far:
53 favorite_user, favorite_watch = None, None
54 for u, w in users_and_watches:
55 row_email = u.email or w.email
c28c1aa @erikrose [bug 630116] Compare email addresses case-insensitively when de-duping.
erikrose authored
56 if email.lower() != row_email.lower():
5bf840f @erikrose [bug 626958] De-dupe email addresses within the scope of one call to …
erikrose authored
57 if email != '':
6f76593 @erikrose [bug 629515] Let us fire more than one event at a time, de-duping amo…
erikrose authored
58 yield ensure_user_has_email(favorite_user, favorite_watch)
5bf840f @erikrose [bug 626958] De-dupe email addresses within the scope of one call to …
erikrose authored
59 favorite_user, favorite_watch = u, w
60 email = row_email
6f76593 @erikrose [bug 629515] Let us fire more than one event at a time, de-duping amo…
erikrose authored
61 elif ((not favorite_user.email or isinstance(u, EmailUser))
62 and u.email and not isinstance(u, EmailUser)):
5bf840f @erikrose [bug 626958] De-dupe email addresses within the scope of one call to …
erikrose authored
63 favorite_user, favorite_watch = u, w
64 if favorite_user is not None:
6f76593 @erikrose [bug 629515] Let us fire more than one event at a time, de-duping amo…
erikrose authored
65 yield ensure_user_has_email(favorite_user, favorite_watch)
c26bd30 @erikrose [bug 626957] Make fire() asynchronous.
erikrose authored
66
67
e2ee36e @erikrose [bug 623644] Sketch notification system API.
erikrose authored
68 class Event(object):
69 """Abstract base class for events
70
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
71 An Event represents, simply, something that occurs. A Watch is a record of
72 someone's interest in a certain type of Event, distinguished by
e2ee36e @erikrose [bug 623644] Sketch notification system API.
erikrose authored
73 Event.event_type.
74
75 Fire an Event (SomeEvent.fire()) from the code that causes the interesting
76 event to occur. Fire it any time the event *might* have occurred. The Event
77 will determine whether conditions are right to actually send notifications;
78 don't succumb to the temptation to do these tests outside the Event.
79
80 Event subclasses can optionally represent a more limited scope of interest
81 by populating the Watch.content_type field and/or adding related
82 WatchFilter rows holding name/value pairs, the meaning of which is up to
83 each individual subclass. NULL values are considered wildcards.
84
c26bd30 @erikrose [bug 626957] Make fire() asynchronous.
erikrose authored
85 Event subclass instances must be pickleable so they can be shuttled off to
86 celery tasks.
87
e2ee36e @erikrose [bug 623644] Sketch notification system API.
erikrose authored
88 """
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
89 # event_type = 'hamster modified' # key for the event_type column
90 content_type = None # or, for example, Hamster
91 filters = set() # or, for example, set(['color', 'flavor'])
e2ee36e @erikrose [bug 623644] Sketch notification system API.
erikrose authored
92
3970e69 @erikrose [630552] `excludes` kwarg no longer causes all notifications to be su…
erikrose authored
93 def fire(self, exclude=None):
c26bd30 @erikrose [bug 626957] Make fire() asynchronous.
erikrose authored
94 """Asynchronously notify everyone watching the event.
e2ee36e @erikrose [bug 623644] Sketch notification system API.
erikrose authored
95
c26bd30 @erikrose [bug 626957] Make fire() asynchronous.
erikrose authored
96 We are explicit about sending notifications; we don't just key off
e2ee36e @erikrose [bug 623644] Sketch notification system API.
erikrose authored
97 creation signals, because the receiver of a post_save signal has no
98 idea what just changed, so it doesn't know which notifications to send.
99 Also, we could easily send mail accidentally: for instance, during
100 tests. If we want implicit event firing, we can always register a
101 signal handler that calls fire().
102
3970e69 @erikrose [630552] `excludes` kwarg no longer causes all notifications to be su…
erikrose authored
103 If a saved user is passed in as `exclude`, that user will not be
104 notified, though anonymous notifications having the same email address
105 may still be sent.
106
e2ee36e @erikrose [bug 623644] Sketch notification system API.
erikrose authored
107 """
5bf840f @erikrose [bug 626958] De-dupe email addresses within the scope of one call to …
erikrose authored
108 # Tasks don't receive the `self` arg implicitly.
3970e69 @erikrose [630552] `excludes` kwarg no longer causes all notifications to be su…
erikrose authored
109 self._fire_task.delay(self, exclude=exclude)
5bf840f @erikrose [bug 626958] De-dupe email addresses within the scope of one call to …
erikrose authored
110
111 @task
3970e69 @erikrose [630552] `excludes` kwarg no longer causes all notifications to be su…
erikrose authored
112 def _fire_task(self, exclude=None):
5bf840f @erikrose [bug 626958] De-dupe email addresses within the scope of one call to …
erikrose authored
113 """Build and send the emails as a celery task."""
114 connection = mail.get_connection(fail_silently=True)
115 # Warning: fail_silently swallows errors thrown by the generators, too.
116 connection.open()
3970e69 @erikrose [630552] `excludes` kwarg no longer causes all notifications to be su…
erikrose authored
117 for m in self._mails(self._users_watching(exclude=exclude)):
5bf840f @erikrose [bug 626958] De-dupe email addresses within the scope of one call to …
erikrose authored
118 connection.send_messages([m])
e2ee36e @erikrose [bug 623644] Sketch notification system API.
erikrose authored
119
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
120 @classmethod
121 def _validate_filters(cls, filters):
122 """Raise a TypeError if `filters` contains any keys inappropriate to
123 this event class."""
124 for k in filters.iterkeys():
125 if k not in cls.filters:
126 # Mirror "unexpected keyword argument" message:
127 raise TypeError("%s got an unsupported filter type '%s'" %
128 (cls.__name__, k))
129
3970e69 @erikrose [630552] `excludes` kwarg no longer causes all notifications to be su…
erikrose authored
130 def _users_watching_by_filter(self, object_id=None, exclude=None,
131 **filters):
5bf840f @erikrose [bug 626958] De-dupe email addresses within the scope of one call to …
erikrose authored
132 """Return an iterable of (User/EmailUser, Watch) pairs watching the
133 event.
134
135 Of multiple Users/EmailUsers having the same email address, only one is
136 returned. Users are favored over EmailUsers so we are sure to be able
137 to, for example, include a link to a user profile in the mail.
e2ee36e @erikrose [bug 623644] Sketch notification system API.
erikrose authored
138
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
139 "Watching the event" means having a Watch whose event_type is
3970e69 @erikrose [630552] `excludes` kwarg no longer causes all notifications to be su…
erikrose authored
140 self.event_type, whose content_type is self.content_type or NULL, whose
141 object_id is `object_id` or NULL, and whose WatchFilter rows match as
142 follows: each name/value pair given in `filters` must be matched by a
143 related WatchFilter, or there must be no related WatchFilter having
144 that name. If you find yourself wanting the lack of a particularly
145 named WatchFilter to scuttle the match, use a different event_type
146 instead.
147
148 If a saved user is passed in as `exclude`, that user will never be
149 returned, though anonymous watches having the same email address may.
e2ee36e @erikrose [bug 623644] Sketch notification system API.
erikrose authored
150
151 """
6f76593 @erikrose [bug 629515] Let us fire more than one event at a time, de-duping amo…
erikrose authored
152 # I don't think we can use the ORM here, as there's no way to get a
153 # second condition (name=whatever) into a left join. However, if we
154 # were willing to have 2 subqueries run for every watch row--select
155 # {are there any filters with name=x?} and select {is there a filter
156 # with name=x and value=y?}--we could do it with extra(). Then we could
157 # have EventUnion simple | the QuerySets together, which would avoid
158 # having to merge in Python.
159
593d553 @erikrose Refactor the query-building _users_watching_by_filter() to be a bit m…
erikrose authored
160 def filter_conditions():
161 """Return joins, WHERE conditions, and params to bind to them in
e2ee36e @erikrose [bug 623644] Sketch notification system API.
erikrose authored
162 order to check a notification against all the given filters."""
163 # Not a one-liner. You're welcome. :-)
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
164 self._validate_filters(filters)
165 joins, wheres, join_params, where_params = [], [], [], []
166 for n, (k, v) in enumerate(filters.iteritems()):
167 joins.append(
593d553 @erikrose Refactor the query-building _users_watching_by_filter() to be a bit m…
erikrose authored
168 'LEFT JOIN notifications_watchfilter f{n} '
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
169 'ON f{n}.watch_id=w.id '
170 'AND f{n}.name=%s'.format(n=n))
171 join_params.append(k)
593d553 @erikrose Refactor the query-building _users_watching_by_filter() to be a bit m…
erikrose authored
172 wheres.append('(f{n}.value=%s '
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
173 'OR f{n}.value IS NULL)'.format(n=n))
82e7ca9 @erikrose [bug 630718] Hash string filter values down to ints. Recreate your ta…
erikrose authored
174 where_params.append(hash_to_unsigned(v))
593d553 @erikrose Refactor the query-building _users_watching_by_filter() to be a bit m…
erikrose authored
175 return joins, wheres, join_params + where_params
e2ee36e @erikrose [bug 623644] Sketch notification system API.
erikrose authored
176
593d553 @erikrose Refactor the query-building _users_watching_by_filter() to be a bit m…
erikrose authored
177 # Apply watchfilter constraints:
178 joins, wheres, params = filter_conditions()
179
180 # Start off with event_type, which is always a constraint. These go in
181 # the `wheres` list to guarantee that the AND after the {wheres}
182 # substitution in the query is okay.
183 wheres.append('w.event_type=%s')
184 params.append(self.event_type)
185
186 # Constrain on other 1-to-1 attributes:
e2ee36e @erikrose [bug 623644] Sketch notification system API.
erikrose authored
187 if self.content_type:
593d553 @erikrose Refactor the query-building _users_watching_by_filter() to be a bit m…
erikrose authored
188 wheres.append('(w.content_type_id IS NULL '
189 'OR w.content_type_id=%s)')
190 params.append(ContentType.objects.get_for_model(
191 self.content_type).id)
ff53e8d @pcraciunoiu [bug 628749] Forums notifications are in da haus.
pcraciunoiu authored
192 if object_id:
593d553 @erikrose Refactor the query-building _users_watching_by_filter() to be a bit m…
erikrose authored
193 wheres.append('(w.object_id IS NULL OR w.object_id=%s)')
194 params.append(object_id)
195 if exclude:
196 if exclude.id: # Don't try excluding unsaved Users.
197 wheres.append('(u.id IS NULL OR u.id!=%s)')
198 params.append(exclude.id)
199 else:
200 raise ValueError("Can't exclude an unsaved User.")
a3133ff @pcraciunoiu [bug 629571] Exclude user from being notified by their own changes.
pcraciunoiu authored
201
e2ee36e @erikrose [bug 623644] Sketch notification system API.
erikrose authored
202 query = (
5bf840f @erikrose [bug 626958] De-dupe email addresses within the scope of one call to …
erikrose authored
203 'SELECT u.*, w.* '
e2ee36e @erikrose [bug 623644] Sketch notification system API.
erikrose authored
204 'FROM notifications_watch w '
593d553 @erikrose Refactor the query-building _users_watching_by_filter() to be a bit m…
erikrose authored
205 'LEFT JOIN auth_user u ON u.id=w.user_id {joins} '
206 'WHERE {wheres} '
207 'AND (length(w.email)>0 OR length(u.email)>0) '
24a13e5 @pcraciunoiu [bug 623982, bug 629520] Anonymous watches. Delete watch when deliver…
pcraciunoiu authored
208 'AND w.is_active '
5bf840f @erikrose [bug 626958] De-dupe email addresses within the scope of one call to …
erikrose authored
209 'ORDER BY u.email DESC, w.email DESC').format(
593d553 @erikrose Refactor the query-building _users_watching_by_filter() to be a bit m…
erikrose authored
210 joins=' '.join(joins),
211 wheres=' AND '.join(wheres))
6f76593 @erikrose [bug 629515] Let us fire more than one event at a time, de-duping amo…
erikrose authored
212 # IIRC, the DESC ordering was something to do with the placement of
213 # NULLs. Track this down and explain it.
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
214
593d553 @erikrose Refactor the query-building _users_watching_by_filter() to be a bit m…
erikrose authored
215 return _unique_by_email(multi_raw(query, params, [User, Watch]))
e2ee36e @erikrose [bug 623644] Sketch notification system API.
erikrose authored
216
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
217 @classmethod
ff53e8d @pcraciunoiu [bug 628749] Forums notifications are in da haus.
pcraciunoiu authored
218 def _watches_belonging_to_user(cls, user_or_email, object_id=None,
219 **filters):
5bf840f @erikrose [bug 626958] De-dupe email addresses within the scope of one call to …
erikrose authored
220 """Return a QuerySet of watches having the given user or email, having
221 (only) the given filters, and having the event_type and content_type
222 attrs of the class.
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
223
224 Matched Watches may be either confirmed and unconfirmed. They may
598af65 @erikrose Add NotificationsMixin to model classes that can be watched. Rely on …
erikrose authored
225 include duplicates if the get-then-create race condition in notify()
226 allowed them to be created.
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
227
228 If you pass an email, it will be matched against only the email
229 addresses of anonymous watches. At the moment, the only integration
230 point planned between anonymous and registered watches is the claiming
c26bd30 @erikrose [bug 626957] Make fire() asynchronous.
erikrose authored
231 of anonymous watches of the same email address on user registration
232 confirmation.
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
233
dd08a1a @erikrose [bug 628752] Port wiki app to new notification system.
erikrose authored
234 If you pass the AnonymousUser, this will return an empty QuerySet.
235
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
236 """
237 # If we have trouble distinguishing subsets and such, we could store a
238 # number_of_filters on the Watch.
239 cls._validate_filters(filters)
240
dd08a1a @erikrose [bug 628752] Port wiki app to new notification system.
erikrose authored
241 if isinstance(user_or_email, basestring):
242 user_condition = Q(email=user_or_email)
243 elif not user_or_email.is_anonymous():
244 user_condition = Q(user=user_or_email)
245 else:
246 return Watch.objects.none()
247
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
248 # Filter by stuff in the Watch row:
5bf840f @erikrose [bug 626958] De-dupe email addresses within the scope of one call to …
erikrose authored
249 watches = Watch.uncached.filter(
dd08a1a @erikrose [bug 628752] Port wiki app to new notification system.
erikrose authored
250 user_condition,
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
251 Q(content_type=ContentType.objects.get_for_model(cls.content_type))
252 if cls.content_type
253 else Q(),
ff53e8d @pcraciunoiu [bug 628749] Forums notifications are in da haus.
pcraciunoiu authored
254 Q(object_id=object_id)
255 if object_id
256 else Q(),
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
257 event_type=cls.event_type).extra(
258 where=['(SELECT count(*) FROM notifications_watchfilter WHERE '
259 'notifications_watchfilter.watch_id='
260 'notifications_watch.id)=%s'],
261 params=[len(filters)])
262 # Optimization: If the subselect ends up being slow, store the number
263 # of filters in each Watch row or try a GROUP BY.
264
265 # Apply 1-to-many filters:
266 for k, v in filters.iteritems():
82e7ca9 @erikrose [bug 630718] Hash string filter values down to ints. Recreate your ta…
erikrose authored
267 watches = watches.filter(filters__name=k,
268 filters__value=hash_to_unsigned(v))
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
269
270 return watches
271
272 @classmethod
273 # Funny arg name to reserve use of nice ones for filters
23e900b @pcraciunoiu [bug 630630] Add some InstanceEvent tests and fix a bug with watching…
pcraciunoiu authored
274 def is_notifying(cls, user_or_email_, object_id=None, **filters):
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
275 """Return whether the user/email is watching this event (either
24a13e5 @pcraciunoiu [bug 623982, bug 629520] Anonymous watches. Delete watch when deliver…
pcraciunoiu authored
276 active or inactive watches), conditional on meeting the criteria in
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
277 `filters`.
278
279 Count only watches that match the given filters exactly--not ones which
280 match merely a superset of them. This lets callers distinguish between
281 watches which overlap in scope. Equivalently, this lets callers check
282 whether notify() has been called with these arguments.
283
284 Implementations in subclasses may take different arguments--for
285 example, to assume certain filters--though most will probably just use
286 this. However, subclasses should clearly document what filters they
287 supports and the meaning of each.
288
dd08a1a @erikrose [bug 628752] Port wiki app to new notification system.
erikrose authored
289 Passing this an AnonymousUser always returns False. This means you can
290 always pass it request.user in a view and get a sensible response.
291
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
292 """
5bf840f @erikrose [bug 626958] De-dupe email addresses within the scope of one call to …
erikrose authored
293 return cls._watches_belonging_to_user(user_or_email_,
23e900b @pcraciunoiu [bug 630630] Add some InstanceEvent tests and fix a bug with watching…
pcraciunoiu authored
294 object_id=object_id,
5bf840f @erikrose [bug 626958] De-dupe email addresses within the scope of one call to …
erikrose authored
295 **filters).exists()
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
296
297 @classmethod
ff53e8d @pcraciunoiu [bug 628749] Forums notifications are in da haus.
pcraciunoiu authored
298 def notify(cls, user_or_email_, object_id=None, **filters):
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
299 """Start notifying the given user or email address when this event
300 occurs and meets the criteria given in `filters`.
301
302 Return the created (or the existing matching) Watch so you can call
24a13e5 @pcraciunoiu [bug 623982, bug 629520] Anonymous watches. Delete watch when deliver…
pcraciunoiu authored
303 activate() on it if you're so inclined.
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
304
305 Implementations in subclasses may take different arguments; see the
306 docstring of is_notifying().
307
24a13e5 @pcraciunoiu [bug 623982, bug 629520] Anonymous watches. Delete watch when deliver…
pcraciunoiu authored
308 Send an activation email if an anonymous watch is created and
309 settings.CONFIRM_ANONYMOUS_WATCHES = True. If the activation request
310 fails, raise a ActivationRequestFailed exception.
311
312 Calling notify() twice for an anonymous user will send the email
313 each time.
314
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
315 """
316 # A test-for-existence-then-create race condition exists here, but it
317 # doesn't matter: de-duplication on fire() and deletion of all matches
598af65 @erikrose Add NotificationsMixin to model classes that can be watched. Rely on …
erikrose authored
318 # on stop_notifying() nullify its effects.
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
319 try:
320 # Pick 1 if >1 are returned:
5bf840f @erikrose [bug 626958] De-dupe email addresses within the scope of one call to …
erikrose authored
321 watch = cls._watches_belonging_to_user(
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
322 user_or_email_,
23e900b @pcraciunoiu [bug 630630] Add some InstanceEvent tests and fix a bug with watching…
pcraciunoiu authored
323 object_id=object_id,
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
324 **filters)[0:1].get()
325 except Watch.DoesNotExist:
326 create_kwargs = {}
327 if cls.content_type:
328 create_kwargs['content_type'] = \
329 ContentType.objects.get_for_model(cls.content_type)
330 create_kwargs['email' if isinstance(user_or_email_, basestring)
331 else 'user'] = user_or_email_
24a13e5 @pcraciunoiu [bug 623982, bug 629520] Anonymous watches. Delete watch when deliver…
pcraciunoiu authored
332 secret = ''.join(random.choice(letters) for x in xrange(10))
333 # Registered users don't need to confirm, but anonymous users do.
334 is_active = ('user' in create_kwargs or
335 not settings.CONFIRM_ANONYMOUS_WATCHES)
ff53e8d @pcraciunoiu [bug 628749] Forums notifications are in da haus.
pcraciunoiu authored
336 if object_id:
337 create_kwargs['object_id'] = object_id
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
338 watch = Watch.objects.create(
ff53e8d @pcraciunoiu [bug 628749] Forums notifications are in da haus.
pcraciunoiu authored
339 secret=secret,
24a13e5 @pcraciunoiu [bug 623982, bug 629520] Anonymous watches. Delete watch when deliver…
pcraciunoiu authored
340 is_active=is_active,
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
341 event_type=cls.event_type,
342 **create_kwargs)
343 for k, v in filters.iteritems():
82e7ca9 @erikrose [bug 630718] Hash string filter values down to ints. Recreate your ta…
erikrose authored
344 WatchFilter.objects.create(watch=watch, name=k,
345 value=hash_to_unsigned(v))
24a13e5 @pcraciunoiu [bug 623982, bug 629520] Anonymous watches. Delete watch when deliver…
pcraciunoiu authored
346 # Send email for inactive watches.
347 if not watch.is_active:
348 email = watch.user.email if watch.user else watch.email
349 message = cls._activation_email(watch, email)
350 try:
351 message.send()
352 except SMTPException, e:
353 watch.delete()
354 raise ActivationRequestFailed(e.recipients)
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
355 return watch
356
357 @classmethod
358 def stop_notifying(cls, user_or_email_, **filters):
359 """Delete all watches matching the exact user/email and filters.
360
24a13e5 @pcraciunoiu [bug 623982, bug 629520] Anonymous watches. Delete watch when deliver…
pcraciunoiu authored
361 Delete both active and inactive watches. If duplicate watches
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
362 exist due to the get-then-create race condition, delete them all.
363
364 Implementations in subclasses may take different arguments; see the
365 docstring of is_notifying().
366
367 """
5bf840f @erikrose [bug 626958] De-dupe email addresses within the scope of one call to …
erikrose authored
368 cls._watches_belonging_to_user(user_or_email_, **filters).delete()
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
369
370 # TODO: If GenericForeignKeys don't give us cascading deletes, make a
371 # stop_notifying_all(**filters) or something. It should delete any watch of
372 # the class's event_type and content_type and having filters matching each
373 # of **filters. Even if there are additional filters on a watch, that watch
374 # should still be deleted so we can delete, for example, any watch that
375 # references a certain Question instance. To do that, factor such that you
5bf840f @erikrose [bug 626958] De-dupe email addresses within the scope of one call to …
erikrose authored
376 # can effectively call _watches_belonging_to_user() without it calling
377 # extra().
962a72a @erikrose [bug 623644] Implement (and consequently iterate the design of) most …
erikrose authored
378
379 # Subclasses should implement the following:
380
5bf840f @erikrose [bug 626958] De-dupe email addresses within the scope of one call to …
erikrose authored
381 def _mails(self, users_and_watches):
382 """Return an iterable yielding an EmailMessage to send to each user.
383
384 `users_and_watches` -- an iterable of (User or EmailUser, Watch) pairs
385 where the first element is the user to send to and the second is
386 the watch that indicated the user's interest in this event
387
388 """
6f76593 @erikrose [bug 629515] Let us fire more than one event at a time, de-duping amo…
erikrose authored
389 # Did this instead of mail() because a common case might be sending the
390 # same mail to many users. mail() would make it difficult to avoid
391 # redoing the templating every time.
e2ee36e @erikrose [bug 623644] Sketch notification system API.
erikrose authored
392 raise NotImplementedError
393
3970e69 @erikrose [630552] `excludes` kwarg no longer causes all notifications to be su…
erikrose authored
394 def _users_watching(self, **kwargs):
5bf840f @erikrose [bug 626958] De-dupe email addresses within the scope of one call to …
erikrose authored
395 """Return an iterable of Users and EmailUsers watching this event
396 and the Watches that map them to it.
397
398 Each yielded item is a tuple: (User or EmailUser, Watch).
e2ee36e @erikrose [bug 623644] Sketch notification system API.
erikrose authored
399
c26bd30 @erikrose [bug 626957] Make fire() asynchronous.
erikrose authored
400 Default implementation returns users watching this object's event_type
401 and, if defined, content_type.
402
e2ee36e @erikrose [bug 623644] Sketch notification system API.
erikrose authored
403 """
3970e69 @erikrose [630552] `excludes` kwarg no longer causes all notifications to be su…
erikrose authored
404 return self._users_watching_by_filter(**kwargs)
ff53e8d @pcraciunoiu [bug 628749] Forums notifications are in da haus.
pcraciunoiu authored
405
24a13e5 @pcraciunoiu [bug 623982, bug 629520] Anonymous watches. Delete watch when deliver…
pcraciunoiu authored
406 @classmethod
407 def _activation_email(cls, watch, email):
408 """Return an EmailMessage to send to anonymous watchers.
409
410 They are expected to follow the activation URL sent in the email to
411 activate their watch, so you should include at least that.
412
413 """
414 # TODO: basic implementation.
415 return mail.EmailMessage('TODO', 'Activate!',
416 settings.NOTIFICATIONS_FROM_ADDRESS,
417 [email])
418
419 @classmethod
420 def _activation_url(cls, watch):
421 """Return a URL pointing to the watch activation.
422
423 TODO: provide generic implementation of this before liberating.
424 Generic implementation could involve a setting to the default reverse()
425 path, e.g. 'notifications.activate_watch'.
426
427 """
428 raise NotImplementedError
429
430 @classmethod
431 def watch_description(cls, watch):
432 """Return a description of the watch which can be used in emails."""
433 raise NotImplementedError
434
ff53e8d @pcraciunoiu [bug 628749] Forums notifications are in da haus.
pcraciunoiu authored
435
6f76593 @erikrose [bug 629515] Let us fire more than one event at a time, de-duping amo…
erikrose authored
436 class EventUnion(Event):
dd08a1a @erikrose [bug 628752] Port wiki app to new notification system.
erikrose authored
437 """Fireable conglomeration of multiple events
438
439 Use this when you want to send a single mail to each person watching any of
440 several events. For example, this sends only 1 mail to a given user, even
441 if he was being notified of all 3 events:
442
443 EventUnion(SomeEvent(), OtherEvent(), ThirdEvent()).fire()
444
445 """
6f76593 @erikrose [bug 629515] Let us fire more than one event at a time, de-duping amo…
erikrose authored
446 # Calls some private methods on events, but this and Event are good
447 # friends.
448
449 def __init__(self, *events):
450 """`events` -- the events of which to take the union"""
dd08a1a @erikrose [bug 628752] Port wiki app to new notification system.
erikrose authored
451 super(EventUnion, self).__init__()
6f76593 @erikrose [bug 629515] Let us fire more than one event at a time, de-duping amo…
erikrose authored
452 self.events = events
453
454 def _mails(self, users_and_watches):
455 """Default implementation fires the _mails() of my first event but may
456 pass it any of my events as `self`.
457
458 Use this default implementation when the content of each event's mail
459 template is essentially the same, e.g. "This new post was made.
460 Enjoy.". When the receipt of a second mail from the second event would
461 add no value, this is a fine choice. If the second event's email would
462 add value, you should probably fire both events independently and let
463 both mails be delivered. Or, if you would like to send a single mail
464 with a custom template for a batch of events, just subclass EventUnion
465 and override this method.
466
467 """
468 return self.events[0]._mails(users_and_watches)
469
3970e69 @erikrose [630552] `excludes` kwarg no longer causes all notifications to be su…
erikrose authored
470 def _users_watching(self, **kwargs):
6f76593 @erikrose [bug 629515] Let us fire more than one event at a time, de-duping amo…
erikrose authored
471 # Get a sorted iterable of user-watch pairs:
3970e69 @erikrose [630552] `excludes` kwarg no longer causes all notifications to be su…
erikrose authored
472 users_and_watches = merge(*[e._users_watching(**kwargs)
473 for e in self.events],
dd08a1a @erikrose [bug 628752] Port wiki app to new notification system.
erikrose authored
474 key=lambda (user, watch): user.email.lower(),
6f76593 @erikrose [bug 629515] Let us fire more than one event at a time, de-duping amo…
erikrose authored
475 reverse=True)
476
477 # Pick the best User out of each cluster of identical email addresses:
478 return _unique_by_email(users_and_watches)
479
480
ff53e8d @pcraciunoiu [bug 628749] Forums notifications are in da haus.
pcraciunoiu authored
481 class InstanceEvent(Event):
482 """Common case of watching a specific instance of a Model."""
483
3970e69 @erikrose [630552] `excludes` kwarg no longer causes all notifications to be su…
erikrose authored
484 def __init__(self, instance, *args, **kwargs):
485 super(InstanceEvent, self).__init__(*args, **kwargs)
ff53e8d @pcraciunoiu [bug 628749] Forums notifications are in da haus.
pcraciunoiu authored
486 self.instance = instance
487
488 @classmethod
489 def notify(cls, user_or_email, instance):
490 """Create, save, and return a Watch which fires when something
491 happens to `instance`."""
492 return super(InstanceEvent, cls).notify(user_or_email,
493 object_id=instance.pk)
494
495 @classmethod
496 def stop_notifying(cls, user_or_email, instance):
497 """Delete the watch created by notify."""
498 super(InstanceEvent, cls).stop_notifying(user_or_email,
499 object_id=instance.pk)
500
501 @classmethod
502 def is_notifying(cls, user_or_email, instance):
503 """Check if the watch created by notify exists."""
504 return super(InstanceEvent, cls).is_notifying(user_or_email,
505 object_id=instance.pk)
506
3970e69 @erikrose [630552] `excludes` kwarg no longer causes all notifications to be su…
erikrose authored
507 def _users_watching(self, **kwargs):
ff53e8d @pcraciunoiu [bug 628749] Forums notifications are in da haus.
pcraciunoiu authored
508 """Return users watching this instance."""
3970e69 @erikrose [630552] `excludes` kwarg no longer causes all notifications to be su…
erikrose authored
509 return self._users_watching_by_filter(object_id=self.instance.pk,
510 **kwargs)
Something went wrong with that request. Please try again.