| 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)
|
|---|