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 %} +
+ {{ notification.action }} + {% if notification.read_at %} + {% else %} + {% blocktranslate %}Mark as read{% endblocktranslate %} + {% endif %} +
+ {% endfor %} +{% else %} +

+ {% 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( + '/mark-read/', + views.MarkReadView.as_view(), + name='notifications-mark-read', + ), + ], + ), + ), +] diff --git a/notifications/views.py b/notifications/views.py new file mode 100644 index 00000000..5fb08b9c --- /dev/null +++ b/notifications/views.py @@ -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({}) diff --git a/ratings/apps.py b/ratings/apps.py index 15464229..f1849f97 100644 --- a/ratings/apps.py +++ b/ratings/apps.py @@ -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')) diff --git a/ratings/signals.py b/ratings/signals.py index 23e10d29..5ff6c271 100644 --- a/ratings/signals.py +++ b/ratings/signals.py @@ -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, + ) diff --git a/requirements.txt b/requirements.txt index 65acdd3c..1f02303a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/reviewers/apps.py b/reviewers/apps.py index 02fcdcbc..0ddcb5b3 100644 --- a/reviewers/apps.py +++ b/reviewers/apps.py @@ -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')) diff --git a/reviewers/signals.py b/reviewers/signals.py new file mode 100644 index 00000000..4e143c05 --- /dev/null +++ b/reviewers/signals.py @@ -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, + ) diff --git a/users/admin.py b/users/admin.py index 8e94fcdf..a7e8bb06 100644 --- a/users/admin.py +++ b/users/admin.py @@ -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'), diff --git a/users/apps.py b/users/apps.py index c6b7ce20..fa628092 100644 --- a/users/apps.py +++ b/users/apps.py @@ -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()) diff --git a/users/forms.py b/users/forms.py new file mode 100644 index 00000000..7c1da727 --- /dev/null +++ b/users/forms.py @@ -0,0 +1,5 @@ +from django import forms + + +class SubscribeNotificationEmailsForm(forms.Form): + subscribe = forms.BooleanField(widget=forms.HiddenInput(), required=False) diff --git a/users/migrations/0003_user_is_subscribed_to_notification_emails.py b/users/migrations/0003_user_is_subscribed_to_notification_emails.py new file mode 100644 index 00000000..61cc1a31 --- /dev/null +++ b/users/migrations/0003_user_is_subscribed_to_notification_emails.py @@ -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), + ), + ] diff --git a/users/models.py b/users/models.py index e1c9773d..464e5dd3 100644 --- a/users/models.py +++ b/users/models.py @@ -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}' diff --git a/users/signals.py b/users/signals.py index 8a6b5e91..4fac99e9 100644 --- a/users/signals.py +++ b/users/signals.py @@ -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) diff --git a/users/templates/users/settings/profile.html b/users/templates/users/settings/profile.html index 2f08fa73..9d2fefa6 100644 --- a/users/templates/users/settings/profile.html +++ b/users/templates/users/settings/profile.html @@ -117,4 +117,25 @@ +

Notifications

+
+
+
+
+ {% csrf_token %} + {{ subscribe_notification_emails_form }} + {% if user.is_subscribed_to_notification_emails %} + You are subscribed to notification emails. + + {% if not user.confirmed_email_at %} +

Your need to confirm your email to receive notification emails.

+ {% endif %} + {% else %} + You are not subscribed to notification emails. + + {% endif %} +
+
+
+
{% endblock settings %} diff --git a/users/urls.py b/users/urls.py index c398e8f4..3f54f4eb 100644 --- a/users/urls.py +++ b/users/urls.py @@ -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'), ] ), diff --git a/users/views/settings.py b/users/views/settings.py index e2bee9b0..baaa1f83 100644 --- a/users/views/settings.py +++ b/users/views/settings.py @@ -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)