diff --git a/abuse/apps.py b/abuse/apps.py index 90891569..c775109d 100644 --- a/abuse/apps.py +++ b/abuse/apps.py @@ -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')) diff --git a/abuse/models.py b/abuse/models.py index a7e491c3..687328d2 100644 --- a/abuse/models.py +++ b/abuse/models.py @@ -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, diff --git a/abuse/signals.py b/abuse/signals.py new file mode 100644 index 00000000..129a5c77 --- /dev/null +++ b/abuse/signals.py @@ -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, + ) diff --git a/blender_extensions/settings.py b/blender_extensions/settings.py index 5e62b24f..9206a5ae 100644 --- a/blender_extensions/settings.py +++ b/blender_extensions/settings.py @@ -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', +} diff --git a/blender_extensions/urls.py b/blender_extensions/urls.py index 711170de..fb6f91d8 100644 --- a/blender_extensions/urls.py +++ b/blender_extensions/urls.py @@ -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'), diff --git a/common/tests/factories/users.py b/common/tests/factories/users.py index ed085882..5925e1c4 100644 --- a/common/tests/factories/users.py +++ b/common/tests/factories/users.py @@ -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 diff --git a/constants/activity.py b/constants/activity.py new file mode 100644 index 00000000..e5127d69 --- /dev/null +++ b/constants/activity.py @@ -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' diff --git a/extensions/apps.py b/extensions/apps.py index 2942caa2..4bec7d8c 100644 --- a/extensions/apps.py +++ b/extensions/apps.py @@ -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')) diff --git a/extensions/signals.py b/extensions/signals.py index 780abdd9..34dfd935 100644 --- a/extensions/signals.py +++ b/extensions/signals.py @@ -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) diff --git a/extensions/views/manage.py b/extensions/views/manage.py index eac467e9..77498540 100644 --- a/extensions/views/manage.py +++ b/extensions/views/manage.py @@ -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() diff --git a/notifications/__init.py__ b/notifications/__init.py__ new file mode 100644 index 00000000..e69de29b diff --git a/notifications/apps.py b/notifications/apps.py new file mode 100644 index 00000000..3d6987ef --- /dev/null +++ b/notifications/apps.py @@ -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 diff --git a/notifications/management/__init__.py b/notifications/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/notifications/management/commands/__init__.py b/notifications/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/notifications/management/commands/ensure_followers.py b/notifications/management/commands/ensure_followers.py new file mode 100644 index 00000000..5b3461c5 --- /dev/null +++ b/notifications/management/commands/ensure_followers.py @@ -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}') diff --git a/notifications/management/commands/send_notification_emails.py b/notifications/management/commands/send_notification_emails.py new file mode 100644 index 00000000..8d704664 --- /dev/null +++ b/notifications/management/commands/send_notification_emails.py @@ -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], + ) diff --git a/notifications/migrations/0001_initial.py b/notifications/migrations/0001_initial.py new file mode 100644 index 00000000..5e38f335 --- /dev/null +++ b/notifications/migrations/0001_initial.py @@ -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')}, + }, + ), + ] diff --git a/notifications/migrations/__init__.py b/notifications/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/notifications/models.py b/notifications/models.py new file mode 100644 index 00000000..633c4192 --- /dev/null +++ b/notifications/models.py @@ -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) diff --git a/notifications/signals.py b/notifications/signals.py new file mode 100644 index 00000000..468e921c --- /dev/null +++ b/notifications/signals.py @@ -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) diff --git a/notifications/templates/notifications/notification_list.html b/notifications/templates/notifications/notification_list.html new file mode 100644 index 00000000..9ac5dc59 --- /dev/null +++ b/notifications/templates/notifications/notification_list.html @@ -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 %} +
+ {% blocktranslate %}You have no notifications{% endblocktranslate %} +
+{% endif %} +{% endblock content %} diff --git a/notifications/tests/test_follow_logic.py b/notifications/tests/test_follow_logic.py new file mode 100644 index 00000000..8a3d5b50 --- /dev/null +++ b/notifications/tests/test_follow_logic.py @@ -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}) diff --git a/notifications/urls.py b/notifications/urls.py new file mode 100644 index 00000000..7a961ae3 --- /dev/null +++ b/notifications/urls.py @@ -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( + '