1 | import datetime
|
---|
2 |
|
---|
3 | try:
|
---|
4 | import cPickle as pickle
|
---|
5 | except ImportError:
|
---|
6 | import pickle
|
---|
7 |
|
---|
8 | from django.db import models
|
---|
9 | from django.db.models.query import QuerySet
|
---|
10 | from django.conf import settings
|
---|
11 | from django.core.urlresolvers import reverse
|
---|
12 | from django.template import Context
|
---|
13 | from django.template.loader import render_to_string
|
---|
14 |
|
---|
15 | from django.core.exceptions import ImproperlyConfigured
|
---|
16 |
|
---|
17 | from django.contrib.sites.models import Site
|
---|
18 | from django.contrib.auth.models import User
|
---|
19 | from django.contrib.auth.models import AnonymousUser
|
---|
20 |
|
---|
21 | from django.contrib.contenttypes.models import ContentType
|
---|
22 | from django.contrib.contenttypes import generic
|
---|
23 |
|
---|
24 | from django.utils.translation import ugettext_lazy as _
|
---|
25 | from django.utils.translation import ugettext, get_language, activate
|
---|
26 |
|
---|
27 | # favour django-html_mailer but fall back to django.core.mail
|
---|
28 | if 'html_mailer' in settings.INSTALLED_APPS:
|
---|
29 | from html_mailer import send_mail
|
---|
30 | else:
|
---|
31 | if 'mailer' in settings.INSTALLED_APPS:
|
---|
32 | from mailer import send_mail
|
---|
33 | else:
|
---|
34 | from django.core.mail import send_mail
|
---|
35 |
|
---|
36 | QUEUE_ALL = getattr(settings, "NOTIFICATION_QUEUE_ALL", False)
|
---|
37 |
|
---|
38 | class LanguageStoreNotAvailable(Exception):
|
---|
39 | pass
|
---|
40 |
|
---|
41 | class NoticeType(models.Model):
|
---|
42 |
|
---|
43 | label = models.CharField(_('label'), max_length=40)
|
---|
44 | display = models.CharField(_('display'), max_length=50)
|
---|
45 | description = models.CharField(_('description'), max_length=100)
|
---|
46 |
|
---|
47 | # by default only on for media with sensitivity less than or equal to this number
|
---|
48 | default = models.IntegerField(_('default'))
|
---|
49 |
|
---|
50 | def __unicode__(self):
|
---|
51 | return self.label
|
---|
52 |
|
---|
53 | class Meta:
|
---|
54 | verbose_name = _("notice type")
|
---|
55 | verbose_name_plural = _("notice types")
|
---|
56 |
|
---|
57 |
|
---|
58 | # if this gets updated, the create() method below needs to be as well...
|
---|
59 | NOTICE_MEDIA = (
|
---|
60 | ("1", _("Email")),
|
---|
61 | )
|
---|
62 |
|
---|
63 | # how spam-sensitive is the medium
|
---|
64 | NOTICE_MEDIA_DEFAULTS = {
|
---|
65 | "1": 2 # email
|
---|
66 | }
|
---|
67 |
|
---|
68 | class NoticeSetting(models.Model):
|
---|
69 | """
|
---|
70 | Indicates, for a given user, whether to send notifications
|
---|
71 | of a given type to a given medium.
|
---|
72 | """
|
---|
73 |
|
---|
74 | user = models.ForeignKey(User, verbose_name=_('user'))
|
---|
75 | notice_type = models.ForeignKey(NoticeType, verbose_name=_('notice type'))
|
---|
76 | medium = models.CharField(_('medium'), max_length=1, choices=NOTICE_MEDIA)
|
---|
77 | send = models.BooleanField(_('send'))
|
---|
78 |
|
---|
79 | class Meta:
|
---|
80 | verbose_name = _("notice setting")
|
---|
81 | verbose_name_plural = _("notice settings")
|
---|
82 | unique_together = ("user", "notice_type", "medium")
|
---|
83 |
|
---|
84 | def get_notification_setting(user, notice_type, medium):
|
---|
85 | try:
|
---|
86 | return NoticeSetting.objects.get(user=user, notice_type=notice_type, medium=medium)
|
---|
87 | except NoticeSetting.DoesNotExist:
|
---|
88 | default = (NOTICE_MEDIA_DEFAULTS[medium] <= notice_type.default)
|
---|
89 | setting = NoticeSetting(user=user, notice_type=notice_type, medium=medium, send=default)
|
---|
90 | setting.save()
|
---|
91 | return setting
|
---|
92 |
|
---|
93 | def should_send(user, notice_type, medium):
|
---|
94 | return get_notification_setting(user, notice_type, medium).send
|
---|
95 |
|
---|
96 |
|
---|
97 | class NoticeManager(models.Manager):
|
---|
98 |
|
---|
99 | def notices_for(self, user, archived=False, unseen=None, on_site=None):
|
---|
100 | """
|
---|
101 | returns Notice objects for the given user.
|
---|
102 |
|
---|
103 | If archived=False, it only include notices not archived.
|
---|
104 | If archived=True, it returns all notices for that user.
|
---|
105 |
|
---|
106 | If unseen=None, it includes all notices.
|
---|
107 | If unseen=True, return only unseen notices.
|
---|
108 | If unseen=False, return only seen notices.
|
---|
109 | """
|
---|
110 | if archived:
|
---|
111 | qs = self.filter(user=user)
|
---|
112 | else:
|
---|
113 | qs = self.filter(user=user, archived=archived)
|
---|
114 | if unseen is not None:
|
---|
115 | qs = qs.filter(unseen=unseen)
|
---|
116 | if on_site is not None:
|
---|
117 | qs = qs.filter(on_site=on_site)
|
---|
118 | return qs
|
---|
119 |
|
---|
120 | def unseen_count_for(self, user, **kwargs):
|
---|
121 | """
|
---|
122 | returns the number of unseen notices for the given user but does not
|
---|
123 | mark them seen
|
---|
124 | """
|
---|
125 | return self.notices_for(user, unseen=True, **kwargs).count()
|
---|
126 |
|
---|
127 | class Notice(models.Model):
|
---|
128 |
|
---|
129 | user = models.ForeignKey(User, verbose_name=_('user'))
|
---|
130 | message = models.TextField(_('message'))
|
---|
131 | notice_type = models.ForeignKey(NoticeType, verbose_name=_('notice type'))
|
---|
132 | added = models.DateTimeField(_('added'), default=datetime.datetime.now)
|
---|
133 | unseen = models.BooleanField(_('unseen'), default=True)
|
---|
134 | archived = models.BooleanField(_('archived'), default=False)
|
---|
135 | on_site = models.BooleanField(_('on site'))
|
---|
136 |
|
---|
137 | objects = NoticeManager()
|
---|
138 |
|
---|
139 | def __unicode__(self):
|
---|
140 | return self.message
|
---|
141 |
|
---|
142 | def archive(self):
|
---|
143 | self.archived = True
|
---|
144 | self.save()
|
---|
145 |
|
---|
146 | def is_unseen(self):
|
---|
147 | """
|
---|
148 | returns value of self.unseen but also changes it to false.
|
---|
149 |
|
---|
150 | Use this in a template to mark an unseen notice differently the first
|
---|
151 | time it is shown.
|
---|
152 | """
|
---|
153 | unseen = self.unseen
|
---|
154 | if unseen:
|
---|
155 | self.unseen = False
|
---|
156 | self.save()
|
---|
157 | return unseen
|
---|
158 |
|
---|
159 | class Meta:
|
---|
160 | ordering = ["-added"]
|
---|
161 | verbose_name = _("notice")
|
---|
162 | verbose_name_plural = _("notices")
|
---|
163 |
|
---|
164 | def get_absolute_url(self):
|
---|
165 | return ("notification_notice", [str(self.pk)])
|
---|
166 | get_absolute_url = models.permalink(get_absolute_url)
|
---|
167 |
|
---|
168 | class NoticeQueueBatch(models.Model):
|
---|
169 | """
|
---|
170 | A queued notice.
|
---|
171 | Denormalized data for a notice.
|
---|
172 | """
|
---|
173 | pickled_data = models.TextField()
|
---|
174 |
|
---|
175 | def create_notice_type(label, display, description, default=2, verbosity=1):
|
---|
176 | """
|
---|
177 | Creates a new NoticeType.
|
---|
178 |
|
---|
179 | This is intended to be used by other apps as a post_syncdb manangement step.
|
---|
180 | """
|
---|
181 | try:
|
---|
182 | notice_type = NoticeType.objects.get(label=label)
|
---|
183 | updated = False
|
---|
184 | if display != notice_type.display:
|
---|
185 | notice_type.display = display
|
---|
186 | updated = True
|
---|
187 | if description != notice_type.description:
|
---|
188 | notice_type.description = description
|
---|
189 | updated = True
|
---|
190 | if default != notice_type.default:
|
---|
191 | notice_type.default = default
|
---|
192 | updated = True
|
---|
193 | if updated:
|
---|
194 | notice_type.save()
|
---|
195 | if verbosity > 1:
|
---|
196 | print "Updated %s NoticeType" % label
|
---|
197 | except NoticeType.DoesNotExist:
|
---|
198 | NoticeType(label=label, display=display, description=description, default=default).save()
|
---|
199 | if verbosity > 1:
|
---|
200 | print "Created %s NoticeType" % label
|
---|
201 |
|
---|
202 | def get_notification_language(user):
|
---|
203 | """
|
---|
204 | Returns site-specific notification language for this user. Raises
|
---|
205 | LanguageStoreNotAvailable if this site does not use translated
|
---|
206 | notifications.
|
---|
207 | """
|
---|
208 | if getattr(settings, 'NOTIFICATION_LANGUAGE_MODULE', False):
|
---|
209 | try:
|
---|
210 | app_label, model_name = settings.NOTIFICATION_LANGUAGE_MODULE.split('.')
|
---|
211 | model = models.get_model(app_label, model_name)
|
---|
212 | language_model = model._default_manager.get(user__id__exact=user.id)
|
---|
213 | if hasattr(language_model, 'language'):
|
---|
214 | return language_model.language
|
---|
215 | except (ImportError, ImproperlyConfigured, model.DoesNotExist):
|
---|
216 | raise LanguageStoreNotAvailable
|
---|
217 | raise LanguageStoreNotAvailable
|
---|
218 |
|
---|
219 | def get_formatted_messages(formats, label, context):
|
---|
220 | """
|
---|
221 | Returns a dictionary with the format identifier as the key. The values are
|
---|
222 | are fully rendered templates with the given context.
|
---|
223 | """
|
---|
224 | format_templates = {}
|
---|
225 | for format in formats:
|
---|
226 | # conditionally turn off autoescaping for .txt extensions in format
|
---|
227 | if format.endswith(".txt"):
|
---|
228 | context.autoescape = False
|
---|
229 | else:
|
---|
230 | context.autoescape = True
|
---|
231 | format_templates[format] = render_to_string((
|
---|
232 | 'notification/%s/%s' % (label, format),
|
---|
233 | 'notification/%s' % format), context_instance=context)
|
---|
234 | return format_templates
|
---|
235 |
|
---|
236 | def send_now(users, label, extra_context=None, on_site=True):
|
---|
237 | """
|
---|
238 | Creates a new notice.
|
---|
239 |
|
---|
240 | This is intended to be how other apps create new notices.
|
---|
241 |
|
---|
242 | notification.send(user, 'friends_invite_sent', {
|
---|
243 | 'spam': 'eggs',
|
---|
244 | 'foo': 'bar',
|
---|
245 | )
|
---|
246 |
|
---|
247 | You can pass in on_site=False to prevent the notice emitted from being
|
---|
248 | displayed on the site.
|
---|
249 | """
|
---|
250 | if extra_context is None:
|
---|
251 | extra_context = {}
|
---|
252 |
|
---|
253 | notice_type = NoticeType.objects.get(label=label)
|
---|
254 |
|
---|
255 | current_site = Site.objects.get_current()
|
---|
256 | notices_url = u"http://%s%s" % (
|
---|
257 | unicode(current_site),
|
---|
258 | reverse("notification_notices"),
|
---|
259 | )
|
---|
260 |
|
---|
261 | current_language = get_language()
|
---|
262 |
|
---|
263 | formats = (
|
---|
264 | 'short.txt',
|
---|
265 | 'full.txt',
|
---|
266 | 'notice.html',
|
---|
267 | 'full.html',
|
---|
268 | ) # TODO make formats configurable
|
---|
269 |
|
---|
270 | for user in users:
|
---|
271 | recipients = []
|
---|
272 | # get user language for user from language store defined in
|
---|
273 | # NOTIFICATION_LANGUAGE_MODULE setting
|
---|
274 | try:
|
---|
275 | language = get_notification_language(user)
|
---|
276 | except LanguageStoreNotAvailable:
|
---|
277 | language = None
|
---|
278 |
|
---|
279 | if language is not None:
|
---|
280 | # activate the user's language
|
---|
281 | activate(language)
|
---|
282 |
|
---|
283 | # update context with user specific translations
|
---|
284 | context = Context({
|
---|
285 | "user": user,
|
---|
286 | "notice": ugettext(notice_type.display),
|
---|
287 | "notices_url": notices_url,
|
---|
288 | "current_site": current_site,
|
---|
289 | })
|
---|
290 | context.update(extra_context)
|
---|
291 |
|
---|
292 | # get prerendered format messages
|
---|
293 | messages = get_formatted_messages(formats, label, context)
|
---|
294 |
|
---|
295 | # Strip newlines from subject
|
---|
296 | subject = ''.join(render_to_string('notification/email_subject.txt', {
|
---|
297 | 'message': messages['short.txt'],
|
---|
298 | }, context).splitlines())
|
---|
299 |
|
---|
300 | message_text = render_to_string('notification/email_body.txt', {
|
---|
301 | 'message': messages['full.txt'],
|
---|
302 | }, context)
|
---|
303 |
|
---|
304 | # Only if the html mailer is installed, can the html version be attached to the mail sent. The html_mailer version
|
---|
305 | # of send_mail checks to see if the message parameter is a dictionary with two messages types (for HTML mails)
|
---|
306 | # or a string (for plain text-only mails).
|
---|
307 | if 'html_mailer' in settings.INSTALLED_APPS:
|
---|
308 | message_html = render_to_string('notification/email_body.txt', {
|
---|
309 | 'message': messages['full.html'],
|
---|
310 | }, context)
|
---|
311 |
|
---|
312 | message = {"text":message_text, "html":message_html}
|
---|
313 | else:
|
---|
314 | message = message_text
|
---|
315 |
|
---|
316 | notice = Notice.objects.create(user=user, message=messages['notice.html'],
|
---|
317 | notice_type=notice_type, on_site=on_site)
|
---|
318 | if should_send(user, notice_type, "1") and user.email: # Email
|
---|
319 | recipients.append(user.email)
|
---|
320 | send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, recipients)
|
---|
321 |
|
---|
322 | # reset environment to original language
|
---|
323 | activate(current_language)
|
---|
324 |
|
---|
325 | def send(*args, **kwargs):
|
---|
326 | """
|
---|
327 | A basic interface around both queue and send_now. This honors a global
|
---|
328 | flag NOTIFICATION_QUEUE_ALL that helps determine whether all calls should
|
---|
329 | be queued or not. A per call ``queue`` or ``now`` keyword argument can be
|
---|
330 | used to always override the default global behavior.
|
---|
331 | """
|
---|
332 | queue_flag = kwargs.pop("queue", False)
|
---|
333 | now_flag = kwargs.pop("now", False)
|
---|
334 | assert not (queue_flag and now_flag), "'queue' and 'now' cannot both be True."
|
---|
335 | if queue_flag:
|
---|
336 | return queue(*args, **kwargs)
|
---|
337 | elif now_flag:
|
---|
338 | return send_now(*args, **kwargs)
|
---|
339 | else:
|
---|
340 | if QUEUE_ALL:
|
---|
341 | return queue(*args, **kwargs)
|
---|
342 | else:
|
---|
343 | return send_now(*args, **kwargs)
|
---|
344 |
|
---|
345 | def queue(users, label, extra_context=None, on_site=True):
|
---|
346 | """
|
---|
347 | Queue the notification in NoticeQueueBatch. This allows for large amounts
|
---|
348 | of user notifications to be deferred to a seperate process running outside
|
---|
349 | the webserver.
|
---|
350 | """
|
---|
351 | if extra_context is None:
|
---|
352 | extra_context = {}
|
---|
353 | if isinstance(users, QuerySet):
|
---|
354 | users = [row["pk"] for row in users.values("pk")]
|
---|
355 | else:
|
---|
356 | users = [user.pk for user in users]
|
---|
357 | notices = []
|
---|
358 | for user in users:
|
---|
359 | notices.append((user, label, extra_context, on_site))
|
---|
360 | NoticeQueueBatch(pickled_data=pickle.dumps(notices).encode("base64")).save()
|
---|
361 |
|
---|
362 | class ObservedItemManager(models.Manager):
|
---|
363 |
|
---|
364 | def all_for(self, observed, signal):
|
---|
365 | """
|
---|
366 | Returns all ObservedItems for an observed object,
|
---|
367 | to be sent when a signal is emited.
|
---|
368 | """
|
---|
369 | content_type = ContentType.objects.get_for_model(observed)
|
---|
370 | observed_items = self.filter(content_type=content_type, object_id=observed.id, signal=signal)
|
---|
371 | return observed_items
|
---|
372 |
|
---|
373 | def get_for(self, observed, observer, signal):
|
---|
374 | content_type = ContentType.objects.get_for_model(observed)
|
---|
375 | observed_item = self.get(content_type=content_type, object_id=observed.id, user=observer, signal=signal)
|
---|
376 | return observed_item
|
---|
377 |
|
---|
378 |
|
---|
379 | class ObservedItem(models.Model):
|
---|
380 |
|
---|
381 | user = models.ForeignKey(User, verbose_name=_('user'))
|
---|
382 |
|
---|
383 | content_type = models.ForeignKey(ContentType)
|
---|
384 | object_id = models.PositiveIntegerField()
|
---|
385 | observed_object = generic.GenericForeignKey('content_type', 'object_id')
|
---|
386 |
|
---|
387 | notice_type = models.ForeignKey(NoticeType, verbose_name=_('notice type'))
|
---|
388 |
|
---|
389 | added = models.DateTimeField(_('added'), default=datetime.datetime.now)
|
---|
390 |
|
---|
391 | # the signal that will be listened to send the notice
|
---|
392 | signal = models.TextField(verbose_name=_('signal'))
|
---|
393 |
|
---|
394 | objects = ObservedItemManager()
|
---|
395 |
|
---|
396 | class Meta:
|
---|
397 | ordering = ['-added']
|
---|
398 | verbose_name = _('observed item')
|
---|
399 | verbose_name_plural = _('observed items')
|
---|
400 |
|
---|
401 | def send_notice(self):
|
---|
402 | send([self.user], self.notice_type.label,
|
---|
403 | {'observed': self.observed_object})
|
---|
404 |
|
---|
405 |
|
---|
406 | def observe(observed, observer, notice_type_label, signal='post_save'):
|
---|
407 | """
|
---|
408 | Create a new ObservedItem.
|
---|
409 |
|
---|
410 | To be used by applications to register a user as an observer for some object.
|
---|
411 | """
|
---|
412 | notice_type = NoticeType.objects.get(label=notice_type_label)
|
---|
413 | observed_item = ObservedItem(user=observer, observed_object=observed,
|
---|
414 | notice_type=notice_type, signal=signal)
|
---|
415 | observed_item.save()
|
---|
416 | return observed_item
|
---|
417 |
|
---|
418 | def stop_observing(observed, observer, signal='post_save'):
|
---|
419 | """
|
---|
420 | Remove an observed item.
|
---|
421 | """
|
---|
422 | observed_item = ObservedItem.objects.get_for(observed, observer, signal)
|
---|
423 | observed_item.delete()
|
---|
424 |
|
---|
425 | def send_observation_notices_for(observed, signal='post_save'):
|
---|
426 | """
|
---|
427 | Send a notice for each registered user about an observed object.
|
---|
428 | """
|
---|
429 | observed_items = ObservedItem.objects.all_for(observed, signal)
|
---|
430 | for observed_item in observed_items:
|
---|
431 | observed_item.send_notice()
|
---|
432 | return observed_items
|
---|
433 |
|
---|
434 | def is_observing(observed, observer, signal='post_save'):
|
---|
435 | if isinstance(observer, AnonymousUser):
|
---|
436 | return False
|
---|
437 | try:
|
---|
438 | observed_items = ObservedItem.objects.get_for(observed, observer, signal)
|
---|
439 | return True
|
---|
440 | except ObservedItem.DoesNotExist:
|
---|
441 | return False
|
---|
442 | except ObservedItem.MultipleObjectsReturned:
|
---|
443 | return True
|
---|
444 |
|
---|
445 | def handle_observations(sender, instance, *args, **kw):
|
---|
446 | send_observation_notices_for(instance)
|
---|