Notification emails #80
@ -6,7 +6,7 @@ class AbuseConfig(AppConfig):
|
|||||||
name = 'abuse'
|
name = 'abuse'
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
import abuse.signals # noqa: F401
|
|
||||||
from actstream import registry
|
from actstream import registry
|
||||||
|
import abuse.signals # noqa: F401
|
||||||
|
|
||||||
registry.register(self.get_model('AbuseReport'))
|
registry.register(self.get_model('AbuseReport'))
|
||||||
|
@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=AbuseReport)
|
@receiver(post_save, sender=AbuseReport)
|
||||||
def create_action_from_report(
|
def _create_action_from_report(
|
||||||
sender: object,
|
sender: object,
|
||||||
instance: AbuseReport,
|
instance: AbuseReport,
|
||||||
created: bool,
|
created: bool,
|
||||||
|
@ -54,6 +54,7 @@ INSTALLED_APPS = [
|
|||||||
'common',
|
'common',
|
||||||
'files',
|
'files',
|
||||||
'loginas',
|
'loginas',
|
||||||
|
'notifications',
|
||||||
'pipeline',
|
'pipeline',
|
||||||
'ratings',
|
'ratings',
|
||||||
'rangefilter',
|
'rangefilter',
|
||||||
|
@ -6,7 +6,7 @@ class ExtensionsConfig(AppConfig):
|
|||||||
name = 'extensions'
|
name = 'extensions'
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
import extensions.signals # noqa: F401
|
|
||||||
from actstream import registry
|
from actstream import registry
|
||||||
|
import extensions.signals # noqa: F401
|
||||||
|
|
||||||
registry.register(self.get_model('Extension'))
|
registry.register(self.get_model('Extension'))
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
from typing import Union
|
from typing import Union
|
||||||
|
|
||||||
|
from actstream.actions import follow
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
from django.db.models.signals import pre_save, post_save, post_delete
|
from django.db.models.signals import pre_save, post_save, post_delete
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
|
||||||
@ -76,3 +78,19 @@ def _set_is_listed(
|
|||||||
|
|
||||||
extension.is_listed = new_is_listed
|
extension.is_listed = new_is_listed
|
||||||
extension.save()
|
extension.save()
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=extensions.models.Extension)
|
||||||
|
def _setup_followers(
|
||||||
|
sender: object,
|
||||||
|
instance: extensions.models.Extension,
|
||||||
|
created: bool,
|
||||||
|
**kwargs: object,
|
||||||
|
) -> None:
|
||||||
|
if not created:
|
||||||
|
return
|
||||||
|
|
||||||
|
for user in instance.authors.all():
|
||||||
|
follow(user, instance, send_action=False, flag='author')
|
||||||
|
for user in Group.objects.get(name='moderators').user_set.all():
|
||||||
|
follow(user, instance, send_action=False, flag='moderator')
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
"""Contains views allowing developers to manage their add-ons."""
|
"""Contains views allowing developers to manage their add-ons."""
|
||||||
from actstream import action
|
from actstream import action
|
||||||
from actstream.actions import follow
|
|
||||||
from django.contrib.auth.models import Group
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||||
from django.contrib.messages.views import SuccessMessageMixin
|
from django.contrib.messages.views import SuccessMessageMixin
|
||||||
@ -398,13 +396,6 @@ class DraftExtensionView(
|
|||||||
extension_form.save()
|
extension_form.save()
|
||||||
add_preview_formset.save()
|
add_preview_formset.save()
|
||||||
form.save()
|
form.save()
|
||||||
# setup initial followers
|
|
||||||
authors = extension_form.instance.authors.all()
|
|
||||||
moderators = Group.objects.get(name='moderators').user_set.all()
|
|
||||||
for recipient in authors:
|
|
||||||
follow(recipient, extension_form.instance, send_action=False, flag='author')
|
|
||||||
for recipient in moderators:
|
|
||||||
follow(recipient, extension_form.instance, send_action=False, flag='moderator')
|
|
||||||
if 'submit_draft' in self.request.POST:
|
if 'submit_draft' in self.request.POST:
|
||||||
action.send(
|
action.send(
|
||||||
self.request.user,
|
self.request.user,
|
||||||
|
0
notifications/__init.py__
Normal file
0
notifications/__init.py__
Normal file
9
notifications/apps.py
Normal file
9
notifications/apps.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'notifications'
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
import notifications.signals # noqa: F401
|
46
notifications/models.py
Normal file
46
notifications/models.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
from actstream.models import Action
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from constants.activity import Verb
|
||||||
|
from common.templatetags.common import absolutify
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(models.Model):
|
||||||
|
"""Notification records are created in Action's post_save signal.
|
||||||
|
When a user marks a notification as read, a record is deleted.
|
||||||
|
While a notification exists it can be picked up by a send_notifications management command
|
||||||
|
that runs periodically in background.
|
||||||
|
If a recipient opted out from receiving notifications of particular type (based on action.verb),
|
||||||
|
we shouldn't include it in the email, leaving sent=False.
|
||||||
|
"""
|
||||||
|
|
||||||
|
recipient = models.ForeignKey(User, null=False, on_delete=models.CASCADE)
|
||||||
|
action = models.ForeignKey(Action, null=False, on_delete=models.CASCADE)
|
||||||
|
processed_by_mailer_at = models.DateTimeField(default=None, null=True)
|
||||||
|
sent = models.BooleanField(default=False, null=False)
|
||||||
|
|
||||||
|
def format_email_txt(self):
|
||||||
|
url = self.get_absolute_url()
|
||||||
|
# TODO construct a proper phrase, depending on the verb, maybe use a template
|
||||||
|
return f'{self.action.actor.full_name} {self.action.verb} {self.action.target}: {url}'
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
if self.action.verb == Verb.RATED_EXTENSION:
|
||||||
|
url = self.action.target.get_ratings_url()
|
||||||
|
elif self.action.verb in [
|
||||||
|
Verb.APPROVED,
|
||||||
|
Verb.COMMENTED,
|
||||||
|
Verb.REQUESTED_CHANGES,
|
||||||
|
Verb.REQUESTED_REVIEW,
|
||||||
|
]:
|
||||||
|
url = self.action.target.get_review_url()
|
||||||
|
elif self.action.action_object is not None:
|
||||||
|
url = self.action.action_object.get_absolute_url()
|
||||||
|
else:
|
||||||
|
url = self.action.target.get_absolute_url()
|
||||||
|
|
||||||
|
# TODO? url cloacking to auto-delete visited notifications
|
||||||
|
return absolutify(url)
|
35
notifications/signals.py
Normal file
35
notifications/signals.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from actstream.models import Action, followers
|
||||||
|
from django.db.models.signals import post_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
from notifications.models import Notification
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=Action)
|
||||||
|
def _create_notifications(
|
||||||
|
sender: object,
|
||||||
|
instance: Action,
|
||||||
|
created: bool,
|
||||||
|
raw: bool,
|
||||||
|
**kwargs: object,
|
||||||
|
) -> None:
|
||||||
|
if raw:
|
||||||
|
return
|
||||||
|
if not created:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not instance.target:
|
||||||
|
logger.warning('ignoring an unexpected Action without a target, verb={instance.verb}')
|
||||||
|
return
|
||||||
|
|
||||||
|
notifications = []
|
||||||
|
for recipient in followers(instance.target):
|
||||||
|
if recipient == instance.actor:
|
||||||
|
continue
|
||||||
|
notifications.append(Notification(recipient=recipient, action=instance))
|
||||||
|
if len(notifications) > 0:
|
||||||
|
Notification.objects.bulk_create(notifications)
|
@ -6,7 +6,7 @@ class RatingsConfig(AppConfig):
|
|||||||
name = 'ratings'
|
name = 'ratings'
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
import ratings.signals # noqa: F401
|
|
||||||
from actstream import registry
|
from actstream import registry
|
||||||
|
import ratings.signals # noqa: F401
|
||||||
|
|
||||||
registry.register(self.get_model('Rating'))
|
registry.register(self.get_model('Rating'))
|
||||||
|
@ -22,7 +22,7 @@ def _update_rating_counters(sender, instance, *args, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Rating)
|
@receiver(post_save, sender=Rating)
|
||||||
def create_action_from_rating(
|
def _create_action_from_rating(
|
||||||
sender: object,
|
sender: object,
|
||||||
instance: Rating,
|
instance: Rating,
|
||||||
created: bool,
|
created: bool,
|
||||||
|
@ -7,5 +7,6 @@ class ReviewersConfig(AppConfig):
|
|||||||
|
|
||||||
def ready(self) -> None:
|
def ready(self) -> None:
|
||||||
from actstream import registry
|
from actstream import registry
|
||||||
|
import reviewers.signals # noqa: F401
|
||||||
|
|
||||||
registry.register(self.get_model('ApprovalActivity'))
|
registry.register(self.get_model('ApprovalActivity'))
|
||||||
|
39
reviewers/signals.py
Normal file
39
reviewers/signals.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
from actstream import action
|
||||||
|
from actstream.actions import follow
|
||||||
|
from django.db.models.signals import post_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
from constants.activity import Verb
|
||||||
|
from reviewers.models import ApprovalActivity
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=ApprovalActivity)
|
||||||
|
def _create_action_from_review_and_follow(
|
||||||
|
sender: object,
|
||||||
|
instance: ApprovalActivity,
|
||||||
|
created: bool,
|
||||||
|
raw: bool,
|
||||||
|
**kwargs: object,
|
||||||
|
) -> None:
|
||||||
|
if raw:
|
||||||
|
return
|
||||||
|
if not created:
|
||||||
|
return
|
||||||
|
|
||||||
|
# automatically follow after an interaction
|
||||||
|
# if a user had unfollowed this extension before,
|
||||||
|
# we are making them a follower again
|
||||||
|
follow(instance.user, instance.extension, send_action=False)
|
||||||
|
|
||||||
|
activity_type2verb = {
|
||||||
|
ApprovalActivity.ActivityType.APPROVED: Verb.APPROVED,
|
||||||
|
ApprovalActivity.ActivityType.AWAITING_CHANGES: Verb.REQUESTED_CHANGES,
|
||||||
|
ApprovalActivity.ActivityType.AWAITING_REVIEW: Verb.REQUESTED_REVIEW,
|
||||||
|
ApprovalActivity.ActivityType.COMMENT: Verb.COMMENTED,
|
||||||
|
}
|
||||||
|
action.send(
|
||||||
|
instance.user,
|
||||||
|
verb=activity_type2verb.get(instance.type),
|
||||||
|
action_object=instance,
|
||||||
|
target=instance.extension,
|
||||||
|
)
|
@ -1,13 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from actstream import action
|
|
||||||
from actstream.actions import follow
|
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.views.generic.list import ListView
|
from django.views.generic.list import ListView
|
||||||
from django.views.generic import DetailView, FormView
|
from django.views.generic import DetailView, FormView
|
||||||
from django.shortcuts import reverse
|
from django.shortcuts import reverse
|
||||||
|
|
||||||
from constants.activity import Verb
|
|
||||||
from files.models import File
|
from files.models import File
|
||||||
from extensions.models import Extension
|
from extensions.models import Extension
|
||||||
from reviewers.forms import CommentForm
|
from reviewers.forms import CommentForm
|
||||||
@ -79,24 +76,4 @@ class ExtensionsApprovalFormView(LoginRequiredMixin, FormView):
|
|||||||
form.instance.extension = Extension.objects.get(slug=self.kwargs['slug'])
|
form.instance.extension = Extension.objects.get(slug=self.kwargs['slug'])
|
||||||
form.save()
|
form.save()
|
||||||
self.approve_if_allowed(form)
|
self.approve_if_allowed(form)
|
||||||
|
|
||||||
# automatically follow after an interaction
|
|
||||||
# if a user had unfollowed this extension before,
|
|
||||||
# we unfortunately are making them a follower again
|
|
||||||
# TODO? set a specific flag?
|
|
||||||
follow(self.request.user, form.instance.extension, send_action=False)
|
|
||||||
|
|
||||||
activity_type2verb = {
|
|
||||||
ApprovalActivity.ActivityType.APPROVED: Verb.APPROVED,
|
|
||||||
ApprovalActivity.ActivityType.AWAITING_CHANGES: Verb.REQUESTED_CHANGES,
|
|
||||||
ApprovalActivity.ActivityType.AWAITING_REVIEW: Verb.REQUESTED_REVIEW,
|
|
||||||
ApprovalActivity.ActivityType.COMMENT: Verb.COMMENTED,
|
|
||||||
}
|
|
||||||
action.send(
|
|
||||||
self.request.user,
|
|
||||||
verb=activity_type2verb.get(form.cleaned_data['type']),
|
|
||||||
action_object=form.instance,
|
|
||||||
target=form.instance.extension,
|
|
||||||
)
|
|
||||||
|
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
|
@ -6,8 +6,8 @@ class UsersConfig(AppConfig):
|
|||||||
verbose_name = 'Authentication and authorization'
|
verbose_name = 'Authentication and authorization'
|
||||||
|
|
||||||
def ready(self) -> None:
|
def ready(self) -> None:
|
||||||
import users.signals # noqa: F401
|
|
||||||
from actstream import registry
|
from actstream import registry
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
import users.signals # noqa: F401
|
||||||
|
|
||||||
registry.register(get_user_model())
|
registry.register(get_user_model())
|
||||||
|
@ -7,7 +7,7 @@ from django.core.mail import send_mail
|
|||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from users.models import Notification
|
from notifications.models import Notification
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.setLevel(logging.INFO)
|
logger.setLevel(logging.INFO)
|
||||||
@ -25,9 +25,9 @@ class Command(BaseCommand):
|
|||||||
to_send = []
|
to_send = []
|
||||||
for n in batch:
|
for n in batch:
|
||||||
n.processed_by_mailer_at = timezone.now()
|
n.processed_by_mailer_at = timezone.now()
|
||||||
if recipient.is_subscribed(n):
|
# TODO check some form of recipient.is_subscribed(n):
|
||||||
n.sent = True
|
n.sent = True
|
||||||
to_send.append(n)
|
to_send.append(n)
|
||||||
# first mark, then send: to avoid spamming in case of a crash-loop
|
# first mark, then send: to avoid spamming in case of a crash-loop
|
||||||
Notification.objects.bulk_update(batch, ['processed_by_mailer_at', 'sent'])
|
Notification.objects.bulk_update(batch, ['processed_by_mailer_at', 'sent'])
|
||||||
if len(to_send) > 0:
|
if len(to_send) > 0:
|
||||||
|
@ -3,14 +3,11 @@ from typing import Optional
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from actstream.models import Action
|
|
||||||
from django.contrib.admin.utils import NestedObjects
|
from django.contrib.admin.utils import NestedObjects
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
from django.contrib.sites.models import Site
|
|
||||||
from django.db import models, DEFAULT_DB_ALIAS, transaction
|
from django.db import models, DEFAULT_DB_ALIAS, transaction
|
||||||
from django.templatetags.static import static
|
from django.templatetags.static import static
|
||||||
|
|
||||||
from constants.activity import Verb
|
|
||||||
from common.model_mixins import TrackChangesMixin
|
from common.model_mixins import TrackChangesMixin
|
||||||
from files.utils import get_sha256_from_value
|
from files.utils import get_sha256_from_value
|
||||||
|
|
||||||
@ -142,46 +139,3 @@ class User(TrackChangesMixin, AbstractUser):
|
|||||||
def is_moderator(self):
|
def is_moderator(self):
|
||||||
# Used to review and approve extensions
|
# Used to review and approve extensions
|
||||||
return self.groups.filter(name='moderators').exists()
|
return self.groups.filter(name='moderators').exists()
|
||||||
|
|
||||||
def is_subscribed(self, notification: 'Notification') -> bool:
|
|
||||||
# TODO user profile settings to subscribe to groups of action verbs
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class Notification(models.Model):
|
|
||||||
"""Notification records are created in Action's post_save signal.
|
|
||||||
When a user marks a notification as read, a record is deleted.
|
|
||||||
While a notification exists it can be picked up by a send_notifications management command
|
|
||||||
that runs periodically in background.
|
|
||||||
If a recipient opted out from receiving notifications of particular type (based on action.verb),
|
|
||||||
we shouldn't include it in the email, leaving sent=False.
|
|
||||||
"""
|
|
||||||
|
|
||||||
recipient = models.ForeignKey(User, null=False, on_delete=models.CASCADE)
|
|
||||||
action = models.ForeignKey(Action, null=False, on_delete=models.CASCADE)
|
|
||||||
processed_by_mailer_at = models.DateTimeField(default=None, null=True)
|
|
||||||
sent = models.BooleanField(default=False, null=False)
|
|
||||||
|
|
||||||
def format_email_txt(self):
|
|
||||||
url = self.get_absolute_url()
|
|
||||||
# TODO construct a proper phrase, depending on the verb, maybe use a template
|
|
||||||
return f'{self.action.actor.full_name} {self.action.verb} {self.action.target}: {url}'
|
|
||||||
|
|
||||||
def get_absolute_url(self):
|
|
||||||
if self.action.verb == Verb.RATED_EXTENSION:
|
|
||||||
url = self.action.target.get_ratings_url()
|
|
||||||
elif self.action.verb in [
|
|
||||||
Verb.APPROVED,
|
|
||||||
Verb.COMMENTED,
|
|
||||||
Verb.REQUESTED_CHANGES,
|
|
||||||
Verb.REQUESTED_REVIEW,
|
|
||||||
]:
|
|
||||||
url = self.action.target.get_review_url()
|
|
||||||
elif self.action.action_object is not None:
|
|
||||||
url = self.action.action_object.get_absolute_url()
|
|
||||||
else:
|
|
||||||
url = self.action.target.get_absolute_url()
|
|
||||||
|
|
||||||
# TODO? url cloacking to auto-delete visited notifications
|
|
||||||
domain = Site.objects.get_current().domain
|
|
||||||
return f'https://{domain}{url}'
|
|
||||||
|
@ -2,17 +2,15 @@ from typing import Dict
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from actstream.actions import follow, unfollow
|
from actstream.actions import follow, unfollow
|
||||||
from actstream.models import Action, followers
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.db.models.signals import m2m_changed, post_save, pre_save
|
from django.db.models.signals import m2m_changed, pre_save
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
|
||||||
from blender_id_oauth_client import signals as bid_signals
|
from blender_id_oauth_client import signals as bid_signals
|
||||||
|
|
||||||
from extensions.models import Extension
|
from extensions.models import Extension
|
||||||
from users.blender_id import BIDSession
|
from users.blender_id import BIDSession
|
||||||
from users.models import Notification
|
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
bid = BIDSession()
|
bid = BIDSession()
|
||||||
@ -42,28 +40,6 @@ def update_user(
|
|||||||
bid.copy_badges_from_blender_id(user=instance)
|
bid.copy_badges_from_blender_id(user=instance)
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Action)
|
|
||||||
def create_notification(
|
|
||||||
sender: object,
|
|
||||||
instance: Action,
|
|
||||||
created: bool,
|
|
||||||
raw: bool,
|
|
||||||
**kwargs: object,
|
|
||||||
) -> None:
|
|
||||||
if raw:
|
|
||||||
return
|
|
||||||
if not created:
|
|
||||||
return
|
|
||||||
|
|
||||||
if not instance.target:
|
|
||||||
logger.warning('ignoring an unexpected Action without a target, verb={instance.verb}')
|
|
||||||
return
|
|
||||||
|
|
||||||
audience = filter(lambda f: f != instance.actor, followers(instance.target))
|
|
||||||
for recipient in audience:
|
|
||||||
Notification.objects.get_or_create(recipient=recipient, action=instance)
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(m2m_changed, sender=User.groups.through)
|
@receiver(m2m_changed, sender=User.groups.through)
|
||||||
def update_moderator_follows(instance, action, model, reverse, pk_set, **kwargs):
|
def update_moderator_follows(instance, action, model, reverse, pk_set, **kwargs):
|
||||||
"""Users becoming moderators should follow all extensions,
|
"""Users becoming moderators should follow all extensions,
|
||||||
|
Loading…
Reference in New Issue
Block a user