Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion admin_tests/nodes/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
from api_tests.share._utils import mock_update_share
from osf.models.files import Folder
from tests.utils import capture_notifications
from osf.models.notification_type import NotificationTypeEnum
from website import settings
from framework.auth.core import Auth

Expand Down Expand Up @@ -180,8 +181,11 @@ def test_node_spam_ham_workflow_if_node_is_public(self):
guid = node._id
request = RequestFactory().post('/fake_path')
request.user = superuser
node = handle_post_view_request(request, NodeConfirmSpamView(), node, guid)
with capture_notifications() as notifications:
node = handle_post_view_request(request, NodeConfirmSpamView(), node, guid)
assert not node.is_public
assert len(notifications['emits']) == 1
assert notifications['emits'][0]['type'] == NotificationTypeEnum.NODE_CONFIRMED_SPAM
node = handle_post_view_request(request, NodeConfirmHamView(), node, guid)
assert node.is_public

Expand Down
3 changes: 2 additions & 1 deletion admin_tests/preprints/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,8 @@ def test_confirm_spam(self, flagged_preprint, superuser, mock_akismet):

view = views.PreprintConfirmSpamView()
view = setup_view(view, request, guid=flagged_preprint._id)
view.post(request)
with assert_notification(type=NotificationTypeEnum.PREPRINT_CONFIRMED_SPAM):
view.post(request)

assert flagged_preprint.is_public
flagged_preprint.refresh_from_db()
Expand Down
3 changes: 2 additions & 1 deletion admin_tests/users/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,8 @@ def setUp(self):

def test_disable_spam_user(self):
settings.ENABLE_EMAIL_SUBSCRIPTIONS = False
self.view().post(self.request)
with assert_notification(type=NotificationTypeEnum.USER_SPAM_BANNED, user=self.user):
self.view().post(self.request)
self.user.reload()
self.public_node.reload()
assert self.user.is_disabled
Expand Down
14 changes: 14 additions & 0 deletions notifications.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,13 @@ notification_types:
template: 'website/templates/new_public_project.html.mako'
tests: ['osf_tests/test_node.py']

- name: node_confirmed_spam
subject: 'Your OSF project has been flagged as spam'
__docs__: ...
object_content_type_model_name: abstractnode
template: 'website/templates/spam_resource_confirmed.html.mako'
tests: ['tests/test_spam_mixin.py']


#### PREPRINT
- name: preprint_contributor_added_preprint_node_from_osf
Expand Down Expand Up @@ -599,6 +606,13 @@ notification_types:
template: 'website/templates/contributor_added_preprints.html.mako'
tests: ['api_tests/preprints/views/test_preprint_contributors_list.py']

- name: preprint_confirmed_spam
subject: 'Your OSF preprint has been flagged as spam'
__docs__: ...
object_content_type_model_name: preprint
template: 'website/templates/spam_resource_confirmed.html.mako'
tests: ['tests/test_spam_mixin.py']

#### SUPPORT
#### Collection Submissions
- name: collection_submission_removed_moderator
Expand Down
4 changes: 3 additions & 1 deletion osf/external/spam/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ def reclassify_domain_references(notable_domain_id, current_note, previous_note)
for item in references:
item.is_triaged = current_note != NotableDomain.Note.UNKNOWN
if current_note == NotableDomain.Note.EXCLUDE_FROM_ACCOUNT_CREATION_AND_CONTENT:
item.referrer.confirm_spam(save=False, domains=[domain.domain])
# Retroactive/bulk reclassification of a domain already flagged in the past --
# don't send a fresh "flagged as spam" email long after the fact.
item.referrer.confirm_spam(save=False, domains=[domain.domain], notify=False)
elif previous_note == NotableDomain.Note.EXCLUDE_FROM_ACCOUNT_CREATION_AND_CONTENT:
try:
item.referrer.spam_data['domains'].remove(domain.domain)
Expand Down
46 changes: 31 additions & 15 deletions osf/models/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -2285,12 +2285,14 @@ def unspam(self, save=False):
super().unspam(save=save)
self.undelete(save=save)

def confirm_spam(self, domains=None, save=True, train_spam_services=True):
def confirm_spam(self, domains=None, save=True, train_spam_services=True, notify=True):
"""
This should add behavior specific nodes/preprints confirmed to be spam.
:param save:
:return:
"""
was_already_spam = self.spam_status == SpamStatus.SPAM

super().confirm_spam(save=save, domains=domains or [], train_spam_services=train_spam_services)
self.deleted = timezone.now()
was_public = self.was_public_at_spam
Expand All @@ -2308,6 +2310,27 @@ def confirm_spam(self, domains=None, save=True, train_spam_services=True):
if save:
self.save()

if notify and not was_already_spam:
Preprint = apps.get_model('osf.Preprint')
notification_type = (
NotificationTypeEnum.PREPRINT_CONFIRMED_SPAM
if isinstance(self, Preprint)
else NotificationTypeEnum.NODE_CONFIRMED_SPAM
)
resource_type_label = 'preprint' if isinstance(self, Preprint) else 'project'
for contributor in self.contributors:
notification_type.instance.emit(
user=contributor,
subscribed_object=self,
event_context={
'user_fullname': contributor.fullname,
'resource_title': self.title,
'resource_absolute_url': self.absolute_url,
'resource_type_label': resource_type_label,
'osf_support_email': settings.OSF_SUPPORT_EMAIL,
}
)
Comment on lines +2313 to +2336

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Registrations inherit this mixin and will be labeled "project" and use NODE_CONFIRMED_SPAM. Should we use a registration-specific label/type?


def confirm_ham(self, save=False, train_spam_services=True):
"""
This should add behavior specific nodes/preprints confirmed to be ham.
Expand Down Expand Up @@ -2403,29 +2426,22 @@ def suspend_spam_user(self, user, domains=None, self_spam=False):

self.flag_spam(skip_user_suspension=True)

# Suspend the flagged user for spam.
if not user.is_disabled:
user.deactivate_account()
NotificationTypeEnum.USER_SPAM_BANNED.instance.emit(
user,
event_context={
'user_fullname': user.fullname,
'osf_support_email': settings.OSF_SUPPORT_EMAIL,
}
)

# Suspend the flagged user for spam. Deactivation and the account-ban
# notification are both handled inside user.confirm_spam().
user.confirm_spam(domains=domains or [], save=False, skip_resources_spam=True)
user.save()

# Make public nodes private from this contributor
# Make public nodes private from this contributor. These are a side effect
# of the account-level ban, so they don't get their own notification --
# the single account-ban email already covers it.
for node in user.all_nodes:
if (self._id != node._id or self_spam) and len(node.contributors) == 1 and node.is_public:
node.confirm_spam(save=True, domains=domains, train_spam_services=False)
node.confirm_spam(save=True, domains=domains, train_spam_services=False, notify=False)

# Make preprints private from this contributor
for preprint in user.preprints.all():
if (self._id != preprint._id or self_spam) and len(preprint.contributors) == 1 and preprint.is_public:
preprint.confirm_spam(save=True, domains=domains, train_spam_services=False)
preprint.confirm_spam(save=True, domains=domains, train_spam_services=False, notify=False)

def flag_spam(self, skip_user_suspension=False):
""" Overrides SpamMixin#flag_spam.
Expand Down
2 changes: 2 additions & 0 deletions osf/models/notification_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ class NotificationTypeEnum(str, Enum):
NODE_WITHDRAWAl_REQUEST_APPROVED = 'node_withdrawal_request_approved'
NODE_WITHDRAWAl_REQUEST_REJECTED = 'node_withdrawal_request_rejected'
NODE_NEW_PUBLIC_PROJECT = 'node_new_public_project'
NODE_CONFIRMED_SPAM = 'node_confirmed_spam'

FILE_UPDATED = 'file_updated'
FILE_ADDED = 'file_added'
Expand All @@ -131,6 +132,7 @@ class NotificationTypeEnum(str, Enum):
PREPRINT_REQUEST_WITHDRAWAL_DECLINED = 'preprint_request_withdrawal_declined'
PREPRINT_CONTRIBUTOR_ADDED_PREPRINT_NODE_FROM_OSF = 'preprint_contributor_added_preprint_node_from_osf'
PREPRINT_CONTRIBUTOR_ADDED_DEFAULT = 'preprint_contributor_added_default'
PREPRINT_CONFIRMED_SPAM = 'preprint_confirmed_spam'

# Collections Submission notifications
COLLECTION_SUBMISSION_REMOVED_ADMIN = 'collection_submission_removed_admin'
Expand Down
2 changes: 1 addition & 1 deletion osf/models/spam.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ def confirm_ham(self, save=False, train_spam_services=True):
)
)

def confirm_spam(self, domains=None, save=True, train_spam_services=True):
def confirm_spam(self, domains=None, save=True, train_spam_services=True, notify=True):
if domains:
if 'domains' in self.spam_data:
self.spam_data['domains'].extend(domains)
Expand Down
20 changes: 16 additions & 4 deletions osf/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -1475,18 +1475,30 @@ def confirm_email(self, token, merge=False):

return True

def confirm_spam(self, domains=None, save=True, train_spam_services=False, skip_resources_spam=False):
def confirm_spam(self, domains=None, save=True, train_spam_services=False, skip_resources_spam=False, notify=True):
was_disabled = self.is_disabled
self.deactivate_account()
super().confirm_spam(domains=domains, save=save, train_spam_services=train_spam_services)

if notify and not was_disabled:
NotificationTypeEnum.USER_SPAM_BANNED.instance.emit(
user=self,
event_context={
'user_fullname': self.fullname,
'osf_support_email': website_settings.OSF_SUPPORT_EMAIL,
}
)

if skip_resources_spam:
return

# Don't train on resources merely associated with spam user
# Don't train on resources merely associated with spam user, and don't
# send a separate per-item notification for content caught up in the
# account-level ban cascade -- the single account-ban email covers it.
for node in self.nodes.filter(is_public=True, is_deleted=False):
node.confirm_spam(domains=domains, train_spam_services=train_spam_services)
node.confirm_spam(domains=domains, train_spam_services=train_spam_services, notify=False)
for preprint in self.preprints.filter(is_public=True, deleted__isnull=True):
preprint.confirm_spam(domains=domains, train_spam_services=train_spam_services)
preprint.confirm_spam(domains=domains, train_spam_services=train_spam_services, notify=False)

def confirm_ham(self, save=False, train_spam_services=False):
self.reactivate_account()
Expand Down
21 changes: 20 additions & 1 deletion osf_tests/test_notable_domains.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
from osf.models import (
NotableDomain,
DomainReference,
SpamStatus
SpamStatus,
NotificationTypeEnum,
)
from osf.utils.workflows import DefaultStates
from osf_tests.factories import (
Expand All @@ -21,6 +22,7 @@
RegistrationFactory,
UserFactory
)
from tests.utils import capture_notifications


class TestDomainExtraction:
Expand Down Expand Up @@ -249,6 +251,23 @@ def test_check_resource_for_duplicate_spam_domains(self, factory, spam_domain, m
domain__domain=spam_domain.netloc
).count() == 1

def test_check_resource_for_spam_postcommit_bans_user_flagged_via_domain(self, spam_domain, marked_as_spam_domain):
user = UserFactory()
with mock.patch.object(spam_tasks.requests, 'head'), capture_notifications() as notifications:
spam_tasks.check_resource_for_spam_postcommit(
guid=user._id,
content=spam_domain.geturl(),
author=user.fullname,
author_email=user.username,
request_headers={},
)

user.reload()
assert user.is_disabled
assert user.spam_status == SpamStatus.SPAM
assert len(notifications['emits']) == 1
assert notifications['emits'][0]['type'] == NotificationTypeEnum.USER_SPAM_BANNED

@pytest.mark.enable_enqueue_task
def test_extract_domains_from_wiki__public_project_extracts_domains_on_wiki_save(self, request_context):
assert DomainReference.objects.count() == 0
Expand Down
56 changes: 54 additions & 2 deletions tests/test_spam_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from tests.base import DbTestCase
from osf_tests.factories import UserFactory, CommentFactory, ProjectFactory, PreprintFactory, RegistrationFactory, AuthUserFactory
from osf.models import NotableDomain, SpamStatus, NotificationTypeEnum
from osf.models import NotableDomain, SpamStatus, NotificationTypeEnum, OSFUser, AbstractNode, Preprint
from tests.utils import capture_notifications
from website import settings

Expand All @@ -28,12 +28,36 @@ def test_throttled_autoban():
projects.append(proj)
assert len(notifications['emits']) == 1
assert notifications['emits'][0]['type'] == NotificationTypeEnum.USER_SPAM_BANNED
assert not any(e['type'] == NotificationTypeEnum.NODE_CONFIRMED_SPAM for e in notifications['emits'])
user.reload()
assert user.is_disabled
for project in projects:
assert not project.is_public


@pytest.mark.django_db
def test_user_confirm_spam_cascade_suppresses_content_notifications():
user = AuthUserFactory()
project = ProjectFactory(creator=user, is_public=True)
preprint = PreprintFactory(creator=user, is_public=True)

with capture_notifications() as notifications:
user.confirm_spam(save=True)

assert len(notifications['emits']) == 1
assert notifications['emits'][0]['type'] == NotificationTypeEnum.USER_SPAM_BANNED
assert not any(
e['type'] in (NotificationTypeEnum.NODE_CONFIRMED_SPAM, NotificationTypeEnum.PREPRINT_CONFIRMED_SPAM)
for e in notifications['emits']
)
user.reload()
assert user.is_disabled
project.reload()
assert not project.is_public
preprint.reload()
assert not preprint.is_public


@pytest.mark.enable_implicit_clean
class TestReportAbuse(DbTestCase):

Expand Down Expand Up @@ -173,7 +197,35 @@ def test_confirm_ham(self, spammable_thing):
assert spammable_thing.is_ham

def test_confirm_spam(self, spammable_thing):
spammable_thing.confirm_spam(save=True)
if isinstance(spammable_thing, OSFUser):
expected_type = NotificationTypeEnum.USER_SPAM_BANNED
elif isinstance(spammable_thing, Preprint):
expected_type = NotificationTypeEnum.PREPRINT_CONFIRMED_SPAM
elif isinstance(spammable_thing, AbstractNode):
expected_type = NotificationTypeEnum.NODE_CONFIRMED_SPAM
else:
expected_type = None

with capture_notifications(allow_none=expected_type is None) as notifications:
spammable_thing.confirm_spam(save=True)

assert spammable_thing.is_spam
if expected_type is None:
assert notifications['emits'] == []
else:
assert len(notifications['emits']) == 1
assert notifications['emits'][0]['type'] == expected_type

def test_confirm_spam_is_idempotent_for_notifications(self, spammable_thing):
with capture_notifications(allow_none=True) as notifications:
spammable_thing.confirm_spam(save=True)
spammable_thing.confirm_spam(save=True)

assert len(notifications['emits']) <= 1

def test_confirm_spam_notify_false_suppresses_emit(self, spammable_thing):
with capture_notifications(expect_none=True):
spammable_thing.confirm_spam(save=True, notify=False)
assert spammable_thing.is_spam

@pytest.mark.parametrize('assume_ham', (True, False))
Expand Down
16 changes: 16 additions & 0 deletions website/templates/spam_resource_confirmed.html.mako
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<%inherit file="notify_base.mako" />

<%def name="content()">
<tr>
<td style="border-collapse: collapse;">
Dear ${user_fullname}, <br>
<br>
Your ${resource_type_label} "${resource_title}" on the Open Science Framework has been flagged as spam and made private: ${resource_absolute_url}<br>
If this is in error, please email ${osf_support_email} for assistance.<br>
<br>
Regards,<br>
<br>
The OSF Team<br>

</tr>
</%def>
Loading