[models] Added AbstractNotification #202

Implements and closes #102
This commit is contained in:
Federico Capoano 2019-04-28 14:59:42 -04:00
parent c271193215
commit d55aae4e3d
7 changed files with 367 additions and 309 deletions

View file

@ -401,6 +401,27 @@ In this example the target object can be of type Foo or Bar and the appropriate
Thanks to @DaWy
``AbstractNotification`` model
------------------------------
In case you need to customize the notification model in order to add field or
customised features that depend on your application, you can inherit and extend
the ``AbstractNotification`` model, example:
.. code-block:: python
from django.db import models
from notifications.base.models import AbstractNotification
class Notification(AbstractNotification):
# custom field example
category = models.ForeignKey('myapp.Category',
on_delete=models.CASCADE)
class Meta(AbstractNotification.Meta):
abstract = False
Notes
=====

View file

View file

@ -0,0 +1,317 @@
# -*- coding: utf-8 -*-
# pylint: disable=too-many-lines
from distutils.version import \
StrictVersion # pylint: disable=no-name-in-module,import-error
from django import get_version
from django.conf import settings
from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ImproperlyConfigured
from django.db import models
from django.db.models.query import QuerySet
from django.utils import timezone
from django.utils.encoding import python_2_unicode_compatible
from django.utils.six import text_type
from jsonfield.fields import JSONField
from model_utils import Choices
from notifications import settings as notifications_settings
from notifications.signals import notify
from notifications.utils import id2slug
from swapper import load_model
if StrictVersion(get_version()) >= StrictVersion('1.8.0'):
from django.contrib.contenttypes.fields import GenericForeignKey # noqa
else:
from django.contrib.contenttypes.generic import GenericForeignKey # noqa
EXTRA_DATA = notifications_settings.get_config()['USE_JSONFIELD']
def is_soft_delete():
return notifications_settings.get_config()['SOFT_DELETE']
def assert_soft_delete():
if not is_soft_delete():
# msg = """To use 'deleted' field, please set 'SOFT_DELETE'=True in settings.
# Otherwise NotificationQuerySet.unread and NotificationQuerySet.read do NOT filter by 'deleted' field.
# """
msg = 'REVERTME'
raise ImproperlyConfigured(msg)
class NotificationQuerySet(models.query.QuerySet):
''' Notification QuerySet '''
def unsent(self):
return self.filter(emailed=False)
def sent(self):
return self.filter(emailed=True)
def unread(self, include_deleted=False):
"""Return only unread items in the current queryset"""
if is_soft_delete() and not include_deleted:
return self.filter(unread=True, deleted=False)
# When SOFT_DELETE=False, developers are supposed NOT to touch 'deleted' field.
# In this case, to improve query performance, don't filter by 'deleted' field
return self.filter(unread=True)
def read(self, include_deleted=False):
"""Return only read items in the current queryset"""
if is_soft_delete() and not include_deleted:
return self.filter(unread=False, deleted=False)
# When SOFT_DELETE=False, developers are supposed NOT to touch 'deleted' field.
# In this case, to improve query performance, don't filter by 'deleted' field
return self.filter(unread=False)
def mark_all_as_read(self, recipient=None):
"""Mark as read any unread messages in the current queryset.
Optionally, filter these by recipient first.
"""
# We want to filter out read ones, as later we will store
# the time they were marked as read.
qset = self.unread(True)
if recipient:
qset = qset.filter(recipient=recipient)
return qset.update(unread=False)
def mark_all_as_unread(self, recipient=None):
"""Mark as unread any read messages in the current queryset.
Optionally, filter these by recipient first.
"""
qset = self.read(True)
if recipient:
qset = qset.filter(recipient=recipient)
return qset.update(unread=True)
def deleted(self):
"""Return only deleted items in the current queryset"""
assert_soft_delete()
return self.filter(deleted=True)
def active(self):
"""Return only active(un-deleted) items in the current queryset"""
assert_soft_delete()
return self.filter(deleted=False)
def mark_all_as_deleted(self, recipient=None):
"""Mark current queryset as deleted.
Optionally, filter by recipient first.
"""
assert_soft_delete()
qset = self.active()
if recipient:
qset = qset.filter(recipient=recipient)
return qset.update(deleted=True)
def mark_all_as_active(self, recipient=None):
"""Mark current queryset as active(un-deleted).
Optionally, filter by recipient first.
"""
assert_soft_delete()
qset = self.deleted()
if recipient:
qset = qset.filter(recipient=recipient)
return qset.update(deleted=False)
def mark_as_unsent(self, recipient=None):
qset = self.sent()
if recipient:
qset = qset.filter(recipient=recipient)
return qset.update(emailed=False)
def mark_as_sent(self, recipient=None):
qset = self.unsent()
if recipient:
qset = qset.filter(recipient=recipient)
return qset.update(emailed=True)
@python_2_unicode_compatible
class AbstractNotification(models.Model):
"""
Action model describing the actor acting out a verb (on an optional
target).
Nomenclature based on http://activitystrea.ms/specs/atom/1.0/
Generalized Format::
<actor> <verb> <time>
<actor> <verb> <target> <time>
<actor> <verb> <action_object> <target> <time>
Examples::
<justquick> <reached level 60> <1 minute ago>
<brosner> <commented on> <pinax/pinax> <2 hours ago>
<washingtontimes> <started follow> <justquick> <8 minutes ago>
<mitsuhiko> <closed> <issue 70> on <mitsuhiko/flask> <about 2 hours ago>
Unicode Representation::
justquick reached level 60 1 minute ago
mitsuhiko closed issue 70 on mitsuhiko/flask 3 hours ago
HTML Representation::
<a href="http://oebfare.com/">brosner</a> commented on <a href="http://github.com/pinax/pinax">pinax/pinax</a> 2 hours ago # noqa
"""
LEVELS = Choices('success', 'info', 'warning', 'error')
level = models.CharField(choices=LEVELS, default=LEVELS.info, max_length=20)
recipient = models.ForeignKey(
settings.AUTH_USER_MODEL,
blank=False,
related_name='notifications',
on_delete=models.CASCADE
)
unread = models.BooleanField(default=True, blank=False, db_index=True)
actor_content_type = models.ForeignKey(ContentType, related_name='notify_actor', on_delete=models.CASCADE)
actor_object_id = models.CharField(max_length=255)
actor = GenericForeignKey('actor_content_type', 'actor_object_id')
verb = models.CharField(max_length=255)
description = models.TextField(blank=True, null=True)
target_content_type = models.ForeignKey(
ContentType,
related_name='notify_target',
blank=True,
null=True,
on_delete=models.CASCADE
)
target_object_id = models.CharField(max_length=255, blank=True, null=True)
target = GenericForeignKey('target_content_type', 'target_object_id')
action_object_content_type = models.ForeignKey(ContentType, blank=True, null=True,
related_name='notify_action_object', on_delete=models.CASCADE)
action_object_object_id = models.CharField(max_length=255, blank=True, null=True)
action_object = GenericForeignKey('action_object_content_type', 'action_object_object_id')
timestamp = models.DateTimeField(default=timezone.now, db_index=True)
public = models.BooleanField(default=True, db_index=True)
deleted = models.BooleanField(default=False, db_index=True)
emailed = models.BooleanField(default=False, db_index=True)
data = JSONField(blank=True, null=True)
objects = NotificationQuerySet.as_manager()
class Meta:
abstract = True
ordering = ('-timestamp',)
app_label = 'notifications'
# speed up notifications count query
index_together = ('recipient', 'unread')
def __str__(self):
ctx = {
'actor': self.actor,
'verb': self.verb,
'action_object': self.action_object,
'target': self.target,
'timesince': self.timesince()
}
if self.target:
if self.action_object:
return u'%(actor)s %(verb)s %(action_object)s on %(target)s %(timesince)s ago' % ctx
return u'%(actor)s %(verb)s %(target)s %(timesince)s ago' % ctx
if self.action_object:
return u'%(actor)s %(verb)s %(action_object)s %(timesince)s ago' % ctx
return u'%(actor)s %(verb)s %(timesince)s ago' % ctx
def timesince(self, now=None):
"""
Shortcut for the ``django.utils.timesince.timesince`` function of the
current timestamp.
"""
from django.utils.timesince import timesince as timesince_
return timesince_(self.timestamp, now)
@property
def slug(self):
return id2slug(self.id)
def mark_as_read(self):
if self.unread:
self.unread = False
self.save()
def mark_as_unread(self):
if not self.unread:
self.unread = True
self.save()
def notify_handler(verb, **kwargs):
"""
Handler function to create Notification instance upon action signal call.
"""
# Pull the options out of kwargs
kwargs.pop('signal', None)
recipient = kwargs.pop('recipient')
actor = kwargs.pop('sender')
optional_objs = [
(kwargs.pop(opt, None), opt)
for opt in ('target', 'action_object')
]
public = bool(kwargs.pop('public', True))
description = kwargs.pop('description', None)
timestamp = kwargs.pop('timestamp', timezone.now())
Notification = load_model('notifications', 'Notification')
level = kwargs.pop('level', Notification.LEVELS.info)
# Check if User or Group
if isinstance(recipient, Group):
recipients = recipient.user_set.all()
elif isinstance(recipient, (QuerySet, list)):
recipients = recipient
else:
recipients = [recipient]
new_notifications = []
for recipient in recipients:
newnotify = Notification(
recipient=recipient,
actor_content_type=ContentType.objects.get_for_model(actor),
actor_object_id=actor.pk,
verb=text_type(verb),
public=public,
description=description,
timestamp=timestamp,
level=level,
)
# Set optional objects
for obj, opt in optional_objs:
if obj is not None:
setattr(newnotify, '%s_object_id' % opt, obj.pk)
setattr(newnotify, '%s_content_type' % opt,
ContentType.objects.get_for_model(obj))
if kwargs and EXTRA_DATA:
newnotify.data = kwargs
newnotify.save()
new_notifications.append(newnotify)
return new_notifications
# connect the signal
notify.connect(notify_handler, dispatch_uid='notifications.models.notification')

View file

@ -4,6 +4,7 @@ from __future__ import unicode_literals
from django.db import models, migrations
import django.utils.timezone
from django.conf import settings
import swapper
class Migration(migrations.Migration):
@ -33,6 +34,7 @@ class Migration(migrations.Migration):
('target_content_type', models.ForeignKey(related_name='notify_target', blank=True, to='contenttypes.ContentType', null=True, on_delete=models.CASCADE)),
],
options={
'swappable': swapper.swappable_setting('notifications', 'Notification'),
'ordering': ('-timestamp',),
},
bases=(models.Model,),

View file

@ -0,0 +1,19 @@
# Generated by Django 2.1.8 on 2019-04-27 21:26
from django.conf import settings
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('notifications', '0007_add_timestamp_index'),
]
operations = [
migrations.AlterIndexTogether(
name='notification',
index_together={('recipient', 'unread')},
),
]

View file

@ -1,312 +1,10 @@
''' Django notifications models file '''
# -*- coding: utf-8 -*-
# pylint: disable=too-many-lines
from distutils.version import StrictVersion # pylint: disable=no-name-in-module,import-error
from swapper import swappable_setting
from django import get_version
from django.conf import settings
from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ImproperlyConfigured
from django.db import models
from django.db.models.query import QuerySet
from django.utils import timezone
from django.utils.six import text_type
from jsonfield.fields import JSONField
from model_utils import Choices
from notifications import settings as notifications_settings
from notifications.signals import notify
from notifications.utils import id2slug
from .base.models import AbstractNotification, notify_handler # noqa
if StrictVersion(get_version()) >= StrictVersion('1.8.0'):
from django.contrib.contenttypes.fields import GenericForeignKey # noqa
else:
from django.contrib.contenttypes.generic import GenericForeignKey # noqa
class Notification(AbstractNotification):
EXTRA_DATA = notifications_settings.get_config()['USE_JSONFIELD']
def is_soft_delete():
return notifications_settings.get_config()['SOFT_DELETE']
def assert_soft_delete():
if not is_soft_delete():
msg = """To use 'deleted' field, please set 'SOFT_DELETE'=True in settings.
Otherwise NotificationQuerySet.unread and NotificationQuerySet.read do NOT filter by 'deleted' field.
"""
raise ImproperlyConfigured(msg)
class NotificationQuerySet(models.query.QuerySet):
''' Notification QuerySet '''
def unsent(self):
return self.filter(emailed=False)
def sent(self):
return self.filter(emailed=True)
def unread(self, include_deleted=False):
"""Return only unread items in the current queryset"""
if is_soft_delete() and not include_deleted:
return self.filter(unread=True, deleted=False)
# When SOFT_DELETE=False, developers are supposed NOT to touch 'deleted' field.
# In this case, to improve query performance, don't filter by 'deleted' field
return self.filter(unread=True)
def read(self, include_deleted=False):
"""Return only read items in the current queryset"""
if is_soft_delete() and not include_deleted:
return self.filter(unread=False, deleted=False)
# When SOFT_DELETE=False, developers are supposed NOT to touch 'deleted' field.
# In this case, to improve query performance, don't filter by 'deleted' field
return self.filter(unread=False)
def mark_all_as_read(self, recipient=None):
"""Mark as read any unread messages in the current queryset.
Optionally, filter these by recipient first.
"""
# We want to filter out read ones, as later we will store
# the time they were marked as read.
qset = self.unread(True)
if recipient:
qset = qset.filter(recipient=recipient)
return qset.update(unread=False)
def mark_all_as_unread(self, recipient=None):
"""Mark as unread any read messages in the current queryset.
Optionally, filter these by recipient first.
"""
qset = self.read(True)
if recipient:
qset = qset.filter(recipient=recipient)
return qset.update(unread=True)
def deleted(self):
"""Return only deleted items in the current queryset"""
assert_soft_delete()
return self.filter(deleted=True)
def active(self):
"""Return only active(un-deleted) items in the current queryset"""
assert_soft_delete()
return self.filter(deleted=False)
def mark_all_as_deleted(self, recipient=None):
"""Mark current queryset as deleted.
Optionally, filter by recipient first.
"""
assert_soft_delete()
qset = self.active()
if recipient:
qset = qset.filter(recipient=recipient)
return qset.update(deleted=True)
def mark_all_as_active(self, recipient=None):
"""Mark current queryset as active(un-deleted).
Optionally, filter by recipient first.
"""
assert_soft_delete()
qset = self.deleted()
if recipient:
qset = qset.filter(recipient=recipient)
return qset.update(deleted=False)
def mark_as_unsent(self, recipient=None):
qset = self.sent()
if recipient:
qset = qset.filter(recipient=recipient)
return qset.update(emailed=False)
def mark_as_sent(self, recipient=None):
qset = self.unsent()
if recipient:
qset = qset.filter(recipient=recipient)
return qset.update(emailed=True)
class Notification(models.Model):
"""
Action model describing the actor acting out a verb (on an optional
target).
Nomenclature based on http://activitystrea.ms/specs/atom/1.0/
Generalized Format::
<actor> <verb> <time>
<actor> <verb> <target> <time>
<actor> <verb> <action_object> <target> <time>
Examples::
<justquick> <reached level 60> <1 minute ago>
<brosner> <commented on> <pinax/pinax> <2 hours ago>
<washingtontimes> <started follow> <justquick> <8 minutes ago>
<mitsuhiko> <closed> <issue 70> on <mitsuhiko/flask> <about 2 hours ago>
Unicode Representation::
justquick reached level 60 1 minute ago
mitsuhiko closed issue 70 on mitsuhiko/flask 3 hours ago
HTML Representation::
<a href="http://oebfare.com/">brosner</a> commented on <a href="http://github.com/pinax/pinax">pinax/pinax</a> 2 hours ago # noqa
"""
LEVELS = Choices('success', 'info', 'warning', 'error')
level = models.CharField(choices=LEVELS, default=LEVELS.info, max_length=20)
recipient = models.ForeignKey(
settings.AUTH_USER_MODEL,
blank=False,
related_name='notifications',
on_delete=models.CASCADE
)
unread = models.BooleanField(default=True, blank=False, db_index=True)
actor_content_type = models.ForeignKey(ContentType, related_name='notify_actor', on_delete=models.CASCADE)
actor_object_id = models.CharField(max_length=255)
actor = GenericForeignKey('actor_content_type', 'actor_object_id')
verb = models.CharField(max_length=255)
description = models.TextField(blank=True, null=True)
target_content_type = models.ForeignKey(
ContentType,
related_name='notify_target',
blank=True,
null=True,
on_delete=models.CASCADE
)
target_object_id = models.CharField(max_length=255, blank=True, null=True)
target = GenericForeignKey('target_content_type', 'target_object_id')
action_object_content_type = models.ForeignKey(ContentType, blank=True, null=True,
related_name='notify_action_object', on_delete=models.CASCADE)
action_object_object_id = models.CharField(max_length=255, blank=True, null=True)
action_object = GenericForeignKey('action_object_content_type', 'action_object_object_id')
timestamp = models.DateTimeField(default=timezone.now, db_index=True)
public = models.BooleanField(default=True, db_index=True)
deleted = models.BooleanField(default=False, db_index=True)
emailed = models.BooleanField(default=False, db_index=True)
data = JSONField(blank=True, null=True)
objects = NotificationQuerySet.as_manager()
class Meta:
ordering = ('-timestamp',)
app_label = 'notifications'
def __unicode__(self):
ctx = {
'actor': self.actor,
'verb': self.verb,
'action_object': self.action_object,
'target': self.target,
'timesince': self.timesince()
}
if self.target:
if self.action_object:
return u'%(actor)s %(verb)s %(action_object)s on %(target)s %(timesince)s ago' % ctx
return u'%(actor)s %(verb)s %(target)s %(timesince)s ago' % ctx
if self.action_object:
return u'%(actor)s %(verb)s %(action_object)s %(timesince)s ago' % ctx
return u'%(actor)s %(verb)s %(timesince)s ago' % ctx
def __str__(self): # Adds support for Python 3
return self.__unicode__()
def timesince(self, now=None):
"""
Shortcut for the ``django.utils.timesince.timesince`` function of the
current timestamp.
"""
from django.utils.timesince import timesince as timesince_
return timesince_(self.timestamp, now)
@property
def slug(self):
return id2slug(self.id)
def mark_as_read(self):
if self.unread:
self.unread = False
self.save()
def mark_as_unread(self):
if not self.unread:
self.unread = True
self.save()
def notify_handler(verb, **kwargs):
"""
Handler function to create Notification instance upon action signal call.
"""
# Pull the options out of kwargs
kwargs.pop('signal', None)
recipient = kwargs.pop('recipient')
actor = kwargs.pop('sender')
optional_objs = [
(kwargs.pop(opt, None), opt)
for opt in ('target', 'action_object')
]
public = bool(kwargs.pop('public', True))
description = kwargs.pop('description', None)
timestamp = kwargs.pop('timestamp', timezone.now())
level = kwargs.pop('level', Notification.LEVELS.info)
# Check if User or Group
if isinstance(recipient, Group):
recipients = recipient.user_set.all()
elif isinstance(recipient, (QuerySet, list)):
recipients = recipient
else:
recipients = [recipient]
new_notifications = []
for recipient in recipients:
newnotify = Notification(
recipient=recipient,
actor_content_type=ContentType.objects.get_for_model(actor),
actor_object_id=actor.pk,
verb=text_type(verb),
public=public,
description=description,
timestamp=timestamp,
level=level,
)
# Set optional objects
for obj, opt in optional_objs:
if obj is not None:
setattr(newnotify, '%s_object_id' % opt, obj.pk)
setattr(newnotify, '%s_content_type' % opt,
ContentType.objects.get_for_model(obj))
if kwargs and EXTRA_DATA:
newnotify.data = kwargs
newnotify.save()
new_notifications.append(newnotify)
return new_notifications
# connect the signal
notify.connect(notify_handler, dispatch_uid='notifications.models.notification')
class Meta(AbstractNotification.Meta):
abstract = False
swappable = swappable_setting('notifications', 'Notification')

View file

@ -29,7 +29,8 @@ setup(
'django>=1.7',
'django-model-utils>=2.0.3',
'jsonfield>=1.0.3',
'pytz'
'pytz',
'swapper'
],
test_requires=[
'django>=1.7',