Notification emails #80
@ -4,3 +4,9 @@ from django.apps import AppConfig
|
||||
class AbuseConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'abuse'
|
||||
|
||||
def ready(self):
|
||||
from actstream import registry
|
||||
import abuse.signals # noqa: F401
|
||||
|
||||
registry.register(self.get_model('AbuseReport'))
|
||||
|
@ -34,6 +34,7 @@ class AbuseReport(CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin, mode
|
||||
)
|
||||
|
||||
# NULL if the reporter is anonymous.
|
||||
# FIXME? make non-null
|
||||
reporter = models.ForeignKey(
|
||||
User,
|
||||
null=True,
|
||||
|
47
abuse/signals.py
Normal file
47
abuse/signals.py
Normal file
@ -0,0 +1,47 @@
|
||||
import logging
|
||||
|
||||
from actstream import action
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from abuse.models import AbuseReport
|
||||
from constants.activity import Verb
|
||||
from constants.base import (
|
||||
ABUSE_TYPE_EXTENSION,
|
||||
ABUSE_TYPE_RATING,
|
||||
ABUSE_TYPE_USER,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@receiver(post_save, sender=AbuseReport)
|
||||
def _create_action_from_report(
|
||||
sender: object,
|
||||
instance: AbuseReport,
|
||||
created: bool,
|
||||
raw: bool,
|
||||
**kwargs: object,
|
||||
) -> None:
|
||||
if not created:
|
||||
return
|
||||
if raw:
|
||||
return
|
||||
|
||||
if instance.type == ABUSE_TYPE_EXTENSION:
|
||||
verb = Verb.REPORTED_EXTENSION
|
||||
elif instance.type == ABUSE_TYPE_RATING:
|
||||
verb = Verb.REPORTED_RATING
|
||||
elif instance.type == ABUSE_TYPE_USER:
|
||||
# TODO?
|
||||
return
|
||||
else:
|
||||
logger.warning(f'ignoring an unexpected AbuseReport type={instance.type}')
|
||||
return
|
||||
|
||||
action.send(
|
||||
instance.reporter,
|
||||
verb=verb,
|
||||
target=instance.extension,
|
||||
action_object=instance,
|
||||
)
|
@ -54,6 +54,7 @@ INSTALLED_APPS = [
|
||||
'common',
|
||||
'files',
|
||||
'loginas',
|
||||
'notifications',
|
||||
'pipeline',
|
||||
'ratings',
|
||||
'rangefilter',
|
||||
@ -73,6 +74,7 @@ INSTALLED_APPS = [
|
||||
'django.contrib.staticfiles',
|
||||
'django.contrib.flatpages',
|
||||
'django.contrib.humanize',
|
||||
'actstream',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
@ -319,3 +321,7 @@ EMAIL_HOST = os.getenv('EMAIL_HOST')
|
||||
EMAIL_PORT = os.getenv('EMAIL_PORT', '587')
|
||||
EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER')
|
||||
EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD')
|
||||
|
||||
ACTSTREAM_SETTINGS = {
|
||||
'MANAGER': 'actstream.managers.ActionManager',
|
||||
}
|
||||
|
@ -39,6 +39,7 @@ urlpatterns = [
|
||||
path('', include('users.urls')),
|
||||
path('', include('teams.urls')),
|
||||
path('', include('reviewers.urls')),
|
||||
path('', include('notifications.urls')),
|
||||
path('api/swagger/', RedirectView.as_view(url='/api/v1/swagger/')),
|
||||
path('api/v1/', SpectacularAPIView.as_view(), name='schema_v1'),
|
||||
path('api/v1/swagger/', SpectacularSwaggerView.as_view(url_name='schema_v1'), name='swagger'),
|
||||
|
@ -1,6 +1,7 @@
|
||||
import random
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
from factory.django import DjangoModelFactory
|
||||
import factory
|
||||
|
||||
@ -42,3 +43,10 @@ class UserFactory(DjangoModelFactory):
|
||||
|
||||
oauth_tokens = factory.RelatedFactoryList(OAuthUserTokenFactory, factory_related_name='user')
|
||||
oauth_info = factory.RelatedFactory(OAuthUserInfoFactory, factory_related_name='user')
|
||||
|
||||
|
||||
def create_moderator():
|
||||
user = UserFactory()
|
||||
moderators = Group.objects.get(name='moderators')
|
||||
user.groups.add(moderators)
|
||||
return user
|
||||
|
18
constants/activity.py
Normal file
18
constants/activity.py
Normal file
@ -0,0 +1,18 @@
|
||||
class Verb:
|
||||
"""These constants are used to dispatch Action records,
|
||||
changing the values will result in a mismatch with historical values stored in db.
|
||||
"""
|
||||
|
||||
APPROVED = 'approved'
|
||||
COMMENTED = 'commented'
|
||||
RATED_EXTENSION = 'rated extension'
|
||||
REPORTED_EXTENSION = 'reported extension'
|
||||
REPORTED_RATING = 'reported rating'
|
||||
REQUESTED_CHANGES = 'requested changes'
|
||||
REQUESTED_REVIEW = 'requested review'
|
||||
|
||||
|
||||
class Flag:
|
||||
AUTHOR = 'author'
|
||||
MODERATOR = 'moderator'
|
||||
REVIEWER = 'reviewer'
|
@ -6,4 +6,7 @@ class ExtensionsConfig(AppConfig):
|
||||
name = 'extensions'
|
||||
|
||||
def ready(self):
|
||||
from actstream import registry
|
||||
import extensions.signals # noqa: F401
|
||||
|
||||
registry.register(self.get_model('Extension'))
|
||||
|
@ -1,14 +1,16 @@
|
||||
from typing import Union
|
||||
|
||||
from django.db.models.signals import pre_save, post_save, post_delete
|
||||
from actstream.actions import follow, unfollow
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
from django.db.models.signals import m2m_changed, pre_save, post_save, post_delete
|
||||
from django.dispatch import receiver
|
||||
import django.dispatch
|
||||
|
||||
from constants.activity import Flag
|
||||
import extensions.models
|
||||
import files.models
|
||||
|
||||
version_changed = django.dispatch.Signal()
|
||||
version_uploaded = django.dispatch.Signal()
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@receiver(post_delete, sender=extensions.models.Preview)
|
||||
@ -38,10 +40,6 @@ def _update_search_index(sender, instance, **kw):
|
||||
pass # TODO: update search index
|
||||
|
||||
|
||||
def send_notifications(sender=None, instance=None, signal=None, **kw):
|
||||
pass # TODO: send email notification about new version upload
|
||||
|
||||
|
||||
def extension_should_be_listed(extension):
|
||||
return (
|
||||
extension.latest_version is not None
|
||||
@ -85,4 +83,37 @@ def _set_is_listed(
|
||||
extension.save()
|
||||
|
||||
|
||||
version_uploaded.connect(send_notifications, dispatch_uid='send_notifications')
|
||||
@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=Flag.AUTHOR)
|
||||
for user in Group.objects.get(name='moderators').user_set.all():
|
||||
follow(user, instance, send_action=False, flag=Flag.MODERATOR)
|
||||
|
||||
|
||||
@receiver(m2m_changed, sender=extensions.models.Extension.authors.through)
|
||||
def _update_authors_follow(instance, action, model, reverse, pk_set, **kwargs):
|
||||
if action not in ['post_add', 'post_remove']:
|
||||
return
|
||||
|
||||
if model == extensions.models.Extension and not reverse:
|
||||
targets = extensions.models.Extension.objects.filter(pk__in=pk_set)
|
||||
users = [instance]
|
||||
else:
|
||||
targets = [instance]
|
||||
users = User.objects.filter(pk__in=pk_set)
|
||||
|
||||
for user in users:
|
||||
for extension in targets:
|
||||
if action == 'post_remove':
|
||||
unfollow(user, extension, send_action=False, flag=Flag.AUTHOR)
|
||||
elif action == 'post_add':
|
||||
follow(user, extension, send_action=False, flag=Flag.AUTHOR)
|
||||
|
@ -391,6 +391,7 @@ class DraftExtensionView(
|
||||
# Send the extension and version to the review
|
||||
if 'submit_draft' in self.request.POST:
|
||||
extension_form.instance.status = extension_form.instance.STATUSES.AWAITING_REVIEW
|
||||
# FIXME create ApprovalActivity
|
||||
extension_form.save()
|
||||
add_preview_formset.save()
|
||||
form.save()
|
||||
|
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
|
0
notifications/management/__init__.py
Normal file
0
notifications/management/__init__.py
Normal file
0
notifications/management/commands/__init__.py
Normal file
0
notifications/management/commands/__init__.py
Normal file
35
notifications/management/commands/ensure_followers.py
Normal file
35
notifications/management/commands/ensure_followers.py
Normal file
@ -0,0 +1,35 @@
|
||||
"""Create all necessary follow records."""
|
||||
import logging
|
||||
|
||||
from actstream.actions import follow
|
||||
from django.contrib.auth.models import Group
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from constants.activity import Flag
|
||||
from extensions.models import Extension
|
||||
from reviewers.models import ApprovalActivity
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
def handle(self, *args, **options): # noqa: D102
|
||||
# TODO? keep a record of explicit unfollow requests to avoid re-following
|
||||
extensions = Extension.objects.all()
|
||||
moderators = Group.objects.get(name='moderators').user_set.all()
|
||||
for extension in extensions:
|
||||
authors = extension.authors.all()
|
||||
for recipient in authors:
|
||||
_follow_with_log(recipient, extension, Flag.AUTHOR)
|
||||
for recipient in moderators:
|
||||
_follow_with_log(recipient, extension, Flag.MODERATOR)
|
||||
|
||||
approval_activity_items = ApprovalActivity.objects.all().select_related('extension', 'user')
|
||||
for item in approval_activity_items:
|
||||
_follow_with_log(item.user, item.extension, Flag.MODERATOR)
|
||||
|
||||
|
||||
def _follow_with_log(user, target, flag):
|
||||
follow(user, target, send_action=False, flag=flag)
|
||||
logger.info(f'{user} follows {target} with flag={flag}')
|
@ -0,0 +1,52 @@
|
||||
"""Send user notifications as emails, at most once delivery."""
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import send_mail
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from notifications.models import Notification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
def handle(self, *args, **options): # noqa: D102
|
||||
unprocessed_notifications = Notification.objects.filter(processed_by_mailer_at=None)
|
||||
for n in unprocessed_notifications:
|
||||
logger.info(f'processing Notification pk={n.pk}')
|
||||
n.processed_by_mailer_at = timezone.now()
|
||||
recipient = n.recipient
|
||||
if not recipient.is_subscribed_to_notification_emails:
|
||||
logger.info(f'{recipient} is not subscribed, skipping')
|
||||
n.save()
|
||||
continue
|
||||
# check that email is confirmed to avoid spamming unsuspecting email owners
|
||||
if recipient.confirmed_email_at is None:
|
||||
logger.info(f'{recipient} has unconfirmed email, skipping')
|
||||
n.save()
|
||||
continue
|
||||
# FIXME test with only internal emails first
|
||||
if not recipient.email.endswith('@blender.org'):
|
||||
logger.info('skipping: not an internal email')
|
||||
n.save()
|
||||
continue
|
||||
n.email_sent = True
|
||||
# first mark as processed, then send: avoid spamming in case of a crash-loop
|
||||
n.save()
|
||||
logger.info(f'sending an email to {recipient}: {n.action}')
|
||||
send_notification_email(n)
|
||||
|
||||
|
||||
def send_notification_email(notification):
|
||||
# TODO construct a proper phrase, depending on the verb,
|
||||
# possibly share a template with NotificationsView
|
||||
subject, message = notification.format_email()
|
||||
send_mail(
|
||||
subject,
|
||||
message,
|
||||
settings.DEFAULT_FROM_EMAIL,
|
||||
[notification.recipient.email],
|
||||
)
|
33
notifications/migrations/0001_initial.py
Normal file
33
notifications/migrations/0001_initial.py
Normal file
@ -0,0 +1,33 @@
|
||||
# Generated by Django 4.2.11 on 2024-04-16 15:56
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('actstream', '0003_add_follow_flag'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Notification',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
('action', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='actstream.action')),
|
||||
('email_sent', models.BooleanField(default=False)),
|
||||
('processed_by_mailer_at', models.DateTimeField(default=None, null=True)),
|
||||
('read_at', models.DateTimeField(default=None, null=True)),
|
||||
],
|
||||
options={
|
||||
'indexes': [models.Index(fields=['processed_by_mailer_at'], name='notificatio_process_fc95bc_idx'), models.Index(fields=['recipient', 'read_at'], name='notificatio_recipie_564b1f_idx')],
|
||||
'unique_together': {('recipient', 'action')},
|
||||
},
|
||||
),
|
||||
]
|
0
notifications/migrations/__init__.py
Normal file
0
notifications/migrations/__init__.py
Normal file
56
notifications/models.py
Normal file
56
notifications/models.py
Normal file
@ -0,0 +1,56 @@
|
||||
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, read_at is set.
|
||||
send_notification_emails management command runs periodically in background and sends all
|
||||
notifications that haven't been processed yet, read_at is not checked when sending emails.
|
||||
email_sent flag is used only to record the fact that we attempted to send an email.
|
||||
A user can unsubscribe from notification emails in their profile settings.
|
||||
"""
|
||||
|
||||
recipient = models.ForeignKey(User, null=False, on_delete=models.CASCADE)
|
||||
action = models.ForeignKey(Action, null=False, on_delete=models.CASCADE)
|
||||
email_sent = models.BooleanField(default=False, null=False)
|
||||
processed_by_mailer_at = models.DateTimeField(default=None, null=True)
|
||||
read_at = models.DateTimeField(default=None, null=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=['processed_by_mailer_at']),
|
||||
models.Index(fields=['recipient', 'read_at']),
|
||||
]
|
||||
unique_together = ['recipient', 'action']
|
||||
|
||||
def format_email(self):
|
||||
action = self.action
|
||||
subject = f'New Activity: {action.actor.full_name} {action.verb} {action.target}'
|
||||
url = self.get_absolute_url()
|
||||
mesage = f'{action.actor.full_name} {action.verb} {action.target}: {url}'
|
||||
return (subject, mesage)
|
||||
|
||||
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 mark visited notifications as read automatically
|
||||
return absolutify(url)
|
57
notifications/signals.py
Normal file
57
notifications/signals.py
Normal file
@ -0,0 +1,57 @@
|
||||
import logging
|
||||
|
||||
from actstream.models import Action, Follow
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from constants.activity import Flag, Verb
|
||||
from notifications.models import Notification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VERB2FLAGS = {
|
||||
Verb.APPROVED: [Flag.AUTHOR, Flag.MODERATOR, Flag.REVIEWER],
|
||||
Verb.COMMENTED: [Flag.AUTHOR, Flag.MODERATOR, Flag.REVIEWER],
|
||||
Verb.RATED_EXTENSION: [Flag.AUTHOR],
|
||||
Verb.REPORTED_EXTENSION: [Flag.MODERATOR],
|
||||
Verb.REPORTED_RATING: [Flag.MODERATOR],
|
||||
Verb.REQUESTED_CHANGES: [Flag.AUTHOR, Flag.MODERATOR, Flag.REVIEWER],
|
||||
Verb.REQUESTED_REVIEW: [Flag.MODERATOR, Flag.REVIEWER],
|
||||
}
|
||||
|
||||
|
||||
@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(f'ignoring an unexpected Action without a target, verb={instance.verb}')
|
||||
return
|
||||
|
||||
notifications = []
|
||||
|
||||
flags = VERB2FLAGS.get(instance.verb, None)
|
||||
if not flags:
|
||||
logger.warning(f'no follower flags for verb={instance.verb}, nobody will be notified')
|
||||
return
|
||||
|
||||
followers = Follow.objects.for_object(instance.target).filter(flag__in=flags)
|
||||
user_ids = followers.values_list('user', flat=True)
|
||||
followers = get_user_model().objects.filter(id__in=user_ids)
|
||||
|
||||
for recipient in followers:
|
||||
if recipient == instance.actor:
|
||||
continue
|
||||
notifications.append(Notification(recipient=recipient, action=instance))
|
||||
if len(notifications) > 0:
|
||||
Notification.objects.bulk_create(notifications)
|
22
notifications/templates/notifications/notification_list.html
Normal file
22
notifications/templates/notifications/notification_list.html
Normal file
@ -0,0 +1,22 @@
|
||||
{% extends "common/base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block page_title %}{% blocktranslate %}Notifications{% endblocktranslate %}{% endblock page_title %}
|
||||
|
||||
{% block content %}
|
||||
{% if notification_list %}
|
||||
{% for notification in notification_list %}
|
||||
<div class="row">
|
||||
{{ notification.action }}
|
||||
{% if notification.read_at %}
|
||||
{% else %}
|
||||
{% blocktranslate %}Mark as read{% endblocktranslate %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p>
|
||||
{% blocktranslate %}You have no notifications{% endblocktranslate %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endblock content %}
|
142
notifications/tests/test_follow_logic.py
Normal file
142
notifications/tests/test_follow_logic.py
Normal file
@ -0,0 +1,142 @@
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from common.tests.factories.extensions import create_approved_version, create_version
|
||||
from common.tests.factories.files import FileFactory
|
||||
from common.tests.factories.users import UserFactory, create_moderator
|
||||
from files.models import File
|
||||
from notifications.models import Notification
|
||||
from reviewers.models import ApprovalActivity
|
||||
|
||||
TEST_FILES_DIR = Path(__file__).resolve().parent / '../../extensions/tests/files'
|
||||
|
||||
|
||||
class TestTasks(TestCase):
|
||||
fixtures = ['dev', 'licenses']
|
||||
|
||||
def test_ratings(self):
|
||||
extension = create_approved_version(ratings=[]).extension
|
||||
author = extension.authors.first()
|
||||
notification_nr = Notification.objects.filter(recipient=author).count()
|
||||
some_user = UserFactory()
|
||||
self.client.force_login(some_user)
|
||||
url = extension.get_rate_url()
|
||||
response = self.client.post(url, {'score': 3, 'text': 'rating text'})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(extension.ratings.count(), 1)
|
||||
new_notification_nr = Notification.objects.filter(recipient=author).count()
|
||||
self.assertEqual(new_notification_nr, notification_nr + 1)
|
||||
|
||||
def test_abuse(self):
|
||||
extension = create_approved_version(ratings=[]).extension
|
||||
moderator = create_moderator()
|
||||
notification_nr = Notification.objects.filter(recipient=moderator).count()
|
||||
some_user = UserFactory()
|
||||
self.client.force_login(some_user)
|
||||
url = extension.get_report_url()
|
||||
self.client.post(
|
||||
url,
|
||||
{
|
||||
'message': 'test message',
|
||||
'reason': '127',
|
||||
'version': '',
|
||||
},
|
||||
)
|
||||
new_notification_nr = Notification.objects.filter(recipient=moderator).count()
|
||||
self.assertEqual(new_notification_nr, notification_nr + 1)
|
||||
|
||||
@unittest.skip('FIXME in DraftExtensionView')
|
||||
def test_new_extension_submitted(self):
|
||||
moderator = create_moderator()
|
||||
notification_nr = Notification.objects.filter(recipient=moderator).count()
|
||||
some_user = UserFactory()
|
||||
file_data = {
|
||||
'metadata': {
|
||||
'tagline': 'Get insight on the complexity of an edit',
|
||||
'id': 'edit_breakdown',
|
||||
'name': 'Edit Breakdown',
|
||||
'version': '0.1.0',
|
||||
'blender_version_min': '4.2.0',
|
||||
'type': 'add-on',
|
||||
'schema_version': "1.0.0",
|
||||
},
|
||||
'file_hash': 'sha256:4f3664940fc41641c7136a909270a024bbcfb2f8523a06a0d22f85c459b0b1ae',
|
||||
'size_bytes': 53959,
|
||||
'tags': ['Sequencer'],
|
||||
'version_str': '0.1.0',
|
||||
'slug': 'edit-breakdown',
|
||||
}
|
||||
file = FileFactory(
|
||||
type=File.TYPES.BPY,
|
||||
user=some_user,
|
||||
original_hash=file_data['file_hash'],
|
||||
hash=file_data['file_hash'],
|
||||
metadata=file_data['metadata'],
|
||||
)
|
||||
create_version(
|
||||
file=file,
|
||||
extension__name=file_data['metadata']['name'],
|
||||
extension__slug=file_data['metadata']['id'].replace("_", "-"),
|
||||
extension__website=None,
|
||||
tagline=file_data['metadata']['tagline'],
|
||||
version=file_data['metadata']['version'],
|
||||
blender_version_min=file_data['metadata']['blender_version_min'],
|
||||
schema_version=file_data['metadata']['schema_version'],
|
||||
)
|
||||
self.client.force_login(some_user)
|
||||
data = {
|
||||
# Most of these values should come from the form's initial values, set in the template
|
||||
# Version fields
|
||||
'release_notes': 'initial release',
|
||||
# Extension fields
|
||||
'description': 'Rather long and verbose description',
|
||||
'support': 'https://example.com/issues',
|
||||
# Previews
|
||||
'form-TOTAL_FORMS': ['2'],
|
||||
'form-INITIAL_FORMS': ['0'],
|
||||
'form-MIN_NUM_FORMS': ['0'],
|
||||
'form-MAX_NUM_FORMS': ['1000'],
|
||||
'form-0-id': '',
|
||||
'form-0-caption': ['First Preview Caption Text'],
|
||||
'form-1-id': '',
|
||||
'form-1-caption': ['Second Preview Caption Text'],
|
||||
# Submit for Approval.
|
||||
'submit_draft': '',
|
||||
}
|
||||
file_name1 = 'test_preview_image_0001.png'
|
||||
file_name2 = 'test_preview_image_0002.png'
|
||||
with open(TEST_FILES_DIR / file_name1, 'rb') as fp1, open(
|
||||
TEST_FILES_DIR / file_name2, 'rb'
|
||||
) as fp2:
|
||||
files = {
|
||||
'form-0-source': fp1,
|
||||
'form-1-source': fp2,
|
||||
}
|
||||
self.client.post(file.get_submit_url(), {**data, **files})
|
||||
new_notification_nr = Notification.objects.filter(recipient=moderator).count()
|
||||
self.assertEqual(new_notification_nr, notification_nr + 1)
|
||||
|
||||
def test_approval_queue_activity(self):
|
||||
extension = create_approved_version(ratings=[]).extension
|
||||
author = extension.authors.first()
|
||||
moderator = create_moderator()
|
||||
some_user = UserFactory()
|
||||
notification_nrs = {}
|
||||
for user in [author, moderator, some_user]:
|
||||
notification_nrs[user.pk] = Notification.objects.filter(recipient=user).count()
|
||||
self._leave_a_comment(some_user, extension, 'this is bad')
|
||||
self._leave_a_comment(moderator, extension, 'thanks for the heads up')
|
||||
new_notification_nrs = {}
|
||||
for user in [author, moderator, some_user]:
|
||||
new_notification_nrs[user.pk] = Notification.objects.filter(recipient=user).count()
|
||||
self.assertEqual(new_notification_nrs[author.pk], notification_nrs[author.pk] + 2)
|
||||
self.assertEqual(new_notification_nrs[moderator.pk], notification_nrs[moderator.pk] + 1)
|
||||
self.assertEqual(new_notification_nrs[some_user.pk], notification_nrs[some_user.pk] + 1)
|
||||
|
||||
def _leave_a_comment(self, user, extension, text):
|
||||
self.client.force_login(user)
|
||||
url = reverse('reviewers:approval-comment', args=[extension.slug])
|
||||
self.client.post(url, {'type': ApprovalActivity.ActivityType.COMMENT, 'message': text})
|
25
notifications/urls.py
Normal file
25
notifications/urls.py
Normal file
@ -0,0 +1,25 @@
|
||||
from django.urls import path, include
|
||||
|
||||
import notifications.views as views
|
||||
|
||||
app_name = 'notifications'
|
||||
urlpatterns = [
|
||||
path(
|
||||
'notifications/',
|
||||
include(
|
||||
[
|
||||
path('', views.NotificationsView.as_view(), name='notifications'),
|
||||
path(
|
||||
'mark-read-all/',
|
||||
views.MarkReadAllView.as_view(),
|
||||
name='notifications-mark-read-all',
|
||||
),
|
||||
path(
|
||||
'<int:pk>/mark-read/',
|
||||
views.MarkReadView.as_view(),
|
||||
name='notifications-mark-read',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
49
notifications/views.py
Normal file
49
notifications/views.py
Normal file
@ -0,0 +1,49 @@
|
||||
"""Notifications pages."""
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.http import HttpResponseForbidden
|
||||
from django.http.response import JsonResponse
|
||||
from django.utils import timezone
|
||||
from django.views.generic import ListView
|
||||
from django.views.generic.detail import SingleObjectMixin
|
||||
from django.views.generic.edit import FormView
|
||||
from django.views import View
|
||||
|
||||
from notifications.models import Notification
|
||||
|
||||
|
||||
class NotificationsView(LoginRequiredMixin, ListView):
|
||||
model = Notification
|
||||
ordering = None # FIXME
|
||||
paginate_by = 10
|
||||
|
||||
def get_queryset(self):
|
||||
return Notification.objects.filter(recipient=self.request.user)
|
||||
|
||||
|
||||
class MarkReadAllView(LoginRequiredMixin, FormView):
|
||||
model = Notification
|
||||
raise_exception = True
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""Mark all previously unread notifications as read."""
|
||||
unread = self.model.objects.filter(recipient=request.user, read_at__isnull=True)
|
||||
now = timezone.now()
|
||||
for notification in unread:
|
||||
notification.read_at = now
|
||||
|
||||
Notification.objects.bulk_update(unread, ['read_at'])
|
||||
|
||||
return JsonResponse({})
|
||||
|
||||
|
||||
class MarkReadView(LoginRequiredMixin, SingleObjectMixin, View):
|
||||
model = Notification
|
||||
raise_exception = True
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
notification = self.get_object()
|
||||
if notification.recipient != request.user:
|
||||
return HttpResponseForbidden()
|
||||
notification.read_at = timezone.now()
|
||||
notification.save(update_fields=['read_at'])
|
||||
return JsonResponse({})
|
@ -6,4 +6,7 @@ class RatingsConfig(AppConfig):
|
||||
name = 'ratings'
|
||||
|
||||
def ready(self):
|
||||
from actstream import registry
|
||||
import ratings.signals # noqa: F401
|
||||
|
||||
registry.register(self.get_model('Rating'))
|
||||
|
@ -1,6 +1,8 @@
|
||||
from actstream import action
|
||||
from django.db.models.signals import pre_save, post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from constants.activity import Verb
|
||||
from ratings.models import Rating
|
||||
|
||||
|
||||
@ -17,3 +19,24 @@ def _update_rating_counters(sender, instance, *args, **kwargs):
|
||||
|
||||
version = instance.version
|
||||
version.recalculate_average_score()
|
||||
|
||||
|
||||
@receiver(post_save, sender=Rating)
|
||||
def _create_action_from_rating(
|
||||
sender: object,
|
||||
instance: Rating,
|
||||
created: bool,
|
||||
raw: bool,
|
||||
**kwargs: object,
|
||||
) -> None:
|
||||
if raw:
|
||||
return
|
||||
if not created:
|
||||
return
|
||||
|
||||
action.send(
|
||||
instance.user,
|
||||
verb=Verb.RATED_EXTENSION,
|
||||
action_object=instance,
|
||||
target=instance.extension,
|
||||
)
|
||||
|
@ -13,6 +13,7 @@ click==8.1.3
|
||||
colorhash==1.0.4
|
||||
Django==4.2.11
|
||||
dj-database-url==1.0.0
|
||||
django-activity-stream==2.0.0
|
||||
django-admin-rangefilter==0.8.5
|
||||
django-background-tasks-updated @ git+https://projects.blender.org/infrastructure/django-background-tasks.git@2e60c4ec2fd1e7155bc3f041e0ea4875495a476b
|
||||
django-compat==1.0.15
|
||||
|
@ -4,3 +4,9 @@ from django.apps import AppConfig
|
||||
class ReviewersConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'reviewers'
|
||||
|
||||
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 Flag, 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, flag=Flag.REVIEWER)
|
||||
|
||||
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,
|
||||
)
|
@ -26,7 +26,15 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
(None, {'fields': ('username', 'password')}),
|
||||
(
|
||||
_('Personal info'),
|
||||
{'fields': ('full_name', 'image', 'email', 'badges')},
|
||||
{
|
||||
'fields': (
|
||||
'full_name',
|
||||
'image',
|
||||
'email',
|
||||
'badges',
|
||||
'is_subscribed_to_notification_emails',
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
_('Permissions'),
|
||||
|
@ -6,4 +6,8 @@ class UsersConfig(AppConfig):
|
||||
verbose_name = 'Authentication and authorization'
|
||||
|
||||
def ready(self) -> None:
|
||||
from actstream import registry
|
||||
from django.contrib.auth import get_user_model
|
||||
import users.signals # noqa: F401
|
||||
|
||||
registry.register(get_user_model())
|
||||
|
5
users/forms.py
Normal file
5
users/forms.py
Normal file
@ -0,0 +1,5 @@
|
||||
from django import forms
|
||||
|
||||
|
||||
class SubscribeNotificationEmailsForm(forms.Form):
|
||||
subscribe = forms.BooleanField(widget=forms.HiddenInput(), required=False)
|
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.11 on 2024-04-15 12:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0002_moderators_group'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='is_subscribed_to_notification_emails',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
@ -4,7 +4,7 @@ import logging
|
||||
import time
|
||||
|
||||
from django.contrib.admin.utils import NestedObjects
|
||||
from django.contrib.auth.models import AbstractUser, Group
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models, DEFAULT_DB_ALIAS, transaction
|
||||
from django.templatetags.static import static
|
||||
|
||||
@ -36,6 +36,7 @@ class User(TrackChangesMixin, AbstractUser):
|
||||
'confirmed_email_at',
|
||||
'full_name',
|
||||
'email',
|
||||
'is_subscribed_to_notification_emails',
|
||||
}
|
||||
|
||||
class Meta:
|
||||
@ -49,6 +50,8 @@ class User(TrackChangesMixin, AbstractUser):
|
||||
date_deletion_requested = models.DateTimeField(null=True, blank=True)
|
||||
confirmed_email_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
is_subscribed_to_notification_emails = models.BooleanField(null=False, default=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'{self.full_name or self.username}'
|
||||
|
||||
|
@ -1,12 +1,16 @@
|
||||
from typing import Dict
|
||||
import logging
|
||||
|
||||
from actstream.actions import follow, unfollow
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models.signals import pre_save
|
||||
from django.contrib.auth.models import Group
|
||||
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 constants.activity import Flag
|
||||
from extensions.models import Extension
|
||||
from users.blender_id import BIDSession
|
||||
|
||||
User = get_user_model()
|
||||
@ -35,3 +39,33 @@ def update_user(
|
||||
|
||||
bid.copy_avatar_from_blender_id(user=instance)
|
||||
bid.copy_badges_from_blender_id(user=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,
|
||||
and users that stop being moderators should no longer follow all extensions.
|
||||
The flag=Flag.MODERATOR is used to avoid deleting follow relations that were created in contexts
|
||||
other than moderator's duties.
|
||||
"""
|
||||
if action not in ['post_add', 'post_remove']:
|
||||
return
|
||||
|
||||
moderators = Group.objects.get(name='moderators')
|
||||
extensions = Extension.objects.all()
|
||||
users = []
|
||||
if model == Group and not reverse:
|
||||
if moderators.pk not in pk_set:
|
||||
return
|
||||
users = [instance]
|
||||
else:
|
||||
if instance != moderators:
|
||||
return
|
||||
users = User.objects.filter(pk__in=pk_set)
|
||||
|
||||
for user in users:
|
||||
for extension in extensions:
|
||||
if action == 'post_remove':
|
||||
unfollow(user, extension, send_action=False, flag=Flag.MODERATOR)
|
||||
elif action == 'post_add':
|
||||
follow(user, extension, send_action=False, flag=Flag.MODERATOR)
|
||||
|
@ -117,4 +117,25 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="mb-3 mt-5">Notifications</h1>
|
||||
<div class="box settings">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-4">
|
||||
<form action="{% url 'users:subscribe-notification-emails' %}" method="post">
|
||||
{% csrf_token %}
|
||||
{{ subscribe_notification_emails_form }}
|
||||
{% if user.is_subscribed_to_notification_emails %}
|
||||
You are subscribed to notification emails.
|
||||
<button class="btn" type="submit">Unsubscribe</button>
|
||||
{% if not user.confirmed_email_at %}
|
||||
<p class="helptext text-warning">Your need to confirm your email to receive notification emails.</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
You are not subscribed to notification emails.
|
||||
<button class="btn" type="submit">Subscribe</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock settings %}
|
||||
|
@ -11,6 +11,11 @@ urlpatterns = [
|
||||
include(
|
||||
[
|
||||
path('profile/', settings.ProfileView.as_view(), name='my-profile'),
|
||||
path(
|
||||
'profile/subscribe-notification-emails/',
|
||||
settings.SubscribeNotificationEmailsView.as_view(),
|
||||
name='subscribe-notification-emails',
|
||||
),
|
||||
path('delete/', settings.DeleteView.as_view(), name='my-profile-delete'),
|
||||
]
|
||||
),
|
||||
|
@ -1,7 +1,11 @@
|
||||
"""User profile pages."""
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.urls import reverse_lazy
|
||||
from django.views.generic import TemplateView
|
||||
from django.views.generic.edit import FormView
|
||||
|
||||
from users.forms import SubscribeNotificationEmailsForm
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@ -11,8 +15,25 @@ class ProfileView(LoginRequiredMixin, TemplateView):
|
||||
|
||||
template_name = 'users/settings/profile.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['subscribe_notification_emails_form'] = SubscribeNotificationEmailsForm(
|
||||
{'subscribe': not self.request.user.is_subscribed_to_notification_emails},
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
class DeleteView(LoginRequiredMixin, TemplateView):
|
||||
"""Template view where account deletion can be requested."""
|
||||
|
||||
template_name = 'users/settings/delete.html'
|
||||
|
||||
|
||||
class SubscribeNotificationEmailsView(LoginRequiredMixin, FormView):
|
||||
form_class = SubscribeNotificationEmailsForm
|
||||
success_url = reverse_lazy('users:my-profile')
|
||||
|
||||
def form_valid(self, form):
|
||||
self.request.user.is_subscribed_to_notification_emails = form.cleaned_data['subscribe']
|
||||
self.request.user.save(update_fields={'is_subscribed_to_notification_emails'})
|
||||
return super().form_valid(form)
|
||||
|
Loading…
Reference in New Issue
Block a user