Make it possible to fully delete unlisted/unrated extensions #81
@ -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'))
|
||||||
|
@ -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
47
abuse/signals.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from actstream import action
|
||||||
|
from django.db.models.signals import post_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
from abuse.models import AbuseReport
|
||||||
|
from constants.activity import Verb
|
||||||
|
from constants.base import (
|
||||||
|
ABUSE_TYPE_EXTENSION,
|
||||||
|
ABUSE_TYPE_RATING,
|
||||||
|
ABUSE_TYPE_USER,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=AbuseReport)
|
||||||
|
def _create_action_from_report(
|
||||||
|
sender: object,
|
||||||
|
instance: AbuseReport,
|
||||||
|
created: bool,
|
||||||
|
raw: bool,
|
||||||
|
**kwargs: object,
|
||||||
|
) -> None:
|
||||||
|
if not created:
|
||||||
|
return
|
||||||
|
if raw:
|
||||||
|
return
|
||||||
|
|
||||||
|
if instance.type == ABUSE_TYPE_EXTENSION:
|
||||||
|
verb = Verb.REPORTED_EXTENSION
|
||||||
|
elif instance.type == ABUSE_TYPE_RATING:
|
||||||
|
verb = Verb.REPORTED_RATING
|
||||||
|
elif instance.type == ABUSE_TYPE_USER:
|
||||||
|
# TODO?
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
logger.warning(f'ignoring an unexpected AbuseReport type={instance.type}')
|
||||||
|
return
|
||||||
|
|
||||||
|
action.send(
|
||||||
|
instance.reporter,
|
||||||
|
verb=verb,
|
||||||
|
target=instance.extension,
|
||||||
|
action_object=instance,
|
||||||
|
)
|
@ -54,6 +54,7 @@ INSTALLED_APPS = [
|
|||||||
'common',
|
'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',
|
||||||
|
}
|
||||||
|
@ -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'),
|
||||||
|
@ -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
18
constants/activity.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
class Verb:
|
||||||
|
"""These constants are used to dispatch Action records,
|
||||||
|
changing the values will result in a mismatch with historical values stored in db.
|
||||||
|
"""
|
||||||
|
|
||||||
|
APPROVED = 'approved'
|
||||||
|
COMMENTED = 'commented'
|
||||||
|
RATED_EXTENSION = 'rated extension'
|
||||||
|
REPORTED_EXTENSION = 'reported extension'
|
||||||
|
REPORTED_RATING = 'reported rating'
|
||||||
|
REQUESTED_CHANGES = 'requested changes'
|
||||||
|
REQUESTED_REVIEW = 'requested review'
|
||||||
|
|
||||||
|
|
||||||
|
class Flag:
|
||||||
|
AUTHOR = 'author'
|
||||||
|
MODERATOR = 'moderator'
|
||||||
|
REVIEWER = 'reviewer'
|
@ -6,4 +6,7 @@ class ExtensionsConfig(AppConfig):
|
|||||||
name = 'extensions'
|
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'))
|
||||||
|
@ -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')
|
|
||||||
|
@ -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):
|
||||||
|
@ -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()
|
||||||
|
0
notifications/__init__.py
Normal file
0
notifications/__init__.py
Normal file
9
notifications/apps.py
Normal file
9
notifications/apps.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'notifications'
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
import notifications.signals # noqa: F401
|
0
notifications/management/__init__.py
Normal file
0
notifications/management/__init__.py
Normal file
0
notifications/management/commands/__init__.py
Normal file
0
notifications/management/commands/__init__.py
Normal file
35
notifications/management/commands/ensure_followers.py
Normal file
35
notifications/management/commands/ensure_followers.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
"""Create all necessary follow records."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from actstream.actions import follow
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from constants.activity import Flag
|
||||||
|
from extensions.models import Extension
|
||||||
|
from reviewers.models import ApprovalActivity
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
def handle(self, *args, **options): # noqa: D102
|
||||||
|
# TODO? keep a record of explicit unfollow requests to avoid re-following
|
||||||
|
extensions = Extension.objects.all()
|
||||||
|
moderators = Group.objects.get(name='moderators').user_set.all()
|
||||||
|
for extension in extensions:
|
||||||
|
authors = extension.authors.all()
|
||||||
|
for recipient in authors:
|
||||||
|
_follow_with_log(recipient, extension, Flag.AUTHOR)
|
||||||
|
for recipient in moderators:
|
||||||
|
_follow_with_log(recipient, extension, Flag.MODERATOR)
|
||||||
|
|
||||||
|
approval_activity_items = ApprovalActivity.objects.all().select_related('extension', 'user')
|
||||||
|
for item in approval_activity_items:
|
||||||
|
_follow_with_log(item.user, item.extension, Flag.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}')
|
@ -0,0 +1,52 @@
|
|||||||
|
"""Send user notifications as emails, at most once delivery."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from notifications.models import Notification
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
def handle(self, *args, **options): # noqa: D102
|
||||||
|
unprocessed_notifications = Notification.objects.filter(processed_by_mailer_at=None)
|
||||||
|
for n in unprocessed_notifications:
|
||||||
|
logger.info(f'processing Notification pk={n.pk}')
|
||||||
|
n.processed_by_mailer_at = timezone.now()
|
||||||
|
recipient = n.recipient
|
||||||
|
if not recipient.is_subscribed_to_notification_emails:
|
||||||
|
logger.info(f'{recipient} is not subscribed, skipping')
|
||||||
|
n.save()
|
||||||
|
continue
|
||||||
|
# check that email is confirmed to avoid spamming unsuspecting email owners
|
||||||
|
if recipient.confirmed_email_at is None:
|
||||||
|
logger.info(f'{recipient} has unconfirmed email, skipping')
|
||||||
|
n.save()
|
||||||
|
continue
|
||||||
|
# FIXME test with only internal emails first
|
||||||
|
if not recipient.email.endswith('@blender.org'):
|
||||||
|
logger.info('skipping: not an internal email')
|
||||||
|
n.save()
|
||||||
|
continue
|
||||||
|
n.email_sent = True
|
||||||
|
# first mark as processed, then send: avoid spamming in case of a crash-loop
|
||||||
|
n.save()
|
||||||
|
logger.info(f'sending an email to {recipient}: {n.action}')
|
||||||
|
send_notification_email(n)
|
||||||
|
|
||||||
|
|
||||||
|
def send_notification_email(notification):
|
||||||
|
# TODO construct a proper phrase, depending on the verb,
|
||||||
|
# possibly share a template with NotificationsView
|
||||||
|
subject, message = notification.format_email()
|
||||||
|
send_mail(
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
settings.DEFAULT_FROM_EMAIL,
|
||||||
|
[notification.recipient.email],
|
||||||
|
)
|
33
notifications/migrations/0001_initial.py
Normal file
33
notifications/migrations/0001_initial.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 4.2.11 on 2024-04-16 15:56
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('actstream', '0003_add_follow_flag'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Notification',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||||
|
('action', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='actstream.action')),
|
||||||
|
('email_sent', models.BooleanField(default=False)),
|
||||||
|
('processed_by_mailer_at', models.DateTimeField(default=None, null=True)),
|
||||||
|
('read_at', models.DateTimeField(default=None, null=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'indexes': [models.Index(fields=['processed_by_mailer_at'], name='notificatio_process_fc95bc_idx'), models.Index(fields=['recipient', 'read_at'], name='notificatio_recipie_564b1f_idx')],
|
||||||
|
'unique_together': {('recipient', 'action')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
0
notifications/migrations/__init__.py
Normal file
0
notifications/migrations/__init__.py
Normal file
56
notifications/models.py
Normal file
56
notifications/models.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
from actstream.models import Action
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from constants.activity import Verb
|
||||||
|
from common.templatetags.common import absolutify
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(models.Model):
|
||||||
|
"""Notification records are created in Action's post_save signal.
|
||||||
|
When a user marks a notification as read, read_at is set.
|
||||||
|
send_notification_emails management command runs periodically in background and sends all
|
||||||
|
notifications that haven't been processed yet, read_at is not checked when sending emails.
|
||||||
|
email_sent flag is used only to record the fact that we attempted to send an email.
|
||||||
|
A user can unsubscribe from notification emails in their profile settings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
recipient = models.ForeignKey(User, null=False, on_delete=models.CASCADE)
|
||||||
|
action = models.ForeignKey(Action, null=False, on_delete=models.CASCADE)
|
||||||
|
email_sent = models.BooleanField(default=False, null=False)
|
||||||
|
processed_by_mailer_at = models.DateTimeField(default=None, null=True)
|
||||||
|
read_at = models.DateTimeField(default=None, null=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['processed_by_mailer_at']),
|
||||||
|
models.Index(fields=['recipient', 'read_at']),
|
||||||
|
]
|
||||||
|
unique_together = ['recipient', 'action']
|
||||||
|
|
||||||
|
def format_email(self):
|
||||||
|
action = self.action
|
||||||
|
subject = f'New Activity: {action.actor.full_name} {action.verb} {action.target}'
|
||||||
|
url = self.get_absolute_url()
|
||||||
|
mesage = f'{action.actor.full_name} {action.verb} {action.target}: {url}'
|
||||||
|
return (subject, mesage)
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
if self.action.verb == Verb.RATED_EXTENSION:
|
||||||
|
url = self.action.target.get_ratings_url()
|
||||||
|
elif self.action.verb in [
|
||||||
|
Verb.APPROVED,
|
||||||
|
Verb.COMMENTED,
|
||||||
|
Verb.REQUESTED_CHANGES,
|
||||||
|
Verb.REQUESTED_REVIEW,
|
||||||
|
]:
|
||||||
|
url = self.action.target.get_review_url()
|
||||||
|
elif self.action.action_object is not None:
|
||||||
|
url = self.action.action_object.get_absolute_url()
|
||||||
|
else:
|
||||||
|
url = self.action.target.get_absolute_url()
|
||||||
|
|
||||||
|
# TODO? url cloacking to mark visited notifications as read automatically
|
||||||
|
return absolutify(url)
|
57
notifications/signals.py
Normal file
57
notifications/signals.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from actstream.models import Action, Follow
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db.models.signals import post_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
from constants.activity import Flag, Verb
|
||||||
|
from notifications.models import Notification
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
VERB2FLAGS = {
|
||||||
|
Verb.APPROVED: [Flag.AUTHOR, Flag.MODERATOR, Flag.REVIEWER],
|
||||||
|
Verb.COMMENTED: [Flag.AUTHOR, Flag.MODERATOR, Flag.REVIEWER],
|
||||||
|
Verb.RATED_EXTENSION: [Flag.AUTHOR],
|
||||||
|
Verb.REPORTED_EXTENSION: [Flag.MODERATOR],
|
||||||
|
Verb.REPORTED_RATING: [Flag.MODERATOR],
|
||||||
|
Verb.REQUESTED_CHANGES: [Flag.AUTHOR, Flag.MODERATOR, Flag.REVIEWER],
|
||||||
|
Verb.REQUESTED_REVIEW: [Flag.MODERATOR, Flag.REVIEWER],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=Action)
|
||||||
|
def _create_notifications(
|
||||||
|
sender: object,
|
||||||
|
instance: Action,
|
||||||
|
created: bool,
|
||||||
|
raw: bool,
|
||||||
|
**kwargs: object,
|
||||||
|
) -> None:
|
||||||
|
if raw:
|
||||||
|
return
|
||||||
|
if not created:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not instance.target:
|
||||||
|
logger.warning(f'ignoring an unexpected Action without a target, verb={instance.verb}')
|
||||||
|
return
|
||||||
|
|
||||||
|
notifications = []
|
||||||
|
|
||||||
|
flags = VERB2FLAGS.get(instance.verb, None)
|
||||||
|
if not flags:
|
||||||
|
logger.warning(f'no follower flags for verb={instance.verb}, nobody will be notified')
|
||||||
|
return
|
||||||
|
|
||||||
|
followers = Follow.objects.for_object(instance.target).filter(flag__in=flags)
|
||||||
|
user_ids = followers.values_list('user', flat=True)
|
||||||
|
followers = get_user_model().objects.filter(id__in=user_ids)
|
||||||
|
|
||||||
|
for recipient in followers:
|
||||||
|
if recipient == instance.actor:
|
||||||
|
continue
|
||||||
|
notifications.append(Notification(recipient=recipient, action=instance))
|
||||||
|
if len(notifications) > 0:
|
||||||
|
Notification.objects.bulk_create(notifications)
|
22
notifications/templates/notifications/notification_list.html
Normal file
22
notifications/templates/notifications/notification_list.html
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{% extends "common/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block page_title %}{% blocktranslate %}Notifications{% endblocktranslate %}{% endblock page_title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% if notification_list %}
|
||||||
|
{% for notification in notification_list %}
|
||||||
|
<div class="row">
|
||||||
|
{{ notification.action }}
|
||||||
|
{% if notification.read_at %}
|
||||||
|
{% else %}
|
||||||
|
{% blocktranslate %}Mark as read{% endblocktranslate %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p>
|
||||||
|
{% blocktranslate %}You have no notifications{% endblocktranslate %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock content %}
|
142
notifications/tests/test_follow_logic.py
Normal file
142
notifications/tests/test_follow_logic.py
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from common.tests.factories.extensions import create_approved_version, create_version
|
||||||
|
from common.tests.factories.files import FileFactory
|
||||||
|
from common.tests.factories.users import UserFactory, create_moderator
|
||||||
|
from files.models import File
|
||||||
|
from notifications.models import Notification
|
||||||
|
from reviewers.models import ApprovalActivity
|
||||||
|
|
||||||
|
TEST_FILES_DIR = Path(__file__).resolve().parent / '../../extensions/tests/files'
|
||||||
|
|
||||||
|
|
||||||
|
class TestTasks(TestCase):
|
||||||
|
fixtures = ['dev', 'licenses']
|
||||||
|
|
||||||
|
def test_ratings(self):
|
||||||
|
extension = create_approved_version(ratings=[]).extension
|
||||||
|
author = extension.authors.first()
|
||||||
|
notification_nr = Notification.objects.filter(recipient=author).count()
|
||||||
|
some_user = UserFactory()
|
||||||
|
self.client.force_login(some_user)
|
||||||
|
url = extension.get_rate_url()
|
||||||
|
response = self.client.post(url, {'score': 3, 'text': 'rating text'})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(extension.ratings.count(), 1)
|
||||||
|
new_notification_nr = Notification.objects.filter(recipient=author).count()
|
||||||
|
self.assertEqual(new_notification_nr, notification_nr + 1)
|
||||||
|
|
||||||
|
def test_abuse(self):
|
||||||
|
extension = create_approved_version(ratings=[]).extension
|
||||||
|
moderator = create_moderator()
|
||||||
|
notification_nr = Notification.objects.filter(recipient=moderator).count()
|
||||||
|
some_user = UserFactory()
|
||||||
|
self.client.force_login(some_user)
|
||||||
|
url = extension.get_report_url()
|
||||||
|
self.client.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'message': 'test message',
|
||||||
|
'reason': '127',
|
||||||
|
'version': '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
new_notification_nr = Notification.objects.filter(recipient=moderator).count()
|
||||||
|
self.assertEqual(new_notification_nr, notification_nr + 1)
|
||||||
|
|
||||||
|
@unittest.skip('FIXME in DraftExtensionView')
|
||||||
|
def test_new_extension_submitted(self):
|
||||||
|
moderator = create_moderator()
|
||||||
|
notification_nr = Notification.objects.filter(recipient=moderator).count()
|
||||||
|
some_user = UserFactory()
|
||||||
|
file_data = {
|
||||||
|
'metadata': {
|
||||||
|
'tagline': 'Get insight on the complexity of an edit',
|
||||||
|
'id': 'edit_breakdown',
|
||||||
|
'name': 'Edit Breakdown',
|
||||||
|
'version': '0.1.0',
|
||||||
|
'blender_version_min': '4.2.0',
|
||||||
|
'type': 'add-on',
|
||||||
|
'schema_version': "1.0.0",
|
||||||
|
},
|
||||||
|
'file_hash': 'sha256:4f3664940fc41641c7136a909270a024bbcfb2f8523a06a0d22f85c459b0b1ae',
|
||||||
|
'size_bytes': 53959,
|
||||||
|
'tags': ['Sequencer'],
|
||||||
|
'version_str': '0.1.0',
|
||||||
|
'slug': 'edit-breakdown',
|
||||||
|
}
|
||||||
|
file = FileFactory(
|
||||||
|
type=File.TYPES.BPY,
|
||||||
|
user=some_user,
|
||||||
|
original_hash=file_data['file_hash'],
|
||||||
|
hash=file_data['file_hash'],
|
||||||
|
metadata=file_data['metadata'],
|
||||||
|
)
|
||||||
|
create_version(
|
||||||
|
file=file,
|
||||||
|
extension__name=file_data['metadata']['name'],
|
||||||
|
extension__slug=file_data['metadata']['id'].replace("_", "-"),
|
||||||
|
extension__website=None,
|
||||||
|
tagline=file_data['metadata']['tagline'],
|
||||||
|
version=file_data['metadata']['version'],
|
||||||
|
blender_version_min=file_data['metadata']['blender_version_min'],
|
||||||
|
schema_version=file_data['metadata']['schema_version'],
|
||||||
|
)
|
||||||
|
self.client.force_login(some_user)
|
||||||
|
data = {
|
||||||
|
# Most of these values should come from the form's initial values, set in the template
|
||||||
|
# Version fields
|
||||||
|
'release_notes': 'initial release',
|
||||||
|
# Extension fields
|
||||||
|
'description': 'Rather long and verbose description',
|
||||||
|
'support': 'https://example.com/issues',
|
||||||
|
# Previews
|
||||||
|
'form-TOTAL_FORMS': ['2'],
|
||||||
|
'form-INITIAL_FORMS': ['0'],
|
||||||
|
'form-MIN_NUM_FORMS': ['0'],
|
||||||
|
'form-MAX_NUM_FORMS': ['1000'],
|
||||||
|
'form-0-id': '',
|
||||||
|
'form-0-caption': ['First Preview Caption Text'],
|
||||||
|
'form-1-id': '',
|
||||||
|
'form-1-caption': ['Second Preview Caption Text'],
|
||||||
|
# Submit for Approval.
|
||||||
|
'submit_draft': '',
|
||||||
|
}
|
||||||
|
file_name1 = 'test_preview_image_0001.png'
|
||||||
|
file_name2 = 'test_preview_image_0002.png'
|
||||||
|
with open(TEST_FILES_DIR / file_name1, 'rb') as fp1, open(
|
||||||
|
TEST_FILES_DIR / file_name2, 'rb'
|
||||||
|
) as fp2:
|
||||||
|
files = {
|
||||||
|
'form-0-source': fp1,
|
||||||
|
'form-1-source': fp2,
|
||||||
|
}
|
||||||
|
self.client.post(file.get_submit_url(), {**data, **files})
|
||||||
|
new_notification_nr = Notification.objects.filter(recipient=moderator).count()
|
||||||
|
self.assertEqual(new_notification_nr, notification_nr + 1)
|
||||||
|
|
||||||
|
def test_approval_queue_activity(self):
|
||||||
|
extension = create_approved_version(ratings=[]).extension
|
||||||
|
author = extension.authors.first()
|
||||||
|
moderator = create_moderator()
|
||||||
|
some_user = UserFactory()
|
||||||
|
notification_nrs = {}
|
||||||
|
for user in [author, moderator, some_user]:
|
||||||
|
notification_nrs[user.pk] = Notification.objects.filter(recipient=user).count()
|
||||||
|
self._leave_a_comment(some_user, extension, 'this is bad')
|
||||||
|
self._leave_a_comment(moderator, extension, 'thanks for the heads up')
|
||||||
|
new_notification_nrs = {}
|
||||||
|
for user in [author, moderator, some_user]:
|
||||||
|
new_notification_nrs[user.pk] = Notification.objects.filter(recipient=user).count()
|
||||||
|
self.assertEqual(new_notification_nrs[author.pk], notification_nrs[author.pk] + 2)
|
||||||
|
self.assertEqual(new_notification_nrs[moderator.pk], notification_nrs[moderator.pk] + 1)
|
||||||
|
self.assertEqual(new_notification_nrs[some_user.pk], notification_nrs[some_user.pk] + 1)
|
||||||
|
|
||||||
|
def _leave_a_comment(self, user, extension, text):
|
||||||
|
self.client.force_login(user)
|
||||||
|
url = reverse('reviewers:approval-comment', args=[extension.slug])
|
||||||
|
self.client.post(url, {'type': ApprovalActivity.ActivityType.COMMENT, 'message': text})
|
25
notifications/urls.py
Normal file
25
notifications/urls.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
from django.urls import path, include
|
||||||
|
|
||||||
|
import notifications.views as views
|
||||||
|
|
||||||
|
app_name = 'notifications'
|
||||||
|
urlpatterns = [
|
||||||
|
path(
|
||||||
|
'notifications/',
|
||||||
|
include(
|
||||||
|
[
|
||||||
|
path('', views.NotificationsView.as_view(), name='notifications'),
|
||||||
|
path(
|
||||||
|
'mark-read-all/',
|
||||||
|
views.MarkReadAllView.as_view(),
|
||||||
|
name='notifications-mark-read-all',
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
'<int:pk>/mark-read/',
|
||||||
|
views.MarkReadView.as_view(),
|
||||||
|
name='notifications-mark-read',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
49
notifications/views.py
Normal file
49
notifications/views.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
"""Notifications pages."""
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.http import HttpResponseForbidden
|
||||||
|
from django.http.response import JsonResponse
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.views.generic import ListView
|
||||||
|
from django.views.generic.detail import SingleObjectMixin
|
||||||
|
from django.views.generic.edit import FormView
|
||||||
|
from django.views import View
|
||||||
|
|
||||||
|
from notifications.models import Notification
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationsView(LoginRequiredMixin, ListView):
|
||||||
|
model = Notification
|
||||||
|
ordering = None # FIXME
|
||||||
|
paginate_by = 10
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Notification.objects.filter(recipient=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class MarkReadAllView(LoginRequiredMixin, FormView):
|
||||||
|
model = Notification
|
||||||
|
raise_exception = True
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
"""Mark all previously unread notifications as read."""
|
||||||
|
unread = self.model.objects.filter(recipient=request.user, read_at__isnull=True)
|
||||||
|
now = timezone.now()
|
||||||
|
for notification in unread:
|
||||||
|
notification.read_at = now
|
||||||
|
|
||||||
|
Notification.objects.bulk_update(unread, ['read_at'])
|
||||||
|
|
||||||
|
return JsonResponse({})
|
||||||
|
|
||||||
|
|
||||||
|
class MarkReadView(LoginRequiredMixin, SingleObjectMixin, View):
|
||||||
|
model = Notification
|
||||||
|
raise_exception = True
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
notification = self.get_object()
|
||||||
|
if notification.recipient != request.user:
|
||||||
|
return HttpResponseForbidden()
|
||||||
|
notification.read_at = timezone.now()
|
||||||
|
notification.save(update_fields=['read_at'])
|
||||||
|
return JsonResponse({})
|
@ -6,4 +6,7 @@ class RatingsConfig(AppConfig):
|
|||||||
name = 'ratings'
|
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'))
|
||||||
|
@ -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,
|
||||||
|
)
|
||||||
|
@ -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
|
||||||
|
@ -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
39
reviewers/signals.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
from actstream import action
|
||||||
|
from actstream.actions import follow
|
||||||
|
from django.db.models.signals import post_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
from constants.activity import Flag, Verb
|
||||||
|
from reviewers.models import ApprovalActivity
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=ApprovalActivity)
|
||||||
|
def _create_action_from_review_and_follow(
|
||||||
|
sender: object,
|
||||||
|
instance: ApprovalActivity,
|
||||||
|
created: bool,
|
||||||
|
raw: bool,
|
||||||
|
**kwargs: object,
|
||||||
|
) -> None:
|
||||||
|
if raw:
|
||||||
|
return
|
||||||
|
if not created:
|
||||||
|
return
|
||||||
|
|
||||||
|
# automatically follow after an interaction
|
||||||
|
# if a user had unfollowed this extension before,
|
||||||
|
# we are making them a follower again
|
||||||
|
follow(instance.user, instance.extension, send_action=False, flag=Flag.REVIEWER)
|
||||||
|
|
||||||
|
activity_type2verb = {
|
||||||
|
ApprovalActivity.ActivityType.APPROVED: Verb.APPROVED,
|
||||||
|
ApprovalActivity.ActivityType.AWAITING_CHANGES: Verb.REQUESTED_CHANGES,
|
||||||
|
ApprovalActivity.ActivityType.AWAITING_REVIEW: Verb.REQUESTED_REVIEW,
|
||||||
|
ApprovalActivity.ActivityType.COMMENT: Verb.COMMENTED,
|
||||||
|
}
|
||||||
|
action.send(
|
||||||
|
instance.user,
|
||||||
|
verb=activity_type2verb.get(instance.type),
|
||||||
|
action_object=instance,
|
||||||
|
target=instance.extension,
|
||||||
|
)
|
@ -26,7 +26,15 @@ class UserAdmin(auth_admin.UserAdmin):
|
|||||||
(None, {'fields': ('username', 'password')}),
|
(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'),
|
||||||
|
@ -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
5
users/forms.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from django import forms
|
||||||
|
|
||||||
|
|
||||||
|
class SubscribeNotificationEmailsForm(forms.Form):
|
||||||
|
subscribe = forms.BooleanField(widget=forms.HiddenInput(), required=False)
|
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.11 on 2024-04-15 12:53
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('users', '0002_moderators_group'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='is_subscribed_to_notification_emails',
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
]
|
@ -4,7 +4,7 @@ import logging
|
|||||||
import time
|
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}'
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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 %}
|
||||||
|
@ -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'),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
@ -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)
|
||||||
|
Loading…
Reference in New Issue
Block a user