Make it possible to fully delete unlisted/unrated extensions #81

Merged
Anna Sirota merged 24 commits from fully-delete-extension into main 2024-04-19 11:00:19 +02:00
39 changed files with 809 additions and 18 deletions
Showing only changes of commit da11d42d72 - Show all commits

View File

@ -4,3 +4,9 @@ from django.apps import AppConfig
class AbuseConfig(AppConfig): class AbuseConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'abuse' name = 'abuse'
def ready(self):
from actstream import registry
import abuse.signals # noqa: F401
registry.register(self.get_model('AbuseReport'))

View File

@ -34,6 +34,7 @@ class AbuseReport(CreatedModifiedMixin, TrackChangesMixin, models.Model):
) )
# NULL if the reporter is anonymous. # NULL if the reporter is anonymous.
# FIXME? make non-null
reporter = models.ForeignKey( reporter = models.ForeignKey(
User, User,
null=True, null=True,

47
abuse/signals.py Normal file
View 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,
)

View File

@ -54,6 +54,7 @@ INSTALLED_APPS = [
'common', 'common',
'files', 'files',
'loginas', 'loginas',
'notifications',
'pipeline', 'pipeline',
'ratings', 'ratings',
'rangefilter', 'rangefilter',
@ -73,6 +74,7 @@ INSTALLED_APPS = [
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'django.contrib.flatpages', 'django.contrib.flatpages',
'django.contrib.humanize', 'django.contrib.humanize',
'actstream',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@ -319,3 +321,7 @@ EMAIL_HOST = os.getenv('EMAIL_HOST')
EMAIL_PORT = os.getenv('EMAIL_PORT', '587') EMAIL_PORT = os.getenv('EMAIL_PORT', '587')
EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER') EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER')
EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD') EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD')
ACTSTREAM_SETTINGS = {
'MANAGER': 'actstream.managers.ActionManager',
}

View File

@ -39,6 +39,7 @@ urlpatterns = [
path('', include('users.urls')), path('', include('users.urls')),
path('', include('teams.urls')), path('', include('teams.urls')),
path('', include('reviewers.urls')), path('', include('reviewers.urls')),
path('', include('notifications.urls')),
path('api/swagger/', RedirectView.as_view(url='/api/v1/swagger/')), path('api/swagger/', RedirectView.as_view(url='/api/v1/swagger/')),
path('api/v1/', SpectacularAPIView.as_view(), name='schema_v1'), path('api/v1/', SpectacularAPIView.as_view(), name='schema_v1'),
path('api/v1/swagger/', SpectacularSwaggerView.as_view(url_name='schema_v1'), name='swagger'), path('api/v1/swagger/', SpectacularSwaggerView.as_view(url_name='schema_v1'), name='swagger'),

View File

@ -1,6 +1,7 @@
import random import random
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from factory.django import DjangoModelFactory from factory.django import DjangoModelFactory
import factory import factory
@ -42,3 +43,10 @@ class UserFactory(DjangoModelFactory):
oauth_tokens = factory.RelatedFactoryList(OAuthUserTokenFactory, factory_related_name='user') oauth_tokens = factory.RelatedFactoryList(OAuthUserTokenFactory, factory_related_name='user')
oauth_info = factory.RelatedFactory(OAuthUserInfoFactory, 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
View 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'

View File

@ -6,4 +6,7 @@ class ExtensionsConfig(AppConfig):
name = 'extensions' name = 'extensions'
def ready(self): def ready(self):
from actstream import registry
import extensions.signals # noqa: F401 import extensions.signals # noqa: F401
registry.register(self.get_model('Extension'))

View File

@ -1,18 +1,18 @@
from typing import Union from typing import Union
import logging import logging
# from django.core.exceptions import ValidationError from actstream.actions import follow, unfollow
from django.db.models.signals import pre_save, post_save, pre_delete, post_delete 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, pre_delete, post_delete
from django.dispatch import receiver from django.dispatch import receiver
import django.dispatch
from constants.activity import Flag
import extensions.models import extensions.models
import files.models import files.models
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
User = get_user_model()
version_changed = django.dispatch.Signal()
version_uploaded = django.dispatch.Signal()
@receiver(pre_delete, sender=extensions.models.Extension) @receiver(pre_delete, sender=extensions.models.Extension)
@ -61,10 +61,6 @@ def _update_search_index(sender, instance, **kw):
pass # TODO: update search index 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): def extension_should_be_listed(extension):
return ( return (
extension.latest_version is not None extension.latest_version is not None
@ -108,6 +104,42 @@ def _set_is_listed(
extension.save() extension.save()
@receiver(post_save, sender=extensions.models.Extension)
def _setup_followers(
sender: object,
instance: extensions.models.Extension,
created: bool,
**kwargs: object,
) -> None:
if not created:
return
for user in instance.authors.all():
follow(user, instance, send_action=False, flag=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)
@receiver(post_save, sender=extensions.models.Preview) @receiver(post_save, sender=extensions.models.Preview)
@receiver(post_save, sender=extensions.models.Version) @receiver(post_save, sender=extensions.models.Version)
def _auto_approve_subsequent_uploads( def _auto_approve_subsequent_uploads(
@ -133,6 +165,3 @@ def _auto_approve_subsequent_uploads(
args = {'f_id': file.pk, 'pk': instance.pk, 'sender': sender, 's': file.source.name} args = {'f_id': file.pk, 'pk': instance.pk, 'sender': sender, 's': file.source.name}
logger.info('Auto-approving file pk=%(f_id)s of %(sender)s pk=%(pk)s source=%(s)s', args) logger.info('Auto-approving file pk=%(f_id)s of %(sender)s pk=%(pk)s source=%(s)s', args)
file.save(update_fields={'status', 'date_modified'}) file.save(update_fields={'status', 'date_modified'})
version_uploaded.connect(send_notifications, dispatch_uid='send_notifications')

View File

@ -1,4 +1,4 @@
from django.test import TransactionTestCase from django.test import TestCase
from common.tests.factories.extensions import create_approved_version, create_version from common.tests.factories.extensions import create_approved_version, create_version
from common.tests.factories.files import FileFactory from common.tests.factories.files import FileFactory
@ -7,7 +7,7 @@ import extensions.models
import files.models import files.models
class DeleteTest(TransactionTestCase): class DeleteTest(TestCase):
fixtures = ['dev', 'licenses'] fixtures = ['dev', 'licenses']
def test_unlisted_unrated_extension_can_be_deleted_by_author(self): def test_unlisted_unrated_extension_can_be_deleted_by_author(self):

View File

@ -408,6 +408,7 @@ class DraftExtensionView(
# Send the extension and version to the review # Send the extension and version to the review
if 'submit_draft' in self.request.POST: if 'submit_draft' in self.request.POST:
extension_form.instance.status = extension_form.instance.STATUSES.AWAITING_REVIEW extension_form.instance.status = extension_form.instance.STATUSES.AWAITING_REVIEW
# FIXME create ApprovalActivity
extension_form.save() extension_form.save()
add_preview_formset.save() add_preview_formset.save()
form.save() form.save()

View File

9
notifications/apps.py Normal file
View 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

View File

View 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.REVIEWER)
def _follow_with_log(user, target, flag):
follow(user, target, send_action=False, flag=flag)
logger.info(f'{user} follows {target} with flag={flag}')

View File

@ -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],
)

View 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')},
},
),
]

View File

56
notifications/models.py Normal file
View 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
View 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)

View 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 %}

View 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
View 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
View 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({})

View File

@ -6,4 +6,7 @@ class RatingsConfig(AppConfig):
name = 'ratings' name = 'ratings'
def ready(self): def ready(self):
from actstream import registry
import ratings.signals # noqa: F401 import ratings.signals # noqa: F401
registry.register(self.get_model('Rating'))

View File

@ -1,6 +1,8 @@
from actstream import action
from django.db.models.signals import pre_save, post_save from django.db.models.signals import pre_save, post_save
from django.dispatch import receiver from django.dispatch import receiver
from constants.activity import Verb
from ratings.models import Rating from ratings.models import Rating
@ -17,3 +19,24 @@ def _update_rating_counters(sender, instance, *args, **kwargs):
version = instance.version version = instance.version
version.recalculate_average_score() 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,
)

View File

@ -13,6 +13,7 @@ click==8.1.3
colorhash==1.0.4 colorhash==1.0.4
Django==4.2.11 Django==4.2.11
dj-database-url==1.0.0 dj-database-url==1.0.0
django-activity-stream==2.0.0
django-admin-rangefilter==0.8.5 django-admin-rangefilter==0.8.5
django-background-tasks-updated @ git+https://projects.blender.org/infrastructure/django-background-tasks.git@2e60c4ec2fd1e7155bc3f041e0ea4875495a476b django-background-tasks-updated @ git+https://projects.blender.org/infrastructure/django-background-tasks.git@2e60c4ec2fd1e7155bc3f041e0ea4875495a476b
django-compat==1.0.15 django-compat==1.0.15

View File

@ -4,3 +4,9 @@ from django.apps import AppConfig
class ReviewersConfig(AppConfig): class ReviewersConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'reviewers' 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
View 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,
)

View File

@ -26,7 +26,15 @@ class UserAdmin(auth_admin.UserAdmin):
(None, {'fields': ('username', 'password')}), (None, {'fields': ('username', 'password')}),
( (
_('Personal info'), _('Personal info'),
{'fields': ('full_name', 'image', 'email', 'badges')}, {
'fields': (
'full_name',
'image',
'email',
'badges',
'is_subscribed_to_notification_emails',
)
},
), ),
( (
_('Permissions'), _('Permissions'),

View File

@ -6,4 +6,8 @@ class UsersConfig(AppConfig):
verbose_name = 'Authentication and authorization' verbose_name = 'Authentication and authorization'
def ready(self) -> None: def ready(self) -> None:
from actstream import registry
from django.contrib.auth import get_user_model
import users.signals # noqa: F401 import users.signals # noqa: F401
registry.register(get_user_model())

5
users/forms.py Normal file
View File

@ -0,0 +1,5 @@
from django import forms
class SubscribeNotificationEmailsForm(forms.Form):
subscribe = forms.BooleanField(widget=forms.HiddenInput(), required=False)

View File

@ -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),
),
]

View File

@ -4,7 +4,7 @@ import logging
import time import time
from django.contrib.admin.utils import NestedObjects 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.db import models, DEFAULT_DB_ALIAS, transaction
from django.templatetags.static import static from django.templatetags.static import static
@ -36,6 +36,7 @@ class User(TrackChangesMixin, AbstractUser):
'confirmed_email_at', 'confirmed_email_at',
'full_name', 'full_name',
'email', 'email',
'is_subscribed_to_notification_emails',
} }
class Meta: class Meta:
@ -49,6 +50,8 @@ class User(TrackChangesMixin, AbstractUser):
date_deletion_requested = models.DateTimeField(null=True, blank=True) date_deletion_requested = models.DateTimeField(null=True, blank=True)
confirmed_email_at = 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: def __str__(self) -> str:
return f'{self.full_name or self.username}' return f'{self.full_name or self.username}'

View File

@ -1,12 +1,16 @@
from typing import Dict from typing import Dict
import logging import logging
from actstream.actions import follow, unfollow
from django.contrib.auth import get_user_model 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 django.dispatch import receiver
from blender_id_oauth_client import signals as bid_signals from blender_id_oauth_client import signals as bid_signals
from constants.activity import Flag
from extensions.models import Extension
from users.blender_id import BIDSession from users.blender_id import BIDSession
User = get_user_model() User = get_user_model()
@ -35,3 +39,33 @@ def update_user(
bid.copy_avatar_from_blender_id(user=instance) bid.copy_avatar_from_blender_id(user=instance)
bid.copy_badges_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)

View File

@ -117,4 +117,25 @@
</div> </div>
</div> </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 %} {% endblock settings %}

View File

@ -11,6 +11,11 @@ urlpatterns = [
include( include(
[ [
path('profile/', settings.ProfileView.as_view(), name='my-profile'), 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'), path('delete/', settings.DeleteView.as_view(), name='my-profile-delete'),
] ]
), ),

View File

@ -1,7 +1,11 @@
"""User profile pages.""" """User profile pages."""
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from django.views.generic import TemplateView from django.views.generic import TemplateView
from django.views.generic.edit import FormView
from users.forms import SubscribeNotificationEmailsForm
User = get_user_model() User = get_user_model()
@ -11,8 +15,25 @@ class ProfileView(LoginRequiredMixin, TemplateView):
template_name = 'users/settings/profile.html' 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): class DeleteView(LoginRequiredMixin, TemplateView):
"""Template view where account deletion can be requested.""" """Template view where account deletion can be requested."""
template_name = 'users/settings/delete.html' 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)