mirror of
https://github.com/Hopiu/django-notifications.git
synced 2026-05-16 00:53:08 +00:00
Restructured files
This commit is contained in:
parent
f33afff505
commit
c6b5a09610
17 changed files with 433 additions and 422 deletions
|
|
@ -4,19 +4,11 @@ from django.contrib import admin
|
|||
from django.utils.translation import gettext_lazy
|
||||
from swapper import load_model
|
||||
|
||||
from notifications.base.admin import AbstractNotificationAdmin
|
||||
|
||||
Notification = load_model("notifications", "Notification")
|
||||
|
||||
|
||||
def mark_unread(queryset, *args, **kwargs):
|
||||
queryset.update(unread=True)
|
||||
|
||||
|
||||
mark_unread.short_description = gettext_lazy("Mark selected notifications as unread")
|
||||
|
||||
|
||||
class NotificationAdmin(AbstractNotificationAdmin):
|
||||
@admin.register(Notification)
|
||||
class NotificationAdmin(admin.ModelAdmin):
|
||||
raw_id_fields = ("recipient",)
|
||||
readonly_fields = ("action_object_url", "actor_object_url", "target_object_url")
|
||||
list_display = ("recipient", "actor", "level", "target", "unread", "public")
|
||||
|
|
@ -26,11 +18,16 @@ class NotificationAdmin(AbstractNotificationAdmin):
|
|||
"public",
|
||||
"timestamp",
|
||||
)
|
||||
actions = [mark_unread]
|
||||
actions = ("mark_unread", "mark_read")
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
return qs.prefetch_related("actor")
|
||||
|
||||
@admin.action(description=gettext_lazy("Mark selected notifications as unread"))
|
||||
def mark_unread(self, request, queryset):
|
||||
queryset.update(unread=True)
|
||||
|
||||
admin.site.register(Notification, NotificationAdmin)
|
||||
@admin.action(description=gettext_lazy("Mark selected notifications as read"))
|
||||
def mark_read(self, request, queryset):
|
||||
queryset.update(unread=False)
|
||||
|
|
|
|||
|
|
@ -8,3 +8,12 @@ class Config(AppConfig):
|
|||
name = "notifications"
|
||||
verbose_name = _("Notifications")
|
||||
default_auto_field = "django.db.models.AutoField"
|
||||
|
||||
def ready(self) -> None:
|
||||
from notifications.signals import ( # pylint: disable=import-outside-toplevel
|
||||
notify,
|
||||
notify_handler,
|
||||
)
|
||||
|
||||
notify.connect(notify_handler, dispatch_uid="notifications.models.notification")
|
||||
return super().ready()
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
from django.contrib import admin
|
||||
|
||||
|
||||
class AbstractNotificationAdmin(admin.ModelAdmin):
|
||||
raw_id_fields = ("recipient",)
|
||||
list_display = ("recipient", "actor", "level", "target", "unread", "public")
|
||||
list_filter = (
|
||||
"level",
|
||||
"unread",
|
||||
"public",
|
||||
"timestamp",
|
||||
)
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
return qs.prefetch_related("actor")
|
||||
|
|
@ -1,365 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# pylint: disable=too-many-lines
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
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.urls import NoReverseMatch, reverse
|
||||
from django.utils import timesince, timezone
|
||||
from django.utils.html import format_html
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from swapper import load_model
|
||||
|
||||
from notifications import settings as notifications_settings
|
||||
from notifications.signals import notify
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class NotificationLevel(models.IntegerChoices):
|
||||
SUCCESS = 1
|
||||
INFO = 2
|
||||
WARNING = 3
|
||||
ERROR = 4
|
||||
|
||||
|
||||
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://test.com/">brosner</a> commented on <a href="http://gh.com/pinax/pinax">pinax/pinax</a> 2 hours ago
|
||||
|
||||
"""
|
||||
|
||||
level = models.IntegerField(_("level"), choices=NotificationLevel.choices, default=NotificationLevel.INFO)
|
||||
|
||||
recipient = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="notifications",
|
||||
verbose_name=_("recipient"),
|
||||
blank=False,
|
||||
)
|
||||
unread = models.BooleanField(_("unread"), default=True, blank=False, db_index=True)
|
||||
|
||||
actor_content_type = models.ForeignKey(
|
||||
ContentType, on_delete=models.CASCADE, related_name="notify_actor", verbose_name=_("actor content type")
|
||||
)
|
||||
actor_object_id = models.CharField(_("actor object id"), max_length=255)
|
||||
actor = GenericForeignKey("actor_content_type", "actor_object_id")
|
||||
actor.short_description = _("actor")
|
||||
|
||||
verb = models.CharField(_("verb"), max_length=255)
|
||||
description = models.TextField(_("description"), blank=True, null=True)
|
||||
|
||||
target_content_type = models.ForeignKey(
|
||||
ContentType,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="notify_target",
|
||||
verbose_name=_("target content type"),
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
target_object_id = models.CharField(_("target object id"), max_length=255, blank=True, null=True)
|
||||
target = GenericForeignKey("target_content_type", "target_object_id")
|
||||
target.short_description = _("target")
|
||||
|
||||
action_object_content_type = models.ForeignKey(
|
||||
ContentType,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="notify_action_object",
|
||||
verbose_name=_("action object content type"),
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
action_object_object_id = models.CharField(_("action object object id"), max_length=255, blank=True, null=True)
|
||||
action_object = GenericForeignKey("action_object_content_type", "action_object_object_id")
|
||||
action_object.short_description = _("action object")
|
||||
|
||||
timestamp = models.DateTimeField(_("timestamp"), default=timezone.now, db_index=True)
|
||||
|
||||
public = models.BooleanField(_("public"), default=True, db_index=True)
|
||||
deleted = models.BooleanField(_("deleted"), default=False, db_index=True)
|
||||
emailed = models.BooleanField(_("emailed"), default=False, db_index=True)
|
||||
|
||||
data = models.JSONField(_("data"), blank=True, null=True)
|
||||
|
||||
objects = NotificationQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
ordering = ("-timestamp",)
|
||||
# speed up notifications count query
|
||||
index_together = ("recipient", "unread")
|
||||
verbose_name = _("Notification")
|
||||
verbose_name_plural = _("Notifications")
|
||||
|
||||
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 _("%(actor)s %(verb)s %(action_object)s on %(target)s %(timesince)s ago") % ctx
|
||||
return _("%(actor)s %(verb)s %(target)s %(timesince)s ago") % ctx
|
||||
if self.action_object:
|
||||
return _("%(actor)s %(verb)s %(action_object)s %(timesince)s ago") % ctx
|
||||
return _("%(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.
|
||||
"""
|
||||
|
||||
return timesince.timesince(self.timestamp, now)
|
||||
|
||||
@property
|
||||
def slug(self):
|
||||
return 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 actor_object_url(self):
|
||||
try:
|
||||
url = reverse(
|
||||
f"admin:{self.actor_content_type.app_label}_{self.actor_content_type.model}_change",
|
||||
args=(self.actor_object_id,),
|
||||
)
|
||||
return format_html("<a href='{url}'>{id}</a>", url=url, id=self.actor_object_id)
|
||||
except NoReverseMatch:
|
||||
return self.actor_object_id
|
||||
|
||||
def action_object_url(self):
|
||||
try:
|
||||
url = reverse(
|
||||
f"admin:{self.action_object_content_type.app_label}_{self.action_object_content_type.model}_change",
|
||||
args=(self.action_object_id,),
|
||||
)
|
||||
return format_html("<a href='{url}'>{id}</a>", url=url, id=self.action_object_object_id)
|
||||
except NoReverseMatch:
|
||||
return self.action_object_object_id
|
||||
|
||||
def target_object_url(self):
|
||||
try:
|
||||
url = reverse(
|
||||
f"admin:{self.target_content_type.app_label}_{self.target_content_type.model}_change",
|
||||
args=(self.target_object_id,),
|
||||
)
|
||||
return format_html("<a href='{url}'>{id}</a>", url=url, id=self.target_object_id)
|
||||
except NoReverseMatch:
|
||||
return self.target_object_id
|
||||
|
||||
|
||||
# TODO: move to notifications/signals.py
|
||||
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") # pylint: disable=invalid-name
|
||||
level = kwargs.pop("level", NotificationLevel.INFO)
|
||||
actor_for_concrete_model = kwargs.pop("actor_for_concrete_model", True)
|
||||
|
||||
# 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, for_concrete_model=actor_for_concrete_model),
|
||||
actor_object_id=actor.pk,
|
||||
verb=str(verb),
|
||||
public=public,
|
||||
description=description,
|
||||
timestamp=timestamp,
|
||||
level=level,
|
||||
)
|
||||
|
||||
# Set optional objects
|
||||
for obj, opt in optional_objs:
|
||||
if obj is not None:
|
||||
for_concrete_model = kwargs.pop(f"{opt}_for_concrete_model", True)
|
||||
setattr(newnotify, f"{opt}_object_id", obj.pk)
|
||||
setattr(
|
||||
newnotify,
|
||||
f"{opt}_content_type",
|
||||
ContentType.objects.get_for_model(obj, for_concrete_model=for_concrete_model),
|
||||
)
|
||||
|
||||
if kwargs and EXTRA_DATA:
|
||||
# set kwargs as model column if available
|
||||
for key in list(kwargs.keys()):
|
||||
if hasattr(newnotify, key):
|
||||
setattr(newnotify, key, kwargs.pop(key))
|
||||
newnotify.data = kwargs
|
||||
|
||||
newnotify.save()
|
||||
new_notifications.append(newnotify)
|
||||
|
||||
return new_notifications
|
||||
|
||||
|
||||
# TODO: Move to notifications/apps.py::ready()
|
||||
notify.connect(notify_handler, dispatch_uid="notifications.models.notification")
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
from django.db import migrations
|
||||
|
||||
from ..base.models import NotificationLevel
|
||||
from notifications.models import NotificationLevel
|
||||
|
||||
|
||||
def copy_level(apps, *args):
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
from django.contrib.humanize.templatetags.humanize import naturalday, naturaltime
|
||||
from swapper import swappable_setting
|
||||
|
||||
from .base.models import AbstractNotification
|
||||
|
||||
|
||||
class Notification(AbstractNotification):
|
||||
class Meta(AbstractNotification.Meta):
|
||||
abstract = False
|
||||
swappable = swappable_setting("notifications", "Notification")
|
||||
|
||||
def naturalday(self):
|
||||
"""
|
||||
Shortcut for the ``humanize``.
|
||||
Take a parameter humanize_type. This parameter control the which humanize method use.
|
||||
Return ``today``, ``yesterday`` ,``now``, ``2 seconds ago``etc.
|
||||
"""
|
||||
|
||||
return naturalday(self.timestamp)
|
||||
|
||||
def naturaltime(self):
|
||||
return naturaltime(self.timestamp)
|
||||
7
notifications/models/__init__.py
Normal file
7
notifications/models/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from .base import NotificationLevel
|
||||
from .notification import Notification
|
||||
|
||||
__all__ = [
|
||||
"Notification",
|
||||
"NotificationLevel",
|
||||
]
|
||||
196
notifications/models/base.py
Normal file
196
notifications/models/base.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# pylint: disable=too-many-lines
|
||||
import datetime
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.humanize.templatetags.humanize import naturalday, naturaltime
|
||||
from django.db import models
|
||||
from django.urls import NoReverseMatch, reverse
|
||||
from django.utils import timesince, timezone
|
||||
from django.utils.html import format_html
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from notifications.querysets import NotificationQuerySet
|
||||
|
||||
|
||||
class NotificationLevel(models.IntegerChoices):
|
||||
SUCCESS = 1
|
||||
INFO = 2
|
||||
WARNING = 3
|
||||
ERROR = 4
|
||||
|
||||
|
||||
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://test.com/">brosner</a> commented on <a href="http://gh.com/pinax/pinax">pinax/pinax</a> 2 hours ago
|
||||
|
||||
"""
|
||||
|
||||
level = models.IntegerField(_("level"), choices=NotificationLevel.choices, default=NotificationLevel.INFO)
|
||||
|
||||
recipient = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="notifications",
|
||||
verbose_name=_("recipient"),
|
||||
blank=False,
|
||||
)
|
||||
unread = models.BooleanField(_("unread"), default=True, blank=False, db_index=True)
|
||||
|
||||
actor_content_type = models.ForeignKey(
|
||||
ContentType, on_delete=models.CASCADE, related_name="notify_actor", verbose_name=_("actor content type")
|
||||
)
|
||||
actor_object_id = models.CharField(_("actor object id"), max_length=255)
|
||||
actor = GenericForeignKey("actor_content_type", "actor_object_id")
|
||||
actor.short_description = _("actor")
|
||||
|
||||
verb = models.CharField(_("verb"), max_length=255)
|
||||
description = models.TextField(_("description"), blank=True, null=True)
|
||||
|
||||
target_content_type = models.ForeignKey(
|
||||
ContentType,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="notify_target",
|
||||
verbose_name=_("target content type"),
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
target_object_id = models.CharField(_("target object id"), max_length=255, blank=True, null=True)
|
||||
target = GenericForeignKey("target_content_type", "target_object_id")
|
||||
target.short_description = _("target")
|
||||
|
||||
action_object_content_type = models.ForeignKey(
|
||||
ContentType,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="notify_action_object",
|
||||
verbose_name=_("action object content type"),
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
action_object_object_id = models.CharField(_("action object object id"), max_length=255, blank=True, null=True)
|
||||
action_object = GenericForeignKey("action_object_content_type", "action_object_object_id")
|
||||
action_object.short_description = _("action object")
|
||||
|
||||
timestamp = models.DateTimeField(_("timestamp"), default=timezone.now, db_index=True)
|
||||
|
||||
public = models.BooleanField(_("public"), default=True, db_index=True)
|
||||
deleted = models.BooleanField(_("deleted"), default=False, db_index=True)
|
||||
emailed = models.BooleanField(_("emailed"), default=False, db_index=True)
|
||||
|
||||
data = models.JSONField(_("data"), blank=True, null=True)
|
||||
|
||||
objects = NotificationQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
ordering = ("-timestamp",)
|
||||
# speed up notifications count query
|
||||
index_together = ("recipient", "unread")
|
||||
verbose_name = _("Notification")
|
||||
verbose_name_plural = _("Notifications")
|
||||
|
||||
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 _("%(actor)s %(verb)s %(action_object)s on %(target)s %(timesince)s ago") % ctx
|
||||
return _("%(actor)s %(verb)s %(target)s %(timesince)s ago") % ctx
|
||||
if self.action_object:
|
||||
return _("%(actor)s %(verb)s %(action_object)s %(timesince)s ago") % ctx
|
||||
return _("%(actor)s %(verb)s %(timesince)s ago") % ctx
|
||||
|
||||
def timesince(self, now: None | datetime.datetime = None) -> str:
|
||||
"""
|
||||
Shortcut for the ``django.utils.timesince.timesince`` function of the
|
||||
current timestamp.
|
||||
"""
|
||||
|
||||
return timesince.timesince(self.timestamp, now)
|
||||
|
||||
@property
|
||||
def slug(self):
|
||||
return self.id
|
||||
|
||||
def mark_as_read(self) -> None:
|
||||
if self.unread:
|
||||
self.unread = False
|
||||
self.save()
|
||||
|
||||
def mark_as_unread(self) -> None:
|
||||
if not self.unread:
|
||||
self.unread = True
|
||||
self.save()
|
||||
|
||||
def actor_object_url(self) -> str:
|
||||
try:
|
||||
url = reverse(
|
||||
f"admin:{self.actor_content_type.app_label}_{self.actor_content_type.model}_change",
|
||||
args=(self.actor_object_id,),
|
||||
)
|
||||
return format_html("<a href='{url}'>{id}</a>", url=url, id=self.actor_object_id)
|
||||
except NoReverseMatch:
|
||||
return self.actor_object_id
|
||||
|
||||
def action_object_url(self) -> str:
|
||||
try:
|
||||
url = reverse(
|
||||
f"admin:{self.action_object_content_type.app_label}_{self.action_object_content_type.model}_change",
|
||||
args=(self.action_object_id,),
|
||||
)
|
||||
return format_html("<a href='{url}'>{id}</a>", url=url, id=self.action_object_object_id)
|
||||
except NoReverseMatch:
|
||||
return self.action_object_object_id
|
||||
|
||||
def target_object_url(self) -> str:
|
||||
try:
|
||||
url = reverse(
|
||||
f"admin:{self.target_content_type.app_label}_{self.target_content_type.model}_change",
|
||||
args=(self.target_object_id,),
|
||||
)
|
||||
return format_html("<a href='{url}'>{id}</a>", url=url, id=self.target_object_id)
|
||||
except NoReverseMatch:
|
||||
return self.target_object_id
|
||||
|
||||
def naturalday(self):
|
||||
"""
|
||||
Shortcut for the ``humanize``.
|
||||
Take a parameter humanize_type. This parameter control the which humanize method use.
|
||||
Return ``today``, ``yesterday`` ,``now``, ``2 seconds ago``etc.
|
||||
"""
|
||||
|
||||
return naturalday(self.timestamp)
|
||||
|
||||
def naturaltime(self):
|
||||
return naturaltime(self.timestamp)
|
||||
9
notifications/models/notification.py
Normal file
9
notifications/models/notification.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
from swapper import swappable_setting
|
||||
|
||||
from notifications.models.base import AbstractNotification
|
||||
|
||||
|
||||
class Notification(AbstractNotification):
|
||||
class Meta(AbstractNotification.Meta):
|
||||
abstract = False
|
||||
swappable = swappable_setting("notifications", "Notification")
|
||||
118
notifications/querysets.py
Normal file
118
notifications/querysets.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
from typing import Optional, Type
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from notifications import settings as notifications_settings
|
||||
from notifications.types import AbstractUser
|
||||
|
||||
|
||||
def is_soft_delete() -> bool:
|
||||
return bool(notifications_settings.get_config()["SOFT_DELETE"])
|
||||
|
||||
|
||||
def assert_soft_delete() -> None:
|
||||
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.QuerySet):
|
||||
"""Notification QuerySet"""
|
||||
|
||||
def unsent(self) -> models.QuerySet["AbstractNotification"]:
|
||||
return self.filter(emailed=False)
|
||||
|
||||
def sent(self) -> models.QuerySet["AbstractNotification"]:
|
||||
return self.filter(emailed=True)
|
||||
|
||||
def unread(self, include_deleted: Optional[bool] = False) -> models.QuerySet["AbstractNotification"]:
|
||||
"""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: Optional[bool] = False) -> models.QuerySet["AbstractNotification"]:
|
||||
"""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 | Type[AbstractUser] = None) -> int:
|
||||
"""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 | Type[AbstractUser] = None) -> int:
|
||||
"""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) -> models.QuerySet["AbstractNotification"]:
|
||||
"""Return only deleted items in the current queryset"""
|
||||
assert_soft_delete()
|
||||
return self.filter(deleted=True)
|
||||
|
||||
def active(self) -> models.QuerySet["AbstractNotification"]:
|
||||
"""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 | Type[AbstractUser] = None) -> int:
|
||||
"""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 | Type[AbstractUser] = None) -> int:
|
||||
"""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 | Type[AbstractUser] = None) -> int:
|
||||
qset = self.sent()
|
||||
if recipient:
|
||||
qset = qset.filter(recipient=recipient)
|
||||
return qset.update(emailed=False)
|
||||
|
||||
def mark_as_sent(self, recipient: None | Type[AbstractUser] = None) -> int:
|
||||
qset = self.unsent()
|
||||
if recipient:
|
||||
qset = qset.filter(recipient=recipient)
|
||||
return qset.update(emailed=True)
|
||||
|
|
@ -11,7 +11,7 @@ CONFIG_DEFAULTS = {
|
|||
}
|
||||
|
||||
|
||||
def get_config():
|
||||
def get_config() -> dict[str, int | bool]:
|
||||
user_config = getattr(settings, "DJANGO_NOTIFICATIONS_CONFIG", {})
|
||||
|
||||
config = CONFIG_DEFAULTS.copy()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,79 @@
|
|||
""" Django notifications signal file """
|
||||
# -*- coding: utf-8 -*-
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from django.dispatch import Signal
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from swapper import load_model
|
||||
|
||||
from notifications import settings as notifications_settings
|
||||
from notifications.models.base import NotificationLevel
|
||||
|
||||
EXTRA_DATA = notifications_settings.get_config()["USE_JSONFIELD"]
|
||||
|
||||
|
||||
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") # pylint: disable=invalid-name
|
||||
level = kwargs.pop("level", NotificationLevel.INFO)
|
||||
actor_for_concrete_model = kwargs.pop("actor_for_concrete_model", True)
|
||||
|
||||
# Check if User or Group
|
||||
if isinstance(recipient, Group):
|
||||
recipients = recipient.user_set.all()
|
||||
elif isinstance(recipient, (models.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, for_concrete_model=actor_for_concrete_model),
|
||||
actor_object_id=actor.pk,
|
||||
verb=str(verb),
|
||||
public=public,
|
||||
description=description,
|
||||
timestamp=timestamp,
|
||||
level=level,
|
||||
)
|
||||
|
||||
# Set optional objects
|
||||
for obj, opt in optional_objs:
|
||||
if obj is not None:
|
||||
for_concrete_model = kwargs.pop(f"{opt}_for_concrete_model", True)
|
||||
setattr(newnotify, f"{opt}_object_id", obj.pk)
|
||||
setattr(
|
||||
newnotify,
|
||||
f"{opt}_content_type",
|
||||
ContentType.objects.get_for_model(obj, for_concrete_model=for_concrete_model),
|
||||
)
|
||||
|
||||
if kwargs and EXTRA_DATA:
|
||||
# set kwargs as model column if available
|
||||
for key in list(kwargs.keys()):
|
||||
if hasattr(newnotify, key):
|
||||
setattr(newnotify, key, kwargs.pop(key))
|
||||
newnotify.data = kwargs
|
||||
|
||||
newnotify.save()
|
||||
new_notifications.append(newnotify)
|
||||
|
||||
return new_notifications
|
||||
|
||||
|
||||
notify = Signal()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import factory
|
||||
|
||||
from ...base.models import NotificationLevel
|
||||
from ..factories import users as user_factory
|
||||
from notifications.models.base import NotificationLevel
|
||||
from notifications.tests.factories import users as user_factory
|
||||
|
||||
|
||||
def test_main_migration0002(migrator):
|
||||
|
|
|
|||
|
|
@ -21,8 +21,7 @@ from django.urls import reverse
|
|||
from django.utils.timezone import localtime
|
||||
from swapper import load_model
|
||||
|
||||
from notifications.base.models import notify_handler
|
||||
from notifications.signals import notify
|
||||
from notifications.signals import notify, notify_handler
|
||||
|
||||
Notification = load_model("notifications", "Notification")
|
||||
User = get_user_model()
|
||||
|
|
|
|||
5
notifications/types.py
Normal file
5
notifications/types.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from typing import NewType
|
||||
|
||||
from django.contrib.auth.base_user import AbstractBaseUser
|
||||
|
||||
AbstractUser = NewType("AbstractUser", AbstractBaseUser)
|
||||
|
|
@ -118,7 +118,7 @@ ignore = [
|
|||
"migrations",
|
||||
]
|
||||
jobs = 0
|
||||
django-settings-module = "notifications.tests.settings_for_tests"
|
||||
django-settings-module = "notifications.settings"
|
||||
|
||||
[tool.pylint.DESIGN]
|
||||
max-locals = 20
|
||||
|
|
|
|||
Loading…
Reference in a new issue