Notification emails #80
@ -6,7 +6,7 @@ class AbuseConfig(AppConfig):
|
||||
name = 'abuse'
|
||||
|
||||
def ready(self):
|
||||
import abuse.signals # noqa: F401
|
||||
from actstream import registry
|
||||
import abuse.signals # noqa: F401
|
||||
|
||||
registry.register(self.get_model('AbuseReport'))
|
||||
|
@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@receiver(post_save, sender=AbuseReport)
|
||||
def create_action_from_report(
|
||||
def _create_action_from_report(
|
||||
sender: object,
|
||||
instance: AbuseReport,
|
||||
created: bool,
|
||||
|
@ -54,6 +54,7 @@ INSTALLED_APPS = [
|
||||
'common',
|
||||
'files',
|
||||
'loginas',
|
||||
'notifications',
|
||||
'pipeline',
|
||||
'ratings',
|
||||
'rangefilter',
|
||||
|
@ -6,7 +6,7 @@ class ExtensionsConfig(AppConfig):
|
||||
name = 'extensions'
|
||||
|
||||
def ready(self):
|
||||
import extensions.signals # noqa: F401
|
||||
from actstream import registry
|
||||
import extensions.signals # noqa: F401
|
||||
|
||||
registry.register(self.get_model('Extension'))
|
||||
|
@ -1,5 +1,7 @@
|
||||
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.dispatch import receiver
|
||||
|
||||
@ -76,3 +78,19 @@ def _set_is_listed(
|
||||
|
||||
extension.is_listed = new_is_listed
|
||||
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."""
|
||||
from actstream import action
|
||||
from actstream.actions import follow
|
||||
from django.contrib.auth.models import Group
|
||||
from django import forms
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
@ -398,13 +396,6 @@ class DraftExtensionView(
|
||||
extension_form.save()
|
||||
add_preview_formset.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:
|
||||
action.send(
|
||||
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'
|
||||
|
||||
def ready(self):
|
||||
import ratings.signals # noqa: F401
|
||||
from actstream import registry
|
||||
import ratings.signals # noqa: F401
|
||||
|
||||
registry.register(self.get_model('Rating'))
|
||||
|
@ -22,7 +22,7 @@ def _update_rating_counters(sender, instance, *args, **kwargs):
|
||||
|
||||
|
||||
@receiver(post_save, sender=Rating)
|
||||
def create_action_from_rating(
|
||||
def _create_action_from_rating(
|
||||
sender: object,
|
||||
instance: Rating,
|
||||
created: bool,
|
||||
|
@ -7,5 +7,6 @@ class ReviewersConfig(AppConfig):
|
||||
|
||||
def ready(self) -> None:
|
||||
from actstream import registry
|
||||
import reviewers.signals # noqa: F401
|
||||
|
||||
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
|
||||
|
||||
from actstream import action
|
||||
from actstream.actions import follow
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.views.generic.list import ListView
|
||||
from django.views.generic import DetailView, FormView
|
||||
from django.shortcuts import reverse
|
||||
|
||||
from constants.activity import Verb
|
||||
from files.models import File
|
||||
from extensions.models import Extension
|
||||
from reviewers.forms import CommentForm
|
||||
@ -79,24 +76,4 @@ class ExtensionsApprovalFormView(LoginRequiredMixin, FormView):
|
||||
form.instance.extension = Extension.objects.get(slug=self.kwargs['slug'])
|
||||
form.save()
|
||||
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)
|
||||
|
@ -6,8 +6,8 @@ class UsersConfig(AppConfig):
|
||||
verbose_name = 'Authentication and authorization'
|
||||
|
||||
def ready(self) -> None:
|
||||
import users.signals # noqa: F401
|
||||
from actstream import registry
|
||||
from django.contrib.auth import get_user_model
|
||||
import users.signals # noqa: F401
|
||||
|
||||
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.utils import timezone
|
||||
|
||||
from users.models import Notification
|
||||
from notifications.models import Notification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO)
|
||||
@ -25,7 +25,7 @@ class Command(BaseCommand):
|
||||
to_send = []
|
||||
for n in batch:
|
||||
n.processed_by_mailer_at = timezone.now()
|
||||
if recipient.is_subscribed(n):
|
||||
# TODO check some form of recipient.is_subscribed(n):
|
||||
n.sent = True
|
||||
to_send.append(n)
|
||||
# first mark, then send: to avoid spamming in case of a crash-loop
|
||||
|
@ -3,14 +3,11 @@ from typing import Optional
|
||||
import logging
|
||||
import time
|
||||
|
||||
from actstream.models import Action
|
||||
from django.contrib.admin.utils import NestedObjects
|
||||
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.templatetags.static import static
|
||||
|
||||
from constants.activity import Verb
|
||||
from common.model_mixins import TrackChangesMixin
|
||||
from files.utils import get_sha256_from_value
|
||||
|
||||
@ -142,46 +139,3 @@ class User(TrackChangesMixin, AbstractUser):
|
||||
def is_moderator(self):
|
||||
# Used to review and approve extensions
|
||||
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
|
||||
|
||||
from actstream.actions import follow, unfollow
|
||||
from actstream.models import Action, followers
|
||||
from django.contrib.auth import get_user_model
|
||||
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 blender_id_oauth_client import signals as bid_signals
|
||||
|
||||
from extensions.models import Extension
|
||||
from users.blender_id import BIDSession
|
||||
from users.models import Notification
|
||||
|
||||
User = get_user_model()
|
||||
bid = BIDSession()
|
||||
@ -42,28 +40,6 @@ def update_user(
|
||||
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)
|
||||
def update_moderator_follows(instance, action, model, reverse, pk_set, **kwargs):
|
||||
"""Users becoming moderators should follow all extensions,
|
||||
|
Loading…
Reference in New Issue
Block a user