diff --git a/CHANGELOG b/CHANGELOG index 791c594bb10..c0eadf5b56f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -2,6 +2,12 @@ We follow the CalVer (https://calver.org/) versioning scheme: YY.MINOR.MICRO. +19.25.0 (2019-09-05) +=================== +- Automate account deactivation if users have no content +- Clean up EZID workflow +- Check redirect URL's for spam + 19.24.0 (2019-08-27) =================== - APIv2: Allow creating a node with a license attached on creation diff --git a/addons/forward/models.py b/addons/forward/models.py index 6cf0d8e8544..8cc518f7887 100644 --- a/addons/forward/models.py +++ b/addons/forward/models.py @@ -1,10 +1,12 @@ # -*- coding: utf-8 -*- +from osf.utils.requests import get_request_and_user_id, get_headers_from_request from addons.base.models import BaseNodeSettings from dirtyfields import DirtyFieldsMixin from django.db import models from osf.exceptions import ValidationValueError from osf.models.validators import validate_no_html +from osf.models import OSFUser class NodeSettings(DirtyFieldsMixin, BaseNodeSettings): @@ -33,6 +35,17 @@ def after_register(self, node, registration, user, save=True): return clone, None + def save(self, request=None, *args, **kwargs): + super(NodeSettings, self).save(*args, **kwargs) + if request: + if not hasattr(request, 'user'): # TODO: remove when Flask is removed + _, user_id = get_request_and_user_id() + user = OSFUser.load(user_id) + else: + user = request.user + + self.owner.check_spam(user, {'addons_forward_node_settings__url'}, get_headers_from_request(request)) + def clean(self): if self.url and self.owner._id in self.url: raise ValidationValueError('Circular URL') diff --git a/addons/forward/tests/test_views.py b/addons/forward/tests/test_views.py index 11907155367..a07b925b8d4 100644 --- a/addons/forward/tests/test_views.py +++ b/addons/forward/tests/test_views.py @@ -1,16 +1,18 @@ +import mock import pytest from nose.tools import assert_equal from addons.forward.tests.utils import ForwardAddonTestCase from tests.base import OsfTestCase +from website import settings pytestmark = pytest.mark.django_db -class TestForwardLogs(ForwardAddonTestCase, OsfTestCase): +class TestForward(ForwardAddonTestCase, OsfTestCase): def setUp(self): - super(TestForwardLogs, self).setUp() + super(TestForward, self).setUp() self.app.authenticate(*self.user.auth) def test_change_url_log_added(self): @@ -40,3 +42,19 @@ def test_change_timeout_log_not_added(self): self.project.logs.count(), log_count ) + + @mock.patch.object(settings, 'SPAM_CHECK_ENABLED', True) + @mock.patch('osf.models.node.Node.do_check_spam') + def test_change_url_check_spam(self, mock_check_spam): + self.project.is_public = True + self.project.save() + self.app.put_json(self.project.api_url_for('forward_config_put'), {'url': 'http://possiblyspam.com'}) + + assert mock_check_spam.called + data, _ = mock_check_spam.call_args + author, author_email, content, request_headers = data + + assert author == self.user.fullname + assert author_email == self.user.username + assert content == 'http://possiblyspam.com' + diff --git a/addons/forward/views/config.py b/addons/forward/views/config.py index 8c9f45972ed..e3e21872e84 100644 --- a/addons/forward/views/config.py +++ b/addons/forward/views/config.py @@ -44,7 +44,7 @@ def forward_config_put(auth, node_addon, **kwargs): # Save settings and get changed fields; crash if validation fails try: dirty_fields = node_addon.get_dirty_fields() - node_addon.save() + node_addon.save(request=request) except ValidationValueError: raise HTTPError(http.BAD_REQUEST) diff --git a/api/addons/forward/test_views.py b/api/addons/forward/test_views.py new file mode 100644 index 00000000000..5e3d3dbd7bd --- /dev/null +++ b/api/addons/forward/test_views.py @@ -0,0 +1,81 @@ +import mock +import pytest + +from addons.forward.tests.utils import ForwardAddonTestCase +from tests.base import OsfTestCase +from website import settings +from tests.json_api_test_app import JSONAPITestApp + +pytestmark = pytest.mark.django_db + +class TestForward(ForwardAddonTestCase, OsfTestCase): + """ + Forward (the redirect url has two v2 routes, one is addon based `/v2/nodes/{}/addons/forward/` one is node settings + based `/v2/nodes/{}/settings/` they both need to be checked for spam each time they are used to modify a redirect url. + """ + + django_app = JSONAPITestApp() + + def setUp(self): + super(TestForward, self).setUp() + self.app.authenticate(*self.user.auth) + + @mock.patch.object(settings, 'SPAM_CHECK_ENABLED', True) + @mock.patch('osf.models.node.Node.do_check_spam') + def test_change_url_check_spam(self, mock_check_spam): + self.project.is_public = True + self.project.save() + self.django_app.put_json_api( + '/v2/nodes/{}/addons/forward/'.format(self.project._id), + {'data': {'attributes': {'url': 'http://possiblyspam.com'}}}, + auth=self.user.auth, + ) + + assert mock_check_spam.called + data, _ = mock_check_spam.call_args + author, author_email, content, request_headers = data + + assert author == self.user.fullname + assert author_email == self.user.username + assert content == 'http://possiblyspam.com' + + @mock.patch.object(settings, 'SPAM_CHECK_ENABLED', True) + @mock.patch('osf.models.node.Node.do_check_spam') + def test_change_url_check_spam_node_settings(self, mock_check_spam): + self.project.is_public = True + self.project.save() + + payload = { + 'data': { + 'type': 'node-settings', + 'attributes': { + 'access_requests_enabled': False, + 'redirect_link_url': 'http://possiblyspam.com', + }, + }, + } + + self.django_app.put_json_api( + '/v2/nodes/{}/settings/'.format(self.project._id), + payload, + auth=self.user.auth, + ) + + assert mock_check_spam.called + data, _ = mock_check_spam.call_args + author, author_email, content, request_headers = data + + assert author == self.user.fullname + assert author_email == self.user.username + assert content == 'http://possiblyspam.com' + + def test_invalid_url(self): + res = self.django_app.put_json_api( + '/v2/nodes/{}/addons/forward/'.format(self.project._id), + {'data': {'attributes': {'url': 'bad url'}}}, + auth=self.user.auth, expect_errors=True, + ) + assert res.status_code == 400 + error = res.json['errors'][0] + + assert error['detail'] == 'Enter a valid URL.' diff --git a/api/nodes/serializers.py b/api/nodes/serializers.py index fb585f31263..cf5c9cecf7c 100644 --- a/api/nodes/serializers.py +++ b/api/nodes/serializers.py @@ -897,7 +897,7 @@ class Meta: # Forward-specific label = ser.CharField(required=False, allow_blank=True) - url = ser.CharField(required=False, allow_blank=True) + url = ser.URLField(required=False, allow_blank=True) links = LinksField({ 'self': 'get_absolute_url', @@ -923,7 +923,9 @@ def create(self, validated_data): class ForwardNodeAddonSettingsSerializer(NodeAddonSettingsSerializerBase): def update(self, instance, validated_data): - auth = Auth(self.context['request'].user) + request = self.context['request'] + user = request.user + auth = Auth(user) set_url = 'url' in validated_data set_label = 'label' in validated_data @@ -953,7 +955,10 @@ def update(self, instance, validated_data): instance.label = label url_changed = True - instance.save() + try: + instance.save(request=request) + except ValidationError as e: + raise exceptions.ValidationError(detail=str(e)) if url_changed: # add log here because forward architecture isn't great @@ -968,7 +973,6 @@ def update(self, instance, validated_data): auth=auth, save=True, ) - return instance @@ -1805,7 +1809,10 @@ def update_forward_fields(self, obj, validated_data, auth): save_forward = True if save_forward: - forward_addon.save() + try: + forward_addon.save(request=self.context['request']) + except ValidationError as e: + raise exceptions.ValidationError(detail=str(e)) def enable_or_disable_addon(self, obj, should_enable, addon_name, auth): """ diff --git a/api/users/serializers.py b/api/users/serializers.py index e0f8a314e9e..a7beacd096f 100644 --- a/api/users/serializers.py +++ b/api/users/serializers.py @@ -20,9 +20,8 @@ from osf.exceptions import ValidationValueError, ValidationError, BlacklistedEmailError from osf.models import OSFUser, QuickFilesNode, Preprint from osf.utils.requests import string_type_request_headers -from website.settings import MAILCHIMP_GENERAL_LIST, OSF_HELP_LIST, CONFIRM_REGISTRATIONS_BY_EMAIL, OSF_SUPPORT_EMAIL +from website.settings import MAILCHIMP_GENERAL_LIST, OSF_HELP_LIST, CONFIRM_REGISTRATIONS_BY_EMAIL from osf.models.provider import AbstractProviderGroupObjectPermission -from website import mails from website.profile.views import update_osf_help_mails_subscription, update_mailchimp_subscription from api.nodes.serializers import NodeSerializer, RegionRelationshipField from api.base.schemas.utils import validate_user_json, from_json @@ -428,6 +427,7 @@ class UserSettingsSerializer(JSONAPISerializer): subscribe_osf_general_email = ser.SerializerMethodField() subscribe_osf_help_email = ser.SerializerMethodField() deactivation_requested = ser.BooleanField(source='requested_deactivation', required=False) + contacted_deactivation = ser.BooleanField(required=False, read_only=True) secret = ser.SerializerMethodField(read_only=True) def to_representation(self, instance): @@ -526,18 +526,12 @@ def verify_two_factor(self, instance, value, two_factor_addon): two_factor_addon.save() def request_deactivation(self, instance, requested_deactivation): + if instance.requested_deactivation != requested_deactivation: - if requested_deactivation: - mails.send_mail( - to_addr=OSF_SUPPORT_EMAIL, - mail=mails.REQUEST_DEACTIVATION, - user=instance, - can_change_preferences=False, - ) - instance.email_last_sent = timezone.now() instance.requested_deactivation = requested_deactivation + if not requested_deactivation: + instance.contacted_deactivation = False instance.save() - return def to_representation(self, instance): """ diff --git a/api_tests/nodes/views/test_node_detail.py b/api_tests/nodes/views/test_node_detail.py index 6c3f286ba67..a7fb3d3d5c9 100644 --- a/api_tests/nodes/views/test_node_detail.py +++ b/api_tests/nodes/views/test_node_detail.py @@ -1348,8 +1348,7 @@ def test_set_node_private_updates_doi( assert res.status_code == 200 project_public.reload() assert not project_public.is_public - mock_update_doi_metadata.assert_called_with( - project_public._id, status='unavailable') + mock_update_doi_metadata.assert_called_with(project_public._id) @pytest.mark.enable_enqueue_task @mock.patch('website.preprints.tasks.update_or_enqueue_on_preprint_updated') diff --git a/api_tests/nodes/views/test_node_wiki_list.py b/api_tests/nodes/views/test_node_wiki_list.py index 7167aef45b7..27c3074813b 100644 --- a/api_tests/nodes/views/test_node_wiki_list.py +++ b/api_tests/nodes/views/test_node_wiki_list.py @@ -338,7 +338,7 @@ def test_create_public_wiki_page(self, app, user_write_contributor, url_node_pub assert res.json['data']['attributes']['name'] == page_name def test_create_public_wiki_page_with_content(self, app, user_write_contributor, url_node_public, project_public): - page_name = fake.word() + page_name = 'using random variables in tests can sometimes expose Testmon problems!' payload = create_wiki_payload(page_name) payload['data']['attributes']['content'] = 'my first wiki page' res = app.post_json_api(url_node_public, payload, auth=user_write_contributor.auth) diff --git a/api_tests/users/views/test_user_settings_detail.py b/api_tests/users/views/test_user_settings_detail.py index e4dd06d46d9..d0433037a41 100644 --- a/api_tests/users/views/test_user_settings_detail.py +++ b/api_tests/users/views/test_user_settings_detail.py @@ -250,31 +250,24 @@ def test_patch_requested_deactivation(self, mock_mail, app, user_one, user_two, assert res.status_code == 403 # Logged in, request to deactivate - assert user_one.email_last_sent is None assert user_one.requested_deactivation is False res = app.patch_json_api(url, payload, auth=user_one.auth) assert res.status_code == 200 user_one.reload() - assert user_one.email_last_sent is not None assert user_one.requested_deactivation is True - assert mock_mail.call_count == 1 # Logged in, deactivation already requested res = app.patch_json_api(url, payload, auth=user_one.auth) assert res.status_code == 200 user_one.reload() - assert user_one.email_last_sent is not None assert user_one.requested_deactivation is True - assert mock_mail.call_count == 1 # Logged in, request to cancel deactivate request payload['data']['attributes']['deactivation_requested'] = False res = app.patch_json_api(url, payload, auth=user_one.auth) assert res.status_code == 200 user_one.reload() - assert user_one.email_last_sent is not None assert user_one.requested_deactivation is False - assert mock_mail.call_count == 1 @mock.patch('framework.auth.views.mails.send_mail') def test_patch_invalid_type(self, mock_mail, app, user_one, url, payload): diff --git a/osf/management/commands/deactivate_requested_accounts.py b/osf/management/commands/deactivate_requested_accounts.py new file mode 100644 index 00000000000..25ef179985b --- /dev/null +++ b/osf/management/commands/deactivate_requested_accounts.py @@ -0,0 +1,81 @@ +import logging + +from website import mails +from django.utils import timezone + +from framework.celery_tasks import app as celery_app +from website.app import setup_django +setup_django() +from osf.models import OSFUser +from website.settings import OSF_SUPPORT_EMAIL, OSF_CONTACT_EMAIL +from django.core.management.base import BaseCommand + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + + +def deactivate_requested_accounts(dry_run=True): + users = OSFUser.objects.filter(requested_deactivation=True, contacted_deactivation=False, date_disabled__isnull=True) + + for user in users: + if user.has_resources: + logger.info('OSF support is being emailed about deactivating the account of user {}.'.format(user._id)) + if not dry_run: + mails.send_mail( + to_addr=OSF_SUPPORT_EMAIL, + mail=mails.REQUEST_DEACTIVATION, + user=user, + can_change_preferences=False, + ) + else: + logger.info('Disabling user {}.'.format(user._id)) + if not dry_run: + user.disable_account() + user.is_registered = False + mails.send_mail( + to_addr=user.username, + mail=mails.REQUEST_DEACTIVATION_COMPLETE, + user=user, + contact_email=OSF_CONTACT_EMAIL, + can_change_preferences=False, + ) + + user.contacted_deactivation = True + user.email_last_sent = timezone.now() + if not dry_run: + user.save() + + if dry_run: + logger.info('Dry run complete') + + +@celery_app.task(name='management.commands.deactivate_requested_accounts') +def main(dry_run=False): + """ + This task runs nightly and emails users who want to delete there account with info on how to do so. Users who don't + have any content can be automatically deleted. + """ + if dry_run: + logger.info('This is a dry run; no changes will be saved, and no emails will be sent.') + deactivate_requested_accounts(dry_run=dry_run) + + +class Command(BaseCommand): + help = ''' + If there are any users who want to be deactivated we will either: immediately deactivate, or if they have active + resources (undeleted nodes, preprints etc) we contact admin to guide the user through the deactivation process. + ''' + + def add_arguments(self, parser): + super(Command, self).add_arguments(parser) + parser.add_argument( + '--dry', + action='store_true', + dest='dry_run', + help='Dry run', + ) + + # Management command handler + def handle(self, *args, **options): + dry_run = options.get('dry_run', True) + main(dry_run=dry_run) diff --git a/osf/migrations/0181_osfuser_contacted_deactivation.py b/osf/migrations/0181_osfuser_contacted_deactivation.py new file mode 100644 index 00000000000..046f2fe968b --- /dev/null +++ b/osf/migrations/0181_osfuser_contacted_deactivation.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-06-13 15:32 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0180_finalize_token_scopes_mig'), + ] + + operations = [ + migrations.AddField( + model_name='osfuser', + name='contacted_deactivation', + field=models.BooleanField(default=False), + ), + ] diff --git a/osf/models/identifiers.py b/osf/models/identifiers.py index e6149452353..5d871a74c0d 100644 --- a/osf/models/identifiers.py +++ b/osf/models/identifiers.py @@ -51,10 +51,10 @@ def request_identifier(self, category): if client: return client.create_identifier(self, category) - def request_identifier_update(self, category, status=None): + def request_identifier_update(self, category): client = self.get_doi_client() if client: - return client.update_identifier(self, category, status=status) + return client.update_identifier(self, category) def get_identifier(self, category): """Returns None of no identifier matches""" diff --git a/osf/models/mixins.py b/osf/models/mixins.py index 8fd6a9b30ac..3f06ba6da20 100644 --- a/osf/models/mixins.py +++ b/osf/models/mixins.py @@ -1656,10 +1656,21 @@ def confirm_spam(self, save=False): self.save() def _get_spam_content(self, saved_fields): + """ + This function retrieves retrieves strings of potential spam from various DB fields. Also here we can follow + django's typical ORM query structure for example we can grab the redirect link of a node by giving a saved + field of {'addons_forward_node_settings__url'}. + + :param saved_fields: set + :return: str + """ spam_fields = self.get_spam_fields(saved_fields) content = [] for field in spam_fields: - content.append((getattr(self, field, None) or '').encode('utf-8')) + exclude_null = {field + '__isnull': False} + values = list(self.__class__.objects.filter(id=self.id, **exclude_null).values_list(field, flat=True)) + if values: + content.append((' '.join(values) or '').encode('utf-8')) if self.all_tags.exists(): content.extend([name.encode('utf-8') for name in self.all_tags.values_list('name', flat=True)]) if not content: diff --git a/osf/models/node.py b/osf/models/node.py index 4dc8742cc3e..0473195bb2f 100644 --- a/osf/models/node.py +++ b/osf/models/node.py @@ -282,6 +282,7 @@ class AbstractNode(DirtyFieldsMixin, TypedModel, AddonModelMixin, IdentifierMixi SPAM_CHECK_FIELDS = { 'title', 'description', + 'addons_forward_node_settings__url' # the often spammed redirect URL } # Fields that are writable by Node.update @@ -1314,8 +1315,7 @@ def set_privacy(self, permissions, auth=None, log=True, save=True, meeting_creat # Update existing identifiers if self.get_identifier('doi'): - doi_status = 'unavailable' if permissions == 'private' else 'public' - enqueue_task(update_doi_metadata_on_change.s(self._id, status=doi_status)) + enqueue_task(update_doi_metadata_on_change.s(self._id)) if log: action = NodeLog.MADE_PUBLIC if permissions == 'public' else NodeLog.MADE_PRIVATE diff --git a/osf/models/user.py b/osf/models/user.py index 3061ddf7e24..9bce511537d 100644 --- a/osf/models/user.py +++ b/osf/models/user.py @@ -377,6 +377,10 @@ class OSFUser(DirtyFieldsMixin, GuidMixin, BaseModel, AbstractBaseUser, Permissi # whether the user has requested to deactivate their account requested_deactivation = models.BooleanField(default=False) + # whether the user has who requested deactivation has been contacted about their pending request. This is reset when + # requests are canceled + contacted_deactivation = models.BooleanField(default=False) + affiliated_institutions = models.ManyToManyField('Institution', blank=True) notifications_configured = DateTimeAwareJSONField(default=dict, blank=True) @@ -1804,6 +1808,26 @@ def gdpr_delete(self): self.external_identity = {} self.deleted = timezone.now() + @property + def has_resources(self): + """ + This is meant to determine if a user has any resources, nodes, preprints etc that might impede their deactivation. + If a user only has no resources or only deleted resources this will return false and they can safely be deactivated + otherwise they must delete or transfer their outstanding resources. + + :return bool: does the user have any active node, preprints, groups, quickfiles etc? + """ + from osf.models import Preprint + + # TODO: Update once quickfolders in merged + + nodes = self.nodes.exclude(type='osf.quickfilesnode').exclude(is_deleted=True).exists() + quickfiles = self.nodes.get(type='osf.quickfilesnode').files.exists() + groups = self.osf_groups.exists() + preprints = Preprint.objects.filter(_contributors=self, ever_public=True, deleted__isnull=True).exists() + + return groups or nodes or quickfiles or preprints + class Meta: # custom permissions for use in the OSF Admin App permissions = ( diff --git a/osf_tests/factories.py b/osf_tests/factories.py index 61f27c21697..b8499447e31 100644 --- a/osf_tests/factories.py +++ b/osf_tests/factories.py @@ -21,7 +21,6 @@ from website.notifications.constants import NOTIFICATION_TYPES from osf.utils import permissions from website.archiver import ARCHIVER_SUCCESS -from website.identifiers.utils import parse_identifiers from website.settings import FAKE_EMAIL_NAME, FAKE_EMAIL_DOMAIN from framework.auth.core import Auth @@ -601,23 +600,9 @@ def _create(cls, target_class, *args, **kwargs): def sync_set_identifiers(preprint): - from website.identifiers.clients import EzidClient from website import settings - client = preprint.get_doi_client() - - if isinstance(client, EzidClient): - doi_value = settings.DOI_FORMAT.format(prefix=settings.EZID_DOI_NAMESPACE, guid=preprint._id) - ark_value = '{ark}osf.io/{guid}'.format(ark=settings.EZID_ARK_NAMESPACE, guid=preprint._id) - return_value = {'success': '{} | {}'.format(doi_value, ark_value)} - else: - return_value = {'doi': settings.DOI_FORMAT.format(prefix=preprint.provider.doi_prefix, guid=preprint._id)} - - doi_client_return_value = { - 'response': return_value, - 'already_exists': False - } - id_dict = parse_identifiers(doi_client_return_value) - preprint.set_identifier_values(doi=id_dict['doi']) + doi = settings.DOI_FORMAT.format(prefix=preprint.provider.doi_prefix, guid=preprint._id) + preprint.set_identifier_values(doi=doi) class PreprintFactory(DjangoModelFactory): diff --git a/package.json b/package.json index c5ea698507d..c5f42c3ccf2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "OSF", - "version": "19.24.0", + "version": "19.25.0", "description": "Facilitating Open Science", "repository": "https://github.com/CenterForOpenScience/osf.io", "author": "Center for Open Science", diff --git a/scripts/create_fakes.py b/scripts/create_fakes.py index f010da5a61d..9ec3cb6498b 100644 --- a/scripts/create_fakes.py +++ b/scripts/create_fakes.py @@ -311,8 +311,6 @@ def create_fake_project(creator, n_users, privacy, n_components, name, n_tags, p if not provider: provider = PreprintProviderFactory(name=fake.science_word()) privacy = 'public' - mock_change_identifier = mock.patch('website.identifiers.client.EzidClient.update_identifier') - mock_change_identifier.start() mock_change_identifier_preprints = mock.patch('website.identifiers.client.CrossRefClient.update_identifier') mock_change_identifier_preprints.start() project = PreprintFactory(title=project_title, description=fake.science_paragraph(), creator=creator, provider=provider) diff --git a/scripts/tests/test_deactivate_requested_accounts.py b/scripts/tests/test_deactivate_requested_accounts.py new file mode 100644 index 00000000000..300f9ea5570 --- /dev/null +++ b/scripts/tests/test_deactivate_requested_accounts.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- + +import pytest +import mock + +from nose.tools import * # noqa + +from osf_tests.factories import ProjectFactory, AuthUserFactory + +from osf.management.commands.deactivate_requested_accounts import deactivate_requested_accounts + +from website import mails, settings + +@pytest.mark.django_db +@pytest.mark.enable_quickfiles_creation +class TestDeactivateRequestedAccount: + + @pytest.fixture() + def user_requested_deactivation(self): + user = AuthUserFactory(requested_deactivation=True) + user.requested_deactivation = True + user.save() + return user + + @pytest.fixture() + def user_requested_deactivation_with_node(self): + user = AuthUserFactory(requested_deactivation=True) + node = ProjectFactory(creator=user) + node.save() + user.save() + return user + + @mock.patch('osf.management.commands.deactivate_requested_accounts.mails.send_mail') + def test_deactivate_user_with_no_content(self, mock_mail, user_requested_deactivation): + + deactivate_requested_accounts(dry_run=False) + user_requested_deactivation.reload() + + assert user_requested_deactivation.requested_deactivation + assert user_requested_deactivation.contacted_deactivation + assert user_requested_deactivation.is_disabled + mock_mail.assert_called_with(can_change_preferences=False, + mail=mails.REQUEST_DEACTIVATION_COMPLETE, + to_addr=user_requested_deactivation.username, + contact_email=settings.OSF_CONTACT_EMAIL, + user=user_requested_deactivation) + + @mock.patch('osf.management.commands.deactivate_requested_accounts.mails.send_mail') + def test_deactivate_user_with_content(self, mock_mail, user_requested_deactivation_with_node): + + deactivate_requested_accounts(dry_run=False) + user_requested_deactivation_with_node.reload() + + assert user_requested_deactivation_with_node.requested_deactivation + assert not user_requested_deactivation_with_node.is_disabled + mock_mail.assert_called_with(can_change_preferences=False, + mail=mails.REQUEST_DEACTIVATION, + to_addr=settings.OSF_SUPPORT_EMAIL, + user=user_requested_deactivation_with_node) + diff --git a/tests/identifiers/test_datacite.py b/tests/identifiers/test_datacite.py index 7da7cc49cb1..18a9dffbe1b 100644 --- a/tests/identifiers/test_datacite.py +++ b/tests/identifiers/test_datacite.py @@ -11,10 +11,7 @@ from framework.auth import Auth from website import settings -from website.app import init_addons from website.identifiers.clients import DataCiteClient -from website.identifiers.clients.datacite import DataCiteMDSClient -from website.identifiers import metadata from website.identifiers.utils import request_identifiers from tests.base import OsfTestCase diff --git a/tests/identifiers/test_ezid.py b/tests/identifiers/test_ezid.py deleted file mode 100644 index 8ef0f1f3bb8..00000000000 --- a/tests/identifiers/test_ezid.py +++ /dev/null @@ -1,134 +0,0 @@ -import furl -import mock -import pytest -import responses -from waffle.testutils import override_switch -from nose.tools import * # noqa - -from tests.base import OsfTestCase -from tests.test_addons import assert_urls_equal -from osf_tests.factories import AuthUserFactory, RegistrationFactory - -from website import settings -from website.app import init_addons -from website.identifiers.utils import to_anvl -from website.identifiers.clients import EzidClient - - -@pytest.mark.django_db -class TestEZIDClient(OsfTestCase): - - def setUp(self): - super(TestEZIDClient, self).setUp() - self.user = AuthUserFactory() - self.registration = RegistrationFactory(creator=self.user, is_public=True) - self.client = EzidClient(base_url='https://test.ezid.osf.io', prefix=settings.EZID_DOI_NAMESPACE.replace('doi:', '')) - - @override_switch('ezid', active=True) - @responses.activate - def test_create_identifiers_not_exists_ezid(self): - guid = self.registration._id - url = furl.furl(self.client.base_url) - doi = settings.DOI_FORMAT.format(prefix=settings.EZID_DOI_NAMESPACE, guid=guid).replace('doi:', '') - url.path.segments += ['id', doi] - responses.add( - responses.Response( - responses.PUT, - url.url, - body=to_anvl({ - 'success': '{doi}osf.io/{ident} | {ark}osf.io/{ident}'.format( - doi=settings.EZID_DOI_NAMESPACE, - ark=settings.EZID_ARK_NAMESPACE, - ident=guid, - ), - }), - status=201, - ) - ) - with mock.patch('osf.models.Registration.get_doi_client') as mock_get_doi: - mock_get_doi.return_value = self.client - res = self.app.post( - self.registration.api_url_for('node_identifiers_post'), - auth=self.user.auth, - ) - self.registration.reload() - assert_equal( - res.json['doi'], - self.registration.get_identifier_value('doi') - ) - - assert_equal(res.status_code, 201) - - @override_switch('ezid', active=True) - @responses.activate - def test_create_identifiers_exists_ezid(self): - guid = self.registration._id - doi = settings.DOI_FORMAT.format(prefix=settings.EZID_DOI_NAMESPACE, guid=guid).replace('doi:', '') - url = furl.furl(self.client.base_url) - url.path.segments += ['id', doi] - responses.add( - responses.Response( - responses.PUT, - url.url, - body='identifier already exists', - status=400, - ) - ) - responses.add( - responses.Response( - responses.GET, - url.url, - body=to_anvl({ - 'success': doi, - }), - status=200, - ) - ) - with mock.patch('osf.models.Registration.get_doi_client') as mock_get_doi: - mock_get_doi.return_value = self.client - res = self.app.post( - self.registration.api_url_for('node_identifiers_post'), - auth=self.user.auth, - ) - self.registration.reload() - assert_equal( - res.json['doi'], - self.registration.get_identifier_value('doi') - ) - assert_equal(res.status_code, 201) - - @responses.activate - def test_get_by_identifier(self): - self.registration.set_identifier_value('doi', 'FK424601') - self.registration.set_identifier_value('ark', 'fk224601') - res_doi = self.app.get( - self.registration.web_url_for( - 'get_referent_by_identifier', - category='doi', - value=self.registration.get_identifier_value('doi'), - ), - ) - assert_equal(res_doi.status_code, 302) - assert_urls_equal(res_doi.headers['Location'], self.registration.absolute_url) - res_ark = self.app.get( - self.registration.web_url_for( - 'get_referent_by_identifier', - category='ark', - value=self.registration.get_identifier_value('ark'), - ), - ) - assert_equal(res_ark.status_code, 302) - assert_urls_equal(res_ark.headers['Location'], self.registration.absolute_url) - - @responses.activate - def test_get_by_identifier_not_found(self): - self.registration.set_identifier_value('doi', 'FK424601') - res = self.app.get( - self.registration.web_url_for( - 'get_referent_by_identifier', - category='doi', - value='fakedoi', - ), - expect_errors=True, - ) - assert_equal(res.status_code, 404) diff --git a/tests/identifiers/test_identifiers.py b/tests/identifiers/test_identifiers.py index 5b47f0e95f2..edf4339dc11 100644 --- a/tests/identifiers/test_identifiers.py +++ b/tests/identifiers/test_identifiers.py @@ -2,130 +2,15 @@ from nose.tools import * # noqa from django.db import IntegrityError -from waffle.testutils import override_switch from osf_tests.factories import ( - SubjectFactory, - AuthUserFactory, - PreprintFactory, IdentifierFactory, RegistrationFactory, - PreprintProviderFactory ) from tests.base import OsfTestCase -import lxml.etree - -from website import settings -from website.identifiers import metadata -from osf.models import Identifier, Subject, NodeLicense - - -class TestMetadataGeneration(OsfTestCase): - - def setUp(self): - OsfTestCase.setUp(self) - self.visible_contrib = AuthUserFactory() - visible_contrib2 = AuthUserFactory(given_name=u'ヽ༼ ಠ益ಠ ༽ノ', family_name=u'ლ(´◉❥◉`ლ)') - self.invisible_contrib = AuthUserFactory() - self.node = RegistrationFactory(is_public=True) - self.identifier = Identifier(referent=self.node, category='catid', value='cat:7') - self.node.add_contributor(self.visible_contrib, visible=True) - self.node.add_contributor(self.invisible_contrib, visible=False) - self.node.add_contributor(visible_contrib2, visible=True) - self.node.save() - - # This test is not used as datacite is currently used for nodes, leaving here for future reference - def test_datacite_metadata_for_preprint_has_correct_structure(self): - provider = PreprintProviderFactory() - license = NodeLicense.objects.get(name='CC-By Attribution 4.0 International') - license_details = { - 'id': license.license_id, - 'year': '2017', - 'copyrightHolders': ['Jeff Hardy', 'Matt Hardy'] - } - preprint = PreprintFactory(provider=provider, project=self.node, is_published=True, license_details=license_details) - metadata_xml = metadata.datacite_metadata_for_preprint(preprint, doi=preprint.get_identifier('doi').value, pretty_print=True) - - root = lxml.etree.fromstring(metadata_xml) - xsi_location = '{http://www.w3.org/2001/XMLSchema-instance}schemaLocation' - expected_location = 'http://datacite.org/schema/kernel-4 http://schema.datacite.org/meta/kernel-4/metadata.xsd' - assert root.attrib[xsi_location] == expected_location - - identifier = root.find('{%s}identifier' % metadata.NAMESPACE) - assert identifier.attrib['identifierType'] == 'DOI' - assert identifier.text == preprint.get_identifier('doi').value - - creators = root.find('{%s}creators' % metadata.NAMESPACE) - assert len(creators.getchildren()) == len(preprint.visible_contributors) - - subjects = root.find('{%s}subjects' % metadata.NAMESPACE) - assert subjects.getchildren() - - publisher = root.find('{%s}publisher' % metadata.NAMESPACE) - assert publisher.text == provider.name - - pub_year = root.find('{%s}publicationYear' % metadata.NAMESPACE) - assert pub_year.text == str(preprint.date_published.year) - - dates = root.find('{%s}dates' % metadata.NAMESPACE).getchildren()[0] - assert dates.text == preprint.modified.isoformat() - assert dates.attrib['dateType'] == 'Updated' - - alternate_identifier = root.find('{%s}alternateIdentifiers' % metadata.NAMESPACE).getchildren()[0] - assert alternate_identifier.text == settings.DOMAIN + preprint._id - assert alternate_identifier.attrib['alternateIdentifierType'] == 'URL' - - descriptions = root.find('{%s}descriptions' % metadata.NAMESPACE).getchildren()[0] - assert descriptions.text == preprint.description - - rights = root.find('{%s}rightsList' % metadata.NAMESPACE).getchildren()[0] - assert rights.text == preprint.license.name - - # This test is not used as datacite is currently used for nodes, leaving here for future reference - def test_datacite_format_creators_for_preprint(self): - preprint = PreprintFactory(project=self.node, is_published=True) - - verified_user = AuthUserFactory(external_identity={'ORCID': {'1234-1234-1234-1234': 'VERIFIED'}}) - linked_user = AuthUserFactory(external_identity={'ORCID': {'1234-nope-1234-nope': 'LINK'}}) - preprint.add_contributor(verified_user, visible=True) - preprint.add_contributor(linked_user, visible=True) - preprint.save() - - formatted_creators = metadata.format_creators(preprint) - - contributors_with_orcids = 0 - guid_identifiers = [] - for creator_xml in formatted_creators: - assert creator_xml.find('creatorName').text != u'{}, {}'.format(self.invisible_contrib.family_name, self.invisible_contrib.given_name) - - name_identifiers = creator_xml.findall('nameIdentifier') - - for name_identifier in name_identifiers: - if name_identifier.attrib['nameIdentifierScheme'] == 'ORCID': - assert name_identifier.attrib['schemeURI'] == 'http://orcid.org/' - contributors_with_orcids += 1 - else: - guid_identifiers.append(name_identifier.text) - assert name_identifier.attrib['nameIdentifierScheme'] == 'OSF' - assert name_identifier.attrib['schemeURI'] == settings.DOMAIN - - assert contributors_with_orcids >= 1 - assert len(formatted_creators) == len(self.node.visible_contributors) - assert sorted(guid_identifiers) == sorted([contrib.absolute_url for contrib in preprint.visible_contributors]) - - # This test is not used as datacite is currently used for nodes, leaving here for future reference - def test_datacite_format_subjects_for_preprint(self): - subject = SubjectFactory() - subject_1 = SubjectFactory(parent=subject) - subject_2 = SubjectFactory(parent=subject) - - subjects = [[subject._id, subject_1._id], [subject._id, subject_2._id]] - preprint = PreprintFactory(subjects=subjects, project=self.node, is_published=True) - - formatted_subjects = metadata.format_subjects(preprint) - assert len(formatted_subjects) == Subject.objects.all().count() +from osf.models import Identifier class TestIdentifierModel(OsfTestCase): diff --git a/tests/test_views.py b/tests/test_views.py index db884bcd5c4..402a74a9b4b 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1802,15 +1802,6 @@ def test_user_cannot_request_account_export_before_throttle_expires(self, send_m assert_equal(res.status_code, 400) assert_equal(send_mail.call_count, 1) - @mock.patch('framework.auth.views.mails.send_mail') - def test_user_cannot_request_account_deactivation_before_throttle_expires(self, send_mail): - url = api_url_for('request_deactivation') - self.app.post(url, auth=self.user.auth) - assert_true(send_mail.called) - res = self.app.post(url, auth=self.user.auth, expect_errors=True) - assert_equal(res.status_code, 400) - assert_equal(send_mail.call_count, 1) - def test_get_unconfirmed_emails_exclude_external_identity(self): external_identity = { 'service': { diff --git a/website/identifiers/clients/__init__.py b/website/identifiers/clients/__init__.py index 60f1d0ec489..6dd707891fc 100644 --- a/website/identifiers/clients/__init__.py +++ b/website/identifiers/clients/__init__.py @@ -1,3 +1,2 @@ from .crossref import CrossRefClient, ECSArXivCrossRefClient # noqa from .datacite import DataCiteClient # noqa -from .ezid import EzidClient # noqa diff --git a/website/identifiers/clients/crossref.py b/website/identifiers/clients/crossref.py index a1d65e98496..da8bc00bb59 100644 --- a/website/identifiers/clients/crossref.py +++ b/website/identifiers/clients/crossref.py @@ -8,7 +8,7 @@ from django.db.models import QuerySet from framework.auth.utils import impute_names -from website.identifiers.metadata import remove_control_characters +from website.identifiers.utils import remove_control_characters from website.identifiers.clients.base import AbstractIdentifierClient from website import settings @@ -214,9 +214,9 @@ def _build_url(self, **query): url.args.update(query) return url.url - def create_identifier(self, preprint, category, status=None, include_relation=True): - if status is None: - status = self.get_status(preprint) + def create_identifier(self, preprint, category, include_relation=True): + status = self.get_status(preprint) + if category == 'doi': metadata = self.build_metadata(preprint, status, include_relation) doi = self.build_doi(preprint) @@ -241,8 +241,8 @@ def create_identifier(self, preprint, category, status=None, include_relation=Tr else: raise NotImplementedError() - def update_identifier(self, preprint, category, status=None): - return self.create_identifier(preprint, category, status) + def update_identifier(self, preprint, category): + return self.create_identifier(preprint, category) def get_status(self, preprint): return 'public' if preprint.verified_publishable and not preprint.is_retracted else 'unavailable' diff --git a/website/identifiers/clients/datacite.py b/website/identifiers/clients/datacite.py index 09376c10fb2..1d2cb917bef 100644 --- a/website/identifiers/clients/datacite.py +++ b/website/identifiers/clients/datacite.py @@ -85,7 +85,7 @@ def create_identifier(self, node, category): else: raise NotImplementedError('Creating an identifier with category {} is not supported'.format(category)) - def update_identifier(self, node, category, status=None): + def update_identifier(self, node, category): if not node.is_public or node.is_deleted: if category == 'doi': doi = self.build_doi(node) diff --git a/website/identifiers/clients/ezid.py b/website/identifiers/clients/ezid.py deleted file mode 100644 index fe9c38655a4..00000000000 --- a/website/identifiers/clients/ezid.py +++ /dev/null @@ -1,80 +0,0 @@ -# -*- coding: utf-8 -*- -import logging -import furl -import requests - -from website import settings -from website.identifiers import utils -from website.util.client import BaseClient -from website.identifiers.clients import DataCiteClient, exceptions - -logger = logging.getLogger(__name__) - - -class EzidClient(BaseClient, DataCiteClient): - """Inherits _make_request from BaseClient""" - - def _build_url(self, *segments, **query): - url = furl.furl(self.base_url) - url.path.segments.extend(segments) - url.args.update(query) - return url.url - - @property - def _default_headers(self): - return {'Content-Type': 'text/plain; charset=UTF-8'} - - def build_doi(self, object): - return settings.DOI_FORMAT.format(prefix=self.prefix, guid=object._id) - - def get_identifier(self, identifier): - resp = self._make_request( - 'GET', - self._build_url('id', identifier), - expects=(200, ), - ) - return utils.from_anvl(resp.content.strip('\n')) - - def create_identifier(self, object, category): - if category in ['doi', 'ark']: - metadata = self.build_metadata(object) - doi = self.build_doi(object) - resp = requests.request( - 'PUT', - self._build_url('id', doi), - data=utils.to_anvl(metadata or {}), - ) - if resp.status_code != 201: - if 'identifier already exists' in resp.content: - raise exceptions.IdentifierAlreadyExists() - else: - raise exceptions.ClientResponseError(resp) - resp = utils.from_anvl(resp.content) - return dict( - [each.strip('/') for each in pair.strip().split(':')] - for pair in resp['success'].split('|') - ) - else: - raise NotImplementedError('Create identifier method is not supported for category {}'.format(category)) - - def update_identifier(self, object, category): - metadata = self.build_metadata(object) - status = self.get_status(object) - metadata['_status'] = status - identifier = self.build_doi(object) - resp = self._make_request( - 'POST', - self._build_url('id', identifier), - data=utils.to_anvl(metadata or {}), - expects=(200, ), - ) - return utils.from_anvl(resp.content) - - def get_status(self, object): - from osf.models import Preprint - - if isinstance(object, Preprint): - status = 'public' if object.verified_publishable else 'unavailable' - else: - status = 'public' if object.is_public or not object.is_deleted else 'unavailable' - return status diff --git a/website/identifiers/listeners.py b/website/identifiers/listeners.py index 6d3307d7dde..b0acb6efb06 100644 --- a/website/identifiers/listeners.py +++ b/website/identifiers/listeners.py @@ -7,4 +7,4 @@ def update_status_on_delete(node): from website.identifiers.tasks import update_doi_metadata_on_change if node.get_identifier('doi'): - enqueue_task(update_doi_metadata_on_change.s(node._id, status='unavailable')) + enqueue_task(update_doi_metadata_on_change.s(node._id)) diff --git a/website/identifiers/metadata.py b/website/identifiers/metadata.py deleted file mode 100644 index 2bfa4623321..00000000000 --- a/website/identifiers/metadata.py +++ /dev/null @@ -1,127 +0,0 @@ -# -*- coding: utf-8 -*- -import unicodedata -import lxml.etree -import lxml.builder - -from website import settings - -NAMESPACE = 'http://datacite.org/schema/kernel-4' -XSI = 'http://www.w3.org/2001/XMLSchema-instance' -SCHEMA_LOCATION = 'http://datacite.org/schema/kernel-4 http://schema.datacite.org/meta/kernel-4/metadata.xsd' -E = lxml.builder.ElementMaker(nsmap={ - None: NAMESPACE, - 'xsi': XSI}, -) - -CREATOR = E.creator -CREATOR_NAME = E.creatorName -SUBJECT_SCHEME = 'bepress Digital Commons Three-Tiered Taxonomy' - -# From https://stackoverflow.com/a/19016117 -# lxml does not accept strings with control characters -def remove_control_characters(s): - return ''.join(ch for ch in s if unicodedata.category(ch)[0] != 'C') - -# This function is not OSF-specific -def datacite_metadata(doi, title, creators, publisher, publication_year, pretty_print=False): - """Return the formatted datacite metadata XML as a string. - - :param str doi - :param str title - :param list creators: List of creator names, formatted like 'Shakespeare, William' - :param str publisher: Publisher name. - :param int publication_year - :param bool pretty_print - """ - creators = [CREATOR(CREATOR_NAME(each)) for each in creators] - root = E.resource( - E.resourceType('Project', resourceTypeGeneral='Text'), - E.identifier(doi, identifierType='DOI'), - E.creators(*creators), - E.titles(E.title(remove_control_characters(title))), - E.publisher(publisher), - E.publicationYear(str(publication_year)), - ) - # set xsi:schemaLocation - root.attrib['{%s}schemaLocation' % XSI] = SCHEMA_LOCATION - return lxml.etree.tostring(root, pretty_print=pretty_print) - - -def format_contributor(contributor): - return remove_control_characters(u'{}, {}'.format(contributor.family_name, contributor.given_name)) - - -# This function is OSF specific. -def datacite_metadata_for_node(node, doi, pretty_print=False): - """Return the datacite metadata XML document for a given node as a string. - - :param Node node - :param str doi - """ - creators = [format_contributor(each) for each in node.visible_contributors] - return datacite_metadata( - doi=doi, - title=node.title, - creators=creators, - publisher='Open Science Framework', - publication_year=getattr(node.registered_date or node.created, 'year'), - pretty_print=pretty_print - ) - - -def format_creators(preprint): - creators = [] - for contributor in preprint.visible_contributors: - creator = CREATOR(E.creatorName(format_contributor(contributor))) - creator.append(E.givenName(remove_control_characters(contributor.given_name))) - creator.append(E.familyName(remove_control_characters(contributor.family_name))) - creator.append(E.nameIdentifier(contributor.absolute_url, nameIdentifierScheme='OSF', schemeURI=settings.DOMAIN)) - - # contributor.external_identity = {'ORCID': {'1234-1234-1234-1234': 'VERIFIED'}} - if contributor.external_identity.get('ORCID'): - verified = contributor.external_identity['ORCID'].values()[0] == 'VERIFIED' - if verified: - creator.append(E.nameIdentifier(contributor.external_identity['ORCID'].keys()[0], nameIdentifierScheme='ORCID', schemeURI='http://orcid.org/')) - - creators.append(creator) - - return creators - - -def format_subjects(preprint): - return [E.subject(subject, subjectScheme=SUBJECT_SCHEME) for subject in preprint.subjects.values_list('text', flat=True)] - - -# This function is OSF specific. -def datacite_metadata_for_preprint(preprint, doi, pretty_print=False): - """Return the datacite metadata XML document for a given preprint as a string. - - :param preprint -- the preprint - :param str doi - """ - # NOTE: If you change *ANYTHING* here be 100% certain that the - # changes you make are also made to the SHARE serialization code. - # If the data sent out is not EXCATLY the same all the data will get jumbled up in SHARE. - # And then search results will be wrong and broken. And it will be your fault. And you'll have caused many sleepless nights. - # Don't be that person. - root = E.resource( - E.resourceType('Preprint', resourceTypeGeneral='Text'), - E.identifier(doi, identifierType='DOI'), - E.subjects(*format_subjects(preprint)), - E.creators(*format_creators(preprint)), - E.titles(E.title(remove_control_characters(preprint.title))), - E.publisher(preprint.provider.name), - E.publicationYear(str(getattr(preprint.date_published, 'year'))), - E.dates(E.date(preprint.modified.isoformat(), dateType='Updated')), - E.alternateIdentifiers(E.alternateIdentifier(settings.DOMAIN + preprint._id, alternateIdentifierType='URL')), - E.descriptions(E.description(remove_control_characters(preprint.description), descriptionType='Abstract')), - ) - - if preprint.license: - root.append(E.rightsList(E.rights(preprint.license.name))) - - if preprint.article_doi: - root.append(E.relatedIdentifiers(E.relatedIdentifier(settings.DOI_URL_PREFIX + preprint.article_doi, relatedIdentifierType='URL', relationType='IsPreviousVersionOf'))), - # set xsi:schemaLocation - root.attrib['{%s}schemaLocation' % XSI] = SCHEMA_LOCATION - return lxml.etree.tostring(root, pretty_print=pretty_print) diff --git a/website/identifiers/tasks.py b/website/identifiers/tasks.py index 1c17ed199bf..b6f5cade2f4 100644 --- a/website/identifiers/tasks.py +++ b/website/identifiers/tasks.py @@ -4,8 +4,8 @@ @celery_app.task(ignore_results=True) -def update_doi_metadata_on_change(target_guid, status): +def update_doi_metadata_on_change(target_guid): Guid = apps.get_model('osf.Guid') target_object = Guid.load(target_guid).referent if target_object.get_identifier('doi'): - target_object.request_identifier_update(category='doi', status=status) + target_object.request_identifier_update(category='doi') diff --git a/website/identifiers/utils.py b/website/identifiers/utils.py index 458c667dc97..987e3a99cbb 100644 --- a/website/identifiers/utils.py +++ b/website/identifiers/utils.py @@ -2,9 +2,9 @@ import re import logging +import unicodedata from framework.exceptions import HTTPError -from website import settings logger = logging.getLogger(__name__) @@ -28,42 +28,11 @@ def unescape(value): return re.sub(r'%[0-9A-Fa-f]{2}', decode, value) -def to_anvl(data): - if isinstance(data, dict): - return FIELD_SEPARATOR.join( - PAIR_SEPARATOR.join([escape(key), escape(to_anvl(value))]) - for key, value in data.items() - ) - return data - - -def _field_from_anvl(raw): - key, value = raw.split(PAIR_SEPARATOR) - return [unescape(key), from_anvl(unescape(value))] - - -def from_anvl(data): - if PAIR_SEPARATOR in data: - return dict([ - _field_from_anvl(pair) - for pair in data.split(FIELD_SEPARATOR) - ]) - return data - - -def merge_dicts(*dicts): - return dict(sum((each.items() for each in dicts), [])) - - def request_identifiers(target_object): """Request identifiers for the target object using the appropriate client. :param target_object: object to request identifiers for - :return: dict with keys relating to the status of the identifier - response - response from the DOI client - already_exists - the DOI has already been registered with a client - only_doi - boolean; only include the DOI (and not the ARK) identifier - when processing this response in get_or_create_identifiers + :return: dict with DOI """ from website.identifiers.clients import exceptions @@ -74,43 +43,17 @@ def request_identifiers(target_object): if not client: return doi = client.build_doi(target_object) - already_exists = False - only_doi = True try: identifiers = target_object.request_identifier(category='doi') - except exceptions.IdentifierAlreadyExists as error: + except exceptions.IdentifierAlreadyExists: identifiers = client.get_identifier(doi) - already_exists = True - only_doi = False except exceptions.ClientResponseError as error: raise HTTPError(error.response.status_code) return { - 'doi': identifiers.get('doi'), - 'already_exists': already_exists, - 'only_doi': only_doi + 'doi': identifiers.get('doi') } -def parse_identifiers(doi_client_response): - """ - Note: ARKs include a leading slash. This is stripped here to avoid multiple - consecutive slashes in internal URLs (e.g. /ids/ark//). Frontend code - that build ARK URLs is responsible for adding the leading slash. - Moved from website/project/views/register.py for use by other modules - """ - resp = doi_client_response['response'] - exists = doi_client_response.get('already_exists', None) - if exists: - doi = resp['success'] - suffix = doi.strip(settings.EZID_DOI_NAMESPACE) - return { - 'doi': doi.replace('doi:', ''), - 'ark': '{0}{1}'.format(settings.EZID_ARK_NAMESPACE.replace('ark:', ''), suffix), - } - else: - return {'doi': resp['doi']} - - def get_or_create_identifiers(target_object): """ Note: ARKs include a leading slash. This is stripped here to avoid multiple @@ -118,14 +61,18 @@ def get_or_create_identifiers(target_object): that build ARK URLs is responsible for adding the leading slash. Moved from website/project/views/register.py for use by other modules """ - response_dict = request_identifiers(target_object) - ark = target_object.get_identifier(category='ark') - doi = response_dict['doi'] + doi = request_identifiers(target_object)['doi'] if not doi: client = target_object.get_doi_client() doi = client.build_doi(target_object) - response = {'doi': doi} + + ark = target_object.get_identifier(category='ark') if ark: - response['ark'] = ark.value + return {'doi': doi, 'ark': ark} + + return {'doi': doi} - return response +# From https://stackoverflow.com/a/19016117 +# lxml does not accept strings with control characters +def remove_control_characters(s): + return ''.join(ch for ch in s if unicodedata.category(ch)[0] != 'C') diff --git a/website/mails/mails.py b/website/mails/mails.py index d51ca93a282..dd2ac56c809 100644 --- a/website/mails/mails.py +++ b/website/mails/mails.py @@ -251,6 +251,8 @@ def get_english_article(word): REQUEST_EXPORT = Mail('support_request', subject='[via OSF] Export Request') REQUEST_DEACTIVATION = Mail('support_request', subject='[via OSF] Deactivation Request') +REQUEST_DEACTIVATION_COMPLETE = Mail('request_deactivation_complete', subject='[via OSF] OSF account deactivated') + SPAM_USER_BANNED = Mail('spam_user_banned', subject='[OSF] Account flagged as spam') CONFERENCE_SUBMITTED = Mail( diff --git a/website/preprints/tasks.py b/website/preprints/tasks.py index 1b8a52f2217..debb5871a02 100644 --- a/website/preprints/tasks.py +++ b/website/preprints/tasks.py @@ -48,9 +48,8 @@ def should_update_preprint_identifiers(preprint, old_subjects, saved_fields): ) def update_or_create_preprint_identifiers(preprint): - status = 'public' if preprint.verified_publishable and not preprint.is_retracted else 'unavailable' try: - preprint.request_identifier_update(category='doi', status=status) + preprint.request_identifier_update(category='doi') except HTTPError as err: sentry.log_exception() sentry.log_message(err.args[0]) diff --git a/website/profile/views.py b/website/profile/views.py index 026635e4a2f..5fd67f98053 100644 --- a/website/profile/views.py +++ b/website/profile/views.py @@ -817,20 +817,6 @@ def request_export(auth): @must_be_logged_in def request_deactivation(auth): user = auth.user - if not throttle_period_expired(user.email_last_sent, settings.SEND_EMAIL_THROTTLE): - raise HTTPError(http.BAD_REQUEST, - data={ - 'message_long': 'Too many requests. Please wait a while before sending another account deactivation request.', - 'error_type': 'throttle_error' - }) - - mails.send_mail( - to_addr=settings.OSF_SUPPORT_EMAIL, - mail=mails.REQUEST_DEACTIVATION, - user=auth.user, - can_change_preferences=False, - ) - user.email_last_sent = timezone.now() user.requested_deactivation = True user.save() return {'message': 'Sent account deactivation request'} @@ -839,5 +825,6 @@ def request_deactivation(auth): def cancel_request_deactivation(auth): user = auth.user user.requested_deactivation = False + user.contacted_deactivation = False # In case we've already contacted them once. user.save() return {'message': 'You have canceled your deactivation request'} diff --git a/website/settings/defaults.py b/website/settings/defaults.py index c9846449e90..9bfe700d72e 100644 --- a/website/settings/defaults.py +++ b/website/settings/defaults.py @@ -318,10 +318,6 @@ def parent_dir(path): # General Format for DOIs DOI_FORMAT = '{prefix}/osf.io/{guid}' -# ezid -EZID_DOI_NAMESPACE = 'doi:10.5072' -EZID_ARK_NAMESPACE = 'ark:99999' - # datacite DATACITE_USERNAME = None DATACITE_PASSWORD = None @@ -489,6 +485,7 @@ class CeleryConfig: 'scripts.generate_sitemap', 'scripts.premigrate_created_modified', 'scripts.add_missing_identifiers_to_preprints', + 'osf.management.commands.deactivate_requested_accounts', ) # Modules that need metrics and release requirements @@ -608,6 +605,10 @@ class CeleryConfig: 'task': 'scripts.generate_sitemap', 'schedule': crontab(minute=0, hour=5), # Daily 12:00 a.m. }, + 'deactivate_requested_accounts': { + 'task': 'management.commands.deactivate_requested_accounts', + 'schedule': crontab(minute=0, hour=5), # Daily 12:00 a.m. + }, 'check_crossref_doi': { 'task': 'management.commands.check_crossref_dois', 'schedule': crontab(minute=0, hour=4), # Daily 11:00 p.m. diff --git a/website/templates/emails/request_deactivation_complete.html.mako b/website/templates/emails/request_deactivation_complete.html.mako new file mode 100644 index 00000000000..6ef726186e3 --- /dev/null +++ b/website/templates/emails/request_deactivation_complete.html.mako @@ -0,0 +1,19 @@ +<%inherit file="notify_base.mako" /> + +<%def name="content()"> + + + Hi ${user.given_name}, +
+
+ + Your OSF account has been deactivated. You will not show up in search, nor will a profile be visible for you. + If you try to log in, you will receive an error message that your account has been disabled. If, in the future, + you would like to create an account with this email address, you can do so by emailing us at ${contact_email}. + +
+
+ Sincerely, + The OSF Team + + \ No newline at end of file