diff --git a/addons/base/views.py b/addons/base/views.py index 6fea24444210..8b99cab415ee 100644 --- a/addons/base/views.py +++ b/addons/base/views.py @@ -317,6 +317,9 @@ def get_authenticated_resource(resource_id): if resource.deleted: raise HTTPError(http_status.HTTP_410_GONE, message='Resource has been deleted.') + if getattr(resource, 'is_retracted', False): + raise HTTPError(http_status.HTTP_410_GONE, message='Resource has been retracted.') + return resource diff --git a/addons/boa/requirements.txt b/addons/boa/requirements.txt new file mode 100644 index 000000000000..da5a1c65b028 --- /dev/null +++ b/addons/boa/requirements.txt @@ -0,0 +1,5 @@ +# Requirements for the boa add-on +boa-api==0.1.14 + +# Requirements for running asyncio in celery, using 3.4.1 for Python 3.6 compatibility +asgiref==3.7.2 diff --git a/addons/box/requirements.txt b/addons/box/requirements.txt new file mode 100644 index 000000000000..c429a356fb20 --- /dev/null +++ b/addons/box/requirements.txt @@ -0,0 +1 @@ +boxsdk==3.9.2 diff --git a/addons/dataverse/requirements.txt b/addons/dataverse/requirements.txt new file mode 100644 index 000000000000..13c09613bda0 --- /dev/null +++ b/addons/dataverse/requirements.txt @@ -0,0 +1,3 @@ +# Allow for optional timeout parameter. +# https://github.com/IQSS/dataverse-client-python/pull/27 +git+https://github.com/CenterForOpenScience/dataverse-client-python.git@2b3827578048e6df3818f82381c7ea9a2395e526 # branch is feature/dv-client-updates diff --git a/addons/dropbox/requirements.txt b/addons/dropbox/requirements.txt new file mode 100644 index 000000000000..b6470418537b --- /dev/null +++ b/addons/dropbox/requirements.txt @@ -0,0 +1 @@ +dropbox==11.36.2 diff --git a/addons/github/requirements.txt b/addons/github/requirements.txt new file mode 100644 index 000000000000..f7c57e651e99 --- /dev/null +++ b/addons/github/requirements.txt @@ -0,0 +1,3 @@ +cachecontrol==0.14.0 +github3.py==4.0.1 +uritemplate==4.1.1 diff --git a/addons/gitlab/requirements.txt b/addons/gitlab/requirements.txt new file mode 100644 index 000000000000..2edb928a2109 --- /dev/null +++ b/addons/gitlab/requirements.txt @@ -0,0 +1 @@ +python-gitlab==4.4.0 diff --git a/addons/mendeley/requirements.txt b/addons/mendeley/requirements.txt new file mode 100644 index 000000000000..094445ba3705 --- /dev/null +++ b/addons/mendeley/requirements.txt @@ -0,0 +1,2 @@ +# up-to-date with mendeley's master + add folder support and future dep updates +git+https://github.com/CenterForOpenScience/mendeley-python-sdk.git@be8a811fa6c3b105d9f5c656cabb6b1ba855ed5b # branch is feature/osf-dep-updates diff --git a/addons/owncloud/requirements.txt b/addons/owncloud/requirements.txt new file mode 100644 index 000000000000..91497da541b4 --- /dev/null +++ b/addons/owncloud/requirements.txt @@ -0,0 +1,2 @@ +# Requirements for the owncloud add-on +pyocclient==0.6.0 diff --git a/addons/s3/requirements.txt b/addons/s3/requirements.txt new file mode 100644 index 000000000000..79406d53dd19 --- /dev/null +++ b/addons/s3/requirements.txt @@ -0,0 +1 @@ +boto3==1.34.60 diff --git a/addons/twofactor/requirements.txt b/addons/twofactor/requirements.txt new file mode 100644 index 000000000000..f0df16c6611c --- /dev/null +++ b/addons/twofactor/requirements.txt @@ -0,0 +1 @@ +pyotp==2.9.0 \ No newline at end of file diff --git a/addons/wiki/requirements.txt b/addons/wiki/requirements.txt new file mode 100644 index 000000000000..aefa4d0d357e --- /dev/null +++ b/addons/wiki/requirements.txt @@ -0,0 +1 @@ +pymongo==4.6.3 diff --git a/addons/zotero/requirements.txt b/addons/zotero/requirements.txt new file mode 100644 index 000000000000..57f831d845f9 --- /dev/null +++ b/addons/zotero/requirements.txt @@ -0,0 +1 @@ +Pyzotero==1.5.18 diff --git a/admin/base/urls.py b/admin/base/urls.py index 765e3161645e..f46c5afa2409 100644 --- a/admin/base/urls.py +++ b/admin/base/urls.py @@ -36,6 +36,7 @@ re_path(r'^schema_responses/', include('admin.schema_responses.urls', namespace='schema_responses')), re_path(r'^registration_schemas/', include('admin.registration_schemas.urls', namespace='registration_schemas')), re_path(r'^cedar_metadata_templates/', include('admin.cedar.urls', namespace='cedar_metadata_templates')), + re_path(r'^notifications/', include('admin.notifications.urls', namespace='notifications')), ]), ), ] diff --git a/admin/notifications/urls.py b/admin/notifications/urls.py new file mode 100644 index 000000000000..7442d33eff3d --- /dev/null +++ b/admin/notifications/urls.py @@ -0,0 +1,8 @@ +from django.urls import re_path +from admin.notifications import views + +app_name = 'notifications' + +urlpatterns = [ + re_path(r'^$', views.handle_duplicate_notifications, name='handle_duplicate_notifications'), +] diff --git a/admin/notifications/views.py b/admin/notifications/views.py new file mode 100644 index 000000000000..aa152f418ce9 --- /dev/null +++ b/admin/notifications/views.py @@ -0,0 +1,54 @@ +from django.contrib.auth.decorators import user_passes_test +from django.shortcuts import render, redirect +from admin.base.utils import osf_staff_check +from osf.models.notifications import NotificationSubscription +from django.db.models import Count + +def delete_selected_notifications(selected_ids): + NotificationSubscription.objects.filter(id__in=selected_ids).delete() + +def detect_duplicate_notifications(): + duplicates = ( + NotificationSubscription.objects.values('user', 'node', 'event_name') + .annotate(count=Count('id')) + .filter(count__gt=1) + ) + + detailed_duplicates = [] + for dup in duplicates: + notifications = NotificationSubscription.objects.filter( + user=dup['user'], node=dup['node'], event_name=dup['event_name'] + ).order_by('created') + + for notification in notifications: + detailed_duplicates.append({ + 'id': notification.id, + 'user': notification.user, + 'node': notification.node, + 'event_name': notification.event_name, + 'created': notification.created, + 'count': dup['count'] + }) + + return detailed_duplicates + +def process_duplicate_notifications(request): + detailed_duplicates = detect_duplicate_notifications() + + if request.method == 'POST': + selected_ids = request.POST.getlist('selected_notifications') + delete_selected_notifications(selected_ids) + return detailed_duplicates, 'Selected duplicate notifications have been deleted.', True + + return detailed_duplicates, '', False + +@user_passes_test(osf_staff_check) +def handle_duplicate_notifications(request): + detailed_duplicates, message, is_post = process_duplicate_notifications(request) + + context = {'duplicates': detailed_duplicates} + if is_post: + context['message'] = message + return redirect('notifications:handle_duplicate_notifications') + + return render(request, 'notifications/handle_duplicate_notifications.html', context) diff --git a/admin/preprints/forms.py b/admin/preprints/forms.py index 15b0ba077ead..2ea919310188 100644 --- a/admin/preprints/forms.py +++ b/admin/preprints/forms.py @@ -1,9 +1,33 @@ from django import forms from osf.models import Preprint - +from osf.utils.workflows import ReviewStates class ChangeProviderForm(forms.ModelForm): class Meta: model = Preprint fields = ('provider',) + + +class MachineStateForm(forms.ModelForm): + class Meta: + model = Preprint + fields = ('machine_state',) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if not self.instance.is_public: + self.fields['machine_state'].widget.attrs['disabled'] = 'disabled' + else: + if self.instance.machine_state == ReviewStates.INITIAL.db_name: + self.fields['machine_state'].choices = [ + (ReviewStates.INITIAL.value, ReviewStates.INITIAL.value), + (ReviewStates.PENDING.value, ReviewStates.PENDING.value), + ] + else: + # Disabled Option you are on + self.fields['machine_state'].widget.attrs['disabled'] = 'disabled' + self.fields['machine_state'].choices = [ + (self.instance.machine_state.title(), self.instance.machine_state) + ] diff --git a/admin/preprints/urls.py b/admin/preprints/urls.py index ddbbc9c4a541..cec798911346 100644 --- a/admin/preprints/urls.py +++ b/admin/preprints/urls.py @@ -10,6 +10,8 @@ re_path(r'^known_ham$', views.PreprintKnownHamList.as_view(), name='known-ham'), re_path(r'^withdrawal_requests$', views.PreprintWithdrawalRequestList.as_view(), name='withdrawal-requests'), re_path(r'^(?P[a-z0-9]+)/$', views.PreprintView.as_view(), name='preprint'), + re_path(r'^(?P[a-z0-9]+)/change_provider/$', views.PreprintProviderChangeView.as_view(), name='preprint-provider'), + re_path(r'^(?P[a-z0-9]+)/machine_state/$', views.PreprintMachineStateView.as_view(), name='preprint-machine-state'), re_path(r'^(?P[a-z0-9]+)/reindex_share_preprint/$', views.PreprintReindexShare.as_view(), name='reindex-share-preprint'), re_path(r'^(?P[a-z0-9]+)/remove_user/(?P[a-z0-9]+)/$', views.PreprintRemoveContributorView.as_view(), diff --git a/admin/preprints/views.py b/admin/preprints/views.py index f8950c349c9f..80f6da1f0597 100644 --- a/admin/preprints/views.py +++ b/admin/preprints/views.py @@ -15,7 +15,7 @@ from admin.base.views import GuidView from admin.base.forms import GuidForm from admin.nodes.views import NodeRemoveContributorView -from admin.preprints.forms import ChangeProviderForm +from admin.preprints.forms import ChangeProviderForm, MachineStateForm from api.share.utils import update_share @@ -62,6 +62,21 @@ class PreprintView(PreprintMixin, GuidView): """ template_name = 'preprints/preprint.html' permission_required = ('osf.view_preprint', 'osf.change_preprint',) + + def get_context_data(self, **kwargs): + preprint = self.get_object() + return super().get_context_data(**{ + 'preprint': preprint, + 'SPAM_STATUS': SpamStatus, + 'change_provider_form': ChangeProviderForm(instance=preprint), + 'change_machine_state_form': MachineStateForm(instance=preprint), + }, **kwargs) + + +class PreprintProviderChangeView(PreprintMixin, GuidView): + """ Allows authorized users to view preprint info and change a preprint's provider. + """ + permission_required = ('osf.view_preprint', 'osf.change_preprint',) form_class = ChangeProviderForm def post(self, request, *args, **kwargs): @@ -79,13 +94,26 @@ def post(self, request, *args, **kwargs): return redirect(self.get_success_url()) - def get_context_data(self, **kwargs): + +class PreprintMachineStateView(PreprintMixin, GuidView): + """ Allows authorized users to view preprint info and change a preprint's machine_state. + """ + permission_required = ('osf.view_preprint', 'osf.change_preprint',) + form_class = MachineStateForm + + def post(self, request, *args, **kwargs): preprint = self.get_object() - return super().get_context_data(**{ - 'preprint': preprint, - 'SPAM_STATUS': SpamStatus, - 'form': ChangeProviderForm(instance=preprint), - }, **kwargs) + new_machine_state = request.POST.get('machine_state') + if new_machine_state and preprint.machine_state != new_machine_state: + preprint.machine_state = new_machine_state + try: + preprint.save() + except Exception as e: + messages.error(self.request, e.message) + + preprint.refresh_from_db() + + return redirect(self.get_success_url()) class PreprintSearchView(PermissionRequiredMixin, FormView): diff --git a/admin/templates/base.html b/admin/templates/base.html index 952fc088bc53..40a91221f88b 100644 --- a/admin/templates/base.html +++ b/admin/templates/base.html @@ -311,6 +311,9 @@ {% if perms.osf.change_maintenancestate %}
  • Maintenance Alerts
  • {% endif %} + {% if perms.osf.view_notification %} +
  • Duplicate Notifications
  • + {% endif %} diff --git a/admin/templates/notifications/handle_duplicate_notifications.html b/admin/templates/notifications/handle_duplicate_notifications.html new file mode 100644 index 000000000000..de88ff24d8f1 --- /dev/null +++ b/admin/templates/notifications/handle_duplicate_notifications.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} +{% load render_bundle from webpack_loader %} +{% load static %} + +{% block title %} + Duplicate Notifications +{% endblock title %} + +{% block content %} +

    Duplicate Notifications

    + + {% if message %} +
    + {{ message }} +
    + {% endif %} + + {% if duplicates %} +
    + {% csrf_token %} + + + + + + + + + + + + + {% for notification in duplicates %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
    SelectUserNodeEvent NameCreatedCount
    {{ notification.user }}{{ notification.node }}{{ notification.event_name }}{{ notification.created }}{{ notification.count }}
    No duplicate notifications found!
    + +
    + {% else %} +

    No duplicate notifications found.

    + {% endif %} +{% endblock content %} diff --git a/admin/templates/preprints/machine_state.html b/admin/templates/preprints/machine_state.html new file mode 100644 index 000000000000..0d133b037bbc --- /dev/null +++ b/admin/templates/preprints/machine_state.html @@ -0,0 +1,22 @@ +{% load node_extras %} + + Machine State + +

    {{ preprint.machine_state }}

    +

    {{ preprint.state }}

    + {% if perms.osf.change_preprint %} + + Change preprint machine_state + +
    +
    +
    + {% csrf_token %} + {{ change_machine_state_form.as_p }} + +
    +
    +
    + {% endif %} + + \ No newline at end of file diff --git a/admin/templates/preprints/preprint.html b/admin/templates/preprints/preprint.html index 4d96190339fc..0b76a65951f1 100644 --- a/admin/templates/preprints/preprint.html +++ b/admin/templates/preprints/preprint.html @@ -74,10 +74,6 @@

    Preprint: {{ preprint.title }} Published {{ preprint.is_published }} - - Machine State - {{ preprint.machine_state }} - {% if preprint.is_published %} Date Published @@ -104,6 +100,7 @@

    Preprint: {{ preprint.title }} {% endif %} {% include "preprints/provider.html" with preprint=preprint %} + {% include "preprints/machine_state.html" with preprint=preprint %} Subjects diff --git a/admin/templates/preprints/provider.html b/admin/templates/preprints/provider.html index 4d14d1faf039..4a640b997c74 100644 --- a/admin/templates/preprints/provider.html +++ b/admin/templates/preprints/provider.html @@ -9,9 +9,9 @@
    -
    + {% csrf_token %} - {{ form.as_p }} + {{ change_provider_form.as_p }}
    diff --git a/admin_tests/notifications/__init__.py b/admin_tests/notifications/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/admin_tests/notifications/test_views.py b/admin_tests/notifications/test_views.py new file mode 100644 index 000000000000..37d0bcc4432d --- /dev/null +++ b/admin_tests/notifications/test_views.py @@ -0,0 +1,69 @@ +import pytest +from django.test import RequestFactory +from osf.models import OSFUser, NotificationSubscription, Node +from admin.notifications.views import ( + delete_selected_notifications, + detect_duplicate_notifications, + process_duplicate_notifications +) +from tests.base import AdminTestCase + +pytestmark = pytest.mark.django_db + +class TestNotificationFunctions(AdminTestCase): + + def setUp(self): + super().setUp() + self.user = OSFUser.objects.create(username='admin', is_staff=True) + self.node = Node.objects.create(creator=self.user, title='Test Node') + self.request_factory = RequestFactory() + + def test_delete_selected_notifications(self): + notification1 = NotificationSubscription.objects.create(user=self.user, node=self.node, event_name='event1') + notification2 = NotificationSubscription.objects.create(user=self.user, node=self.node, event_name='event2') + notification3 = NotificationSubscription.objects.create(user=self.user, node=self.node, event_name='event3') + + delete_selected_notifications([notification1.id, notification2.id]) + + assert not NotificationSubscription.objects.filter(id__in=[notification1.id, notification2.id]).exists() + assert NotificationSubscription.objects.filter(id=notification3.id).exists() + + def test_detect_duplicate_notifications(self): + NotificationSubscription.objects.create(user=self.user, node=self.node, event_name='event1') + NotificationSubscription.objects.create(user=self.user, node=self.node, event_name='event1') + NotificationSubscription.objects.create(user=self.user, node=self.node, event_name='event2') + + duplicates = detect_duplicate_notifications() + + assert len(duplicates) == 2 + assert duplicates[0]['user'] == self.user + assert duplicates[0]['node'] == self.node + assert duplicates[0]['event_name'] == 'event1' + assert duplicates[0]['count'] == 2 + + non_duplicate_event = [dup for dup in duplicates if dup['event_name'] == 'event2'] + assert len(non_duplicate_event) == 0 + + def test_process_duplicate_notifications_get(self): + request = self.request_factory.get('/fake_path') + request.user = self.user + + detailed_duplicates, message, is_post = process_duplicate_notifications(request) + + assert detailed_duplicates == [] + assert message == '' + assert not is_post + + def test_process_duplicate_notifications_post(self): + notification1 = NotificationSubscription.objects.create(user=self.user, node=self.node, event_name='event1') + notification2 = NotificationSubscription.objects.create(user=self.user, node=self.node, event_name='event1') + + request = self.request_factory.post('/fake_path', {'selected_notifications': [notification1.id]}) + request.user = self.user + + detailed_duplicates, message, is_post = process_duplicate_notifications(request) + + assert message == 'Selected duplicate notifications have been deleted.' + assert is_post + assert not NotificationSubscription.objects.filter(id=notification1.id).exists() + assert NotificationSubscription.objects.filter(id=notification2.id).exists() diff --git a/admin_tests/preprints/test_views.py b/admin_tests/preprints/test_views.py index 2c9d46c48a08..3d06b2c2f86f 100644 --- a/admin_tests/preprints/test_views.py +++ b/admin_tests/preprints/test_views.py @@ -57,7 +57,7 @@ class TestPreprintView: @pytest.fixture() def plain_view(self): - return views.PreprintView + return views.PreprintProviderChangeView @pytest.fixture() def view(self, req, plain_view): @@ -589,3 +589,63 @@ def test_approve_reject_on_list_view(self, withdrawal_request, admin, action, fi assert original_comment == withdrawal_request.target.withdrawal_justification else: assert not withdrawal_request.target.withdrawal_justification + + +@pytest.mark.urls('admin.base.urls') +@pytest.mark.django_db +class TestPreprintMachineStateView: + + @pytest.fixture() + def preprint(self): + return PreprintFactory() + + @pytest.fixture() + def user(self): + return AuthUserFactory() + + @pytest.fixture() + def admin_user(self): + admin_user = AuthUserFactory() + admin_user.is_admin = True + admin_user.save() + return admin_user + + @pytest.fixture() + def req(self, user): + req = RequestFactory().post('/fake_path') + req.user = user + return req + + @pytest.fixture() + def admin_req(self, admin_user): + req = RequestFactory().post('/fake_path') + req.user = admin_user + return req + + def test_post_changes_machine_state(self, admin_req, preprint): + new_state = 'new_state' + admin_req.POST = {'machine_state': new_state} + + view = setup_view(views.PreprintMachineStateView(), admin_req, guid=preprint._id) + response = view.post(admin_req) + + preprint.refresh_from_db() + assert preprint.machine_state == new_state + assert response.status_code == 302 + + def test_post_no_change_in_machine_state(self, admin_req, preprint): + current_state = preprint.machine_state + admin_req.POST = {'machine_state': current_state} + + view = setup_view(views.PreprintMachineStateView(), admin_req, guid=preprint._id) + response = view.post(admin_req) + + preprint.refresh_from_db() + assert preprint.machine_state == current_state + assert response.status_code == 302 + + def test_no_permission_raises_error(self, req, preprint): + request = RequestFactory().post(reverse('preprints:preprint-machine-state', kwargs={'guid': preprint._id})) + request.user = req.user + with pytest.raises(PermissionDenied): + views.PreprintMachineStateView.as_view()(request, guid=preprint._id) diff --git a/api/draft_registrations/permissions.py b/api/draft_registrations/permissions.py index 83bf44a612ed..5232ee9d546a 100644 --- a/api/draft_registrations/permissions.py +++ b/api/draft_registrations/permissions.py @@ -8,6 +8,8 @@ OSFUser, ) from api.nodes.permissions import ContributorDetailPermissions +from osf.utils.permissions import WRITE, ADMIN + class IsContributorOrAdminContributor(permissions.BasePermission): """ @@ -57,3 +59,34 @@ class DraftContributorDetailPermissions(ContributorDetailPermissions): def load_resource(self, context, view): return DraftRegistration.load(context['draft_id']) + + +class DraftRegistrationPermission(permissions.BasePermission): + """ + Check permissions for draft and node, Admin can create (POST) or edit (PATCH, PUT) to a DraftRegistration, but write + users can only edit them. Node permissions are inherited by the DraftRegistration when they are higher. + """ + acceptable_models = (DraftRegistration, AbstractNode) + + def has_object_permission(self, request, view, obj): + auth = get_user_auth(request) + + if not auth.user: + return False + + if request.method in permissions.SAFE_METHODS: + if isinstance(obj, DraftRegistration): + return obj.can_view(auth) + elif isinstance(obj, AbstractNode): + return obj.can_view(auth) + elif request.method == 'POST': # Only Admin can create a draft registration + if isinstance(obj, DraftRegistration): + return obj.is_contributor(auth.user) and obj.has_permission(auth.user, ADMIN) + elif isinstance(obj, AbstractNode): + return obj.has_permission(auth.user, ADMIN) + else: + if isinstance(obj, DraftRegistration): + return obj.is_contributor(auth.user) and obj.has_permission(auth.user, WRITE) + elif isinstance(obj, AbstractNode): + return obj.has_permission(auth.user, WRITE) + return False diff --git a/api/draft_registrations/views.py b/api/draft_registrations/views.py index 164431954927..30c583dd94a8 100644 --- a/api/draft_registrations/views.py +++ b/api/draft_registrations/views.py @@ -6,7 +6,7 @@ from api.base.pagination import DraftRegistrationContributorPagination from api.draft_registrations.permissions import ( DraftContributorDetailPermissions, - IsContributorOrAdminContributor, + DraftRegistrationPermission, IsAdminContributor, ) from api.draft_registrations.serializers import ( @@ -50,9 +50,9 @@ def check_resource_permissions(self, resource): class DraftRegistrationList(NodeDraftRegistrationsList): permission_classes = ( - IsContributorOrAdminContributor, drf_permissions.IsAuthenticatedOrReadOnly, base_permissions.TokenHasScope, + DraftRegistrationPermission, ) view_category = 'draft_registrations' @@ -70,10 +70,9 @@ def get_queryset(self): # Returns DraftRegistrations for which a user is a contributor return user.draft_registrations_active - class DraftRegistrationDetail(NodeDraftRegistrationDetail, DraftRegistrationMixin): permission_classes = ( - ContributorOrPublic, + DraftRegistrationPermission, AdminDeletePermissions, drf_permissions.IsAuthenticatedOrReadOnly, base_permissions.TokenHasScope, diff --git a/api/files/serializers.py b/api/files/serializers.py index 20287c904543..e68845c4cd19 100644 --- a/api/files/serializers.py +++ b/api/files/serializers.py @@ -445,7 +445,6 @@ def to_representation(self, value): guid = Guid.load(view.kwargs['file_id']) if guid: data['data']['id'] = guid._id - return data diff --git a/api/files/views.py b/api/files/views.py index 4a4861f31ec2..5a498fa70899 100644 --- a/api/files/views.py +++ b/api/files/views.py @@ -57,6 +57,9 @@ def get_file(self, check_permissions=True): if obj.target.creator.is_disabled: raise Gone(detail='This user has been deactivated and their quickfiles are no longer available.') + if getattr(obj.target, 'is_retracted', False): + raise Gone(detail='The requested file is no longer available.') + if check_permissions: # May raise a permission denied self.check_object_permissions(self.request, obj) diff --git a/api/nodes/views.py b/api/nodes/views.py index f43dbf17ad2b..c19b7a2762ab 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -5,7 +5,7 @@ from django.db.models import F, Max, Q, Subquery from django.utils import timezone from django.contrib.contenttypes.models import ContentType -from rest_framework import generics, permissions as drf_permissions +from rest_framework import generics, permissions as drf_permissions, exceptions from rest_framework.exceptions import PermissionDenied, ValidationError, NotFound, MethodNotAllowed, NotAuthenticated from rest_framework.response import Response from rest_framework.status import HTTP_202_ACCEPTED, HTTP_204_NO_CONTENT @@ -66,6 +66,7 @@ NodeCommentSerializer, ) from api.draft_registrations.serializers import DraftRegistrationSerializer, DraftRegistrationDetailSerializer +from api.draft_registrations.permissions import DraftRegistrationPermission from api.files.serializers import FileSerializer, OsfStorageFileSerializer from api.files import annotations as file_annotations from api.identifiers.serializers import NodeIdentifierSerializer @@ -75,7 +76,6 @@ from api.nodes.filters import NodesFilterMixin from api.nodes.permissions import ( IsAdmin, - IsAdminContributor, IsPublic, AdminOrPublic, WriteAdmin, @@ -626,7 +626,7 @@ class NodeDraftRegistrationsList(JSONAPIBaseView, generics.ListCreateAPIView, No Use DraftRegistrationsList endpoint instead. """ permission_classes = ( - IsAdminContributor, + DraftRegistrationPermission, drf_permissions.IsAuthenticatedOrReadOnly, base_permissions.TokenHasScope, ) @@ -649,8 +649,11 @@ def get_serializer_class(self): # overrides ListCreateAPIView def get_queryset(self): + user = self.request.user node = self.get_node() - return node.draft_registrations_active + if user.is_anonymous: + raise exceptions.NotAuthenticated() + return user.draft_registrations_active.filter(branched_from=node) class NodeDraftRegistrationDetail(JSONAPIBaseView, generics.RetrieveUpdateDestroyAPIView, DraftMixin): @@ -660,9 +663,9 @@ class NodeDraftRegistrationDetail(JSONAPIBaseView, generics.RetrieveUpdateDestro Use DraftRegistrationDetail endpoint instead. """ permission_classes = ( + DraftRegistrationPermission, drf_permissions.IsAuthenticatedOrReadOnly, base_permissions.TokenHasScope, - IsAdminContributor, ) parser_classes = (JSONAPIMultipleRelationshipsParser, JSONAPIMultipleRelationshipsParserForRegularJSON) diff --git a/api/users/serializers.py b/api/users/serializers.py index fd92914df05a..5d2404c181c6 100644 --- a/api/users/serializers.py +++ b/api/users/serializers.py @@ -610,7 +610,7 @@ def create(self, validated_data): token = user.add_unconfirmed_email(address) user.save() if CONFIRM_REGISTRATIONS_BY_EMAIL: - send_confirm_email(user, email=address) + send_confirm_email_async(user, email=address) user.email_last_sent = timezone.now() user.save() except ValidationError as e: diff --git a/api/users/views.py b/api/users/views.py index 325619d517d9..663985bd9b10 100644 --- a/api/users/views.py +++ b/api/users/views.py @@ -60,7 +60,7 @@ from django.http import JsonResponse from django.utils import timezone from framework.auth.core import get_user -from framework.auth.views import send_confirm_email +from framework.auth.views import send_confirm_email_async from framework.auth.oauth_scopes import CoreScopes, normalize_scopes from framework.auth.exceptions import ChangePasswordError from framework.utils import throttle_period_expired @@ -900,7 +900,7 @@ def get_object(self): if self.request.method == 'GET' and is_truthy(self.request.query_params.get('resend_confirmation')): if not confirmed and settings.CONFIRM_REGISTRATIONS_BY_EMAIL: if throttle_period_expired(user.email_last_sent, settings.SEND_EMAIL_THROTTLE): - send_confirm_email(user, email=address, renew=True) + send_confirm_email_async(user, email=address, renew=True) user.email_last_sent = timezone.now() user.save() diff --git a/api_tests/draft_registrations/views/test_draft_registration_detail.py b/api_tests/draft_registrations/views/test_draft_registration_detail.py index 18b00014f94b..2106f87fb5ad 100644 --- a/api_tests/draft_registrations/views/test_draft_registration_detail.py +++ b/api_tests/draft_registrations/views/test_draft_registration_detail.py @@ -2,10 +2,10 @@ from api.base.settings.defaults import API_BASE from api_tests.nodes.views.test_node_draft_registration_detail import ( - TestDraftRegistrationDetail, TestDraftRegistrationUpdate, TestDraftRegistrationPatch, TestDraftRegistrationDelete, + AbstractDraftRegistrationTestCase ) from osf.models import DraftNode, Node, NodeLicense, RegistrationSchema from osf.utils.permissions import ADMIN, READ, WRITE @@ -16,58 +16,34 @@ SubjectFactory, ProjectFactory, ) +from website.settings import API_DOMAIN @pytest.mark.django_db -class TestDraftRegistrationDetailEndpoint(TestDraftRegistrationDetail): +class TestDraftRegistrationDetailEndpoint(AbstractDraftRegistrationTestCase): @pytest.fixture() def url_draft_registrations(self, project_public, draft_registration): - return '/{}draft_registrations/{}/'.format( - API_BASE, draft_registration._id) - - # Overrides TestDraftRegistrationDetail - def test_admin_group_member_can_view(self, app, user, draft_registration, project_public, - schema, url_draft_registrations, group_mem): - - res = app.get(url_draft_registrations, auth=group_mem.auth, expect_errors=True) - assert res.status_code == 403 + return f'/{API_BASE}draft_registrations/{draft_registration._id}/' - def test_can_view_draft( - self, app, user_write_contrib, project_public, - user_read_contrib, user_non_contrib, - url_draft_registrations, group, group_mem): - - # test_read_only_contributor_can_view_draft - res = app.get( - url_draft_registrations, - auth=user_read_contrib.auth, - expect_errors=False) + def test_read_only_contributor_can_view_draft(self, app, user_read_contrib, url_draft_registrations): + res = app.get(url_draft_registrations, auth=user_read_contrib.auth) assert res.status_code == 200 - # test_read_write_contributor_can_view_draft - res = app.get( - url_draft_registrations, - auth=user_write_contrib.auth, - expect_errors=False) + def test_read_write_contributor_can_view_draft(self, app, user_write_contrib, url_draft_registrations): + res = app.get(url_draft_registrations, auth=user_write_contrib.auth) assert res.status_code == 200 - def test_cannot_view_draft( - self, app, project_public, - user_non_contrib, url_draft_registrations): - - # test_logged_in_non_contributor_cannot_view_draft - res = app.get( - url_draft_registrations, - auth=user_non_contrib.auth, - expect_errors=True) + def test_logged_in_non_contributor_cannot_view_draft(self, app, user_non_contrib, url_draft_registrations): + res = app.get(url_draft_registrations, auth=user_non_contrib.auth, expect_errors=True) assert res.status_code == 403 - # test_unauthenticated_user_cannot_view_draft + def test_unauthenticated_user_cannot_view_draft(self, app, url_draft_registrations): res = app.get(url_draft_registrations, expect_errors=True) assert res.status_code == 401 - def test_detail_view_returns_editable_fields(self, app, user, draft_registration, - url_draft_registrations, project_public): + def test_detail_view_returns_editable_fields( + self, app, user, draft_registration, url_draft_registrations, project_public + ): res = app.get(url_draft_registrations, auth=user.auth, expect_errors=True) attributes = res.json['data']['attributes'] @@ -77,7 +53,7 @@ def test_detail_view_returns_editable_fields(self, app, user, draft_registration assert attributes['category'] == project_public.category assert attributes['has_project'] - res.json['data']['links']['self'] == url_draft_registrations + assert res.json['data']['links']['self'] == f'{API_DOMAIN}{url_draft_registrations.lstrip("/")}' relationships = res.json['data']['relationships'] assert Node.load(relationships['branched_from']['data']['id']) == draft_registration.branched_from @@ -89,8 +65,7 @@ def test_detail_view_returns_editable_fields(self, app, user, draft_registration def test_detail_view_returns_editable_fields_no_specified_node(self, app, user): draft_registration = DraftRegistrationFactory(initiator=user, branched_from=None) - url = '/{}draft_registrations/{}/'.format( - API_BASE, draft_registration._id) + url = f'{API_DOMAIN}{API_BASE}draft_registrations/{draft_registration._id}/' res = app.get(url, auth=user.auth, expect_errors=True) attributes = res.json['data']['attributes'] @@ -101,7 +76,7 @@ def test_detail_view_returns_editable_fields_no_specified_node(self, app, user): assert attributes['node_license'] is None assert not attributes['has_project'] - res.json['data']['links']['self'] == url + assert res.json['data']['links']['self'] == url relationships = res.json['data']['relationships'] assert 'affiliated_institutions' in relationships @@ -112,16 +87,13 @@ def test_detail_view_returns_editable_fields_no_specified_node(self, app, user): res = app.get(draft_node_link, auth=user.auth) assert DraftNode.load(res.json['data']['id']) == draft_registration.branched_from - def test_draft_registration_perms_checked_on_draft_not_node(self, app, user, project_public, - draft_registration, url_draft_registrations): - - # Admin on node and draft + def test_admin_node_and_draft(self, app, user, project_public, draft_registration, url_draft_registrations): assert project_public.has_permission(user, ADMIN) is True assert draft_registration.has_permission(user, ADMIN) is True res = app.get(url_draft_registrations, auth=user.auth) assert res.status_code == 200 - # Admin on node but not draft + def test_admin_node_not_draft(self, app, user, project_public, draft_registration, url_draft_registrations): node_admin = AuthUserFactory() project_public.add_contributor(node_admin, ADMIN) assert project_public.has_permission(node_admin, ADMIN) is True @@ -129,7 +101,7 @@ def test_draft_registration_perms_checked_on_draft_not_node(self, app, user, pro res = app.get(url_draft_registrations, auth=node_admin.auth, expect_errors=True) assert res.status_code == 403 - # Admin on draft but not node + def test_admin_draft_not_node(self, app, user, project_public, draft_registration, url_draft_registrations): draft_admin = AuthUserFactory() draft_registration.add_contributor(draft_admin, ADMIN) assert project_public.has_permission(draft_admin, ADMIN) is False @@ -137,19 +109,66 @@ def test_draft_registration_perms_checked_on_draft_not_node(self, app, user, pro res = app.get(url_draft_registrations, auth=draft_admin.auth) assert res.status_code == 200 - # Overwrites TestDraftRegistrationDetail - def test_can_view_after_added( - self, app, schema, draft_registration, url_draft_registrations): - # Draft Registration permissions are no longer based on the branched from project + def test_write_node_and_draft(self, app, user, project_public, draft_registration, url_draft_registrations): + assert project_public.has_permission(user, WRITE) is True + assert draft_registration.has_permission(user, WRITE) is True + res = app.get(url_draft_registrations, auth=user.auth) + assert res.status_code == 200 + + def test_write_node_not_draft(self, app, user, project_public, draft_registration, url_draft_registrations): + node_admin = AuthUserFactory() + project_public.add_contributor(node_admin, WRITE) + assert project_public.has_permission(node_admin, WRITE) is True + assert draft_registration.has_permission(node_admin, WRITE) is False + res = app.get(url_draft_registrations, auth=node_admin.auth, expect_errors=True) + assert res.status_code == 403 + + def test_write_draft_not_node(self, app, user, project_public, draft_registration, url_draft_registrations): + draft_admin = AuthUserFactory() + draft_registration.add_contributor(draft_admin, WRITE) + assert project_public.has_permission(draft_admin, WRITE) is False + assert draft_registration.has_permission(draft_admin, WRITE) is True + res = app.get(url_draft_registrations, auth=draft_admin.auth) + assert res.status_code == 200 + + def test_read_node_and_draft(self, app, user, project_public, draft_registration, url_draft_registrations): + assert project_public.has_permission(user, READ) is True + assert draft_registration.has_permission(user, READ) is True + res = app.get(url_draft_registrations, auth=user.auth) + assert res.status_code == 200 + def test_read_node_not_draft(self, app, user, project_public, draft_registration, url_draft_registrations): + node_admin = AuthUserFactory() + project_public.add_contributor(node_admin, READ) + assert project_public.has_permission(node_admin, READ) is True + assert draft_registration.has_permission(node_admin, READ) is False + res = app.get(url_draft_registrations, auth=node_admin.auth, expect_errors=True) + assert res.status_code == 403 + + def test_read_draft_not_node(self, app, user, project_public, draft_registration, url_draft_registrations): + draft_admin = AuthUserFactory() + draft_registration.add_contributor(draft_admin, READ) + assert project_public.has_permission(draft_admin, READ) is False + assert draft_registration.has_permission(draft_admin, READ) is True + res = app.get(url_draft_registrations, auth=draft_admin.auth) + assert res.status_code == 200 + + def test_can_view_after_added(self, app, schema, draft_registration, url_draft_registrations): + """ + Ensure Draft Registration permissions are no longer based on the branched from project + """ user = AuthUserFactory() project = draft_registration.branched_from project.add_contributor(user, ADMIN) res = app.get(url_draft_registrations, auth=user.auth, expect_errors=True) assert res.status_code == 403 + draft_registration.add_contributor(user, ADMIN) + res = app.get(url_draft_registrations, auth=user.auth) + assert res.status_code == 200 - def test_current_permissions_field(self, app, user_read_contrib, - user_write_contrib, user, draft_registration, url_draft_registrations): + def test_current_permissions_field( + self, app, user_read_contrib, user_write_contrib, user, draft_registration, url_draft_registrations + ): res = app.get(url_draft_registrations, auth=user_read_contrib.auth, expect_errors=False) assert res.json['data']['attributes']['current_user_permissions'] == [READ] @@ -548,9 +567,8 @@ def test_write_contributor_can_update_draft( assert data['attributes']['registration_metadata'] == payload['data']['attributes']['registration_metadata'] -class TestDraftRegistrationDelete(TestDraftRegistrationDelete): +class TestDraftRegistrationDeleteDetail(TestDraftRegistrationDelete): @pytest.fixture() def url_draft_registrations(self, project_public, draft_registration): # Overrides TestDraftRegistrationDelete - return '/{}draft_registrations/{}/'.format( - API_BASE, draft_registration._id) + return f'/{API_BASE}draft_registrations/{draft_registration._id}/' diff --git a/api_tests/draft_registrations/views/test_draft_registration_list.py b/api_tests/draft_registrations/views/test_draft_registration_list.py index ba49520a1744..b6b87931b880 100644 --- a/api_tests/draft_registrations/views/test_draft_registration_list.py +++ b/api_tests/draft_registrations/views/test_draft_registration_list.py @@ -2,20 +2,19 @@ import pytest from framework.auth.core import Auth -from api_tests.nodes.views.test_node_draft_registration_list import ( - TestDraftRegistrationList, - TestDraftRegistrationCreate -) +from django.utils import timezone +from api_tests.nodes.views.test_node_draft_registration_list import AbstractDraftRegistrationTestCase from api.base.settings.defaults import API_BASE from osf.migrations import ensure_invisible_and_inactive_schema -from osf.models import DraftRegistration, NodeLicense, RegistrationProvider +from osf.models import DraftRegistration, NodeLicense, RegistrationProvider, RegistrationSchema from osf_tests.factories import ( RegistrationFactory, CollectionFactory, ProjectFactory, AuthUserFactory, - InstitutionFactory + InstitutionFactory, + DraftRegistrationFactory, ) from osf.utils.permissions import READ, WRITE, ADMIN @@ -28,52 +27,143 @@ def invisible_and_inactive_schema(): @pytest.mark.django_db -class TestDraftRegistrationListNewWorkflow(TestDraftRegistrationList): +class TestDraftRegistrationListTopLevelEndpoint: + @pytest.fixture() def url_draft_registrations(self, project_public): return f'/{API_BASE}draft_registrations/?' - # Overrides TestDraftRegistrationList - def test_osf_group_with_admin_permissions_can_view(self): - # DraftRegistration endpoints permissions are not calculated from the node - return + @pytest.fixture() + def user(self): + return AuthUserFactory() - # Overrides TestDraftRegistrationList - def test_cannot_view_draft_list( - self, app, user_write_contrib, project_public, - user_read_contrib, user_non_contrib, draft_registration, - url_draft_registrations, group, group_mem): + @pytest.fixture() + def user_admin_contrib(self): + return AuthUserFactory() + + @pytest.fixture() + def user_write_contrib(self): + return AuthUserFactory() + + @pytest.fixture() + def user_read_contrib(self): + return AuthUserFactory() + + @pytest.fixture() + def user_non_contrib(self): + return AuthUserFactory() - # test_read_only_contributor_can_view_draft_list + @pytest.fixture() + def group_mem(self): + return AuthUserFactory() + + @pytest.fixture() + def project(self, user): + return ProjectFactory(creator=user) + + @pytest.fixture() + def schema(self): + return RegistrationSchema.objects.get(name='Open-Ended Registration', schema_version=3) + + @pytest.fixture() + def draft_registration(self, user, project, schema, user_write_contrib, user_read_contrib, user_admin_contrib): + draft = DraftRegistrationFactory( + initiator=user, + registration_schema=schema, + branched_from=project + ) + draft.add_contributor(user_read_contrib, permissions=READ) + draft.add_contributor(user_write_contrib, permissions=WRITE) + draft.add_contributor(user_admin_contrib, permissions=ADMIN) + return draft + + def test_read_only_contributor_can_view_draft_list( + self, app, user_read_contrib, draft_registration, url_draft_registrations + ): res = app.get( url_draft_registrations, - auth=user_read_contrib.auth) + auth=user_read_contrib.auth + ) assert res.status_code == 200 assert len(res.json['data']) == 1 - # test_read_write_contributor_can_view_draft_list - res = app.get( - url_draft_registrations, - auth=user_write_contrib.auth) + def test_read_write_contributor_can_view_draft_list( + self, app, user_write_contrib, draft_registration, url_draft_registrations + ): + res = app.get(url_draft_registrations, auth=user_write_contrib.auth) assert res.status_code == 200 assert len(res.json['data']) == 1 - # test_logged_in_non_contributor_can_view_draft_list - res = app.get( - url_draft_registrations, - auth=user_non_contrib.auth, - expect_errors=True) + def test_admin_can_view_draft_list( + self, app, user_admin_contrib, draft_registration, schema, url_draft_registrations + ): + res = app.get(url_draft_registrations, auth=user_admin_contrib.auth) + + assert res.status_code == 200 + data = res.json['data'] + assert len(data) == 1 + + assert schema._id in data[0]['relationships']['registration_schema']['links']['related']['href'] + assert data[0]['id'] == draft_registration._id + assert data[0]['attributes']['registration_metadata'] == {} + + def test_logged_in_non_contributor_has_empty_list( + self, app, user_non_contrib, url_draft_registrations + ): + res = app.get(url_draft_registrations, auth=user_non_contrib.auth) assert res.status_code == 200 assert len(res.json['data']) == 0 - # test_unauthenticated_user_cannot_view_draft_list + def test_unauthenticated_user_cannot_view_draft_list(self, app, url_draft_registrations): res = app.get(url_draft_registrations, expect_errors=True) assert res.status_code == 401 + def test_logged_in_non_contributor_cannot_view_draft_list(self, app, user_non_contrib, url_draft_registrations): + res = app.get(url_draft_registrations, auth=user_non_contrib.auth) + assert res.status_code == 200 + assert len(res.json['data']) == 0 -class TestDraftRegistrationCreateWithNode(TestDraftRegistrationCreate): + def test_deleted_draft_registration_does_not_show_up_in_draft_list(self, app, user, draft_registration, url_draft_registrations): + draft_registration.deleted = timezone.now() + draft_registration.save() + res = app.get(url_draft_registrations, auth=user.auth) + assert res.status_code == 200 + assert not res.json['data'] + + def test_draft_with_registered_node_does_not_show_up_in_draft_list( + self, app, user, project, draft_registration, url_draft_registrations + ): + registration = RegistrationFactory( + project=project, + draft_registration=draft_registration + ) + draft_registration.registered_node = registration + draft_registration.save() + res = app.get(url_draft_registrations, auth=user.auth) + assert res.status_code == 200 + assert not res.json['data'] + + def test_draft_with_deleted_registered_node_shows_up_in_draft_list( + self, app, user, project, draft_registration, schema, url_draft_registrations + ): + registration = RegistrationFactory(project=project, draft_registration=draft_registration) + draft_registration.registered_node = registration + draft_registration.save() + registration.deleted = timezone.now() + registration.save() + draft_registration.deleted = None + draft_registration.save() + res = app.get(url_draft_registrations, auth=user.auth) + assert res.status_code == 200 + data = res.json['data'] + assert len(data) == 1 + assert schema._id in data[0]['relationships']['registration_schema']['links']['related']['href'] + assert data[0]['id'] == draft_registration._id + assert data[0]['attributes']['registration_metadata'] == {} + + +class TestDraftRegistrationCreateWithNode(AbstractDraftRegistrationTestCase): - # Overrides `url_draft_registrations` in `TestDraftRegistrationCreate` @pytest.fixture() def url_draft_registrations(self, project_public): return f'/{API_BASE}draft_registrations/?' @@ -125,29 +215,35 @@ def payload_alt(self, payload, provider_alt): new_payload['data']['relationships']['provider']['data']['id'] = provider_alt._id return new_payload - # Overrides TestDraftRegistrationList - def test_cannot_create_draft_errors(self, app, user, payload_alt, project_public, url_draft_registrations): - # test_cannot_create_draft_from_a_registration + def test_cannot_create_draft_from_a_registration(self, app, user, payload_alt, project_public, url_draft_registrations): registration = RegistrationFactory( - project=project_public, creator=user) + project=project_public, + creator=user + ) payload_alt['data']['relationships']['branched_from']['data']['id'] = registration._id res = app.post_json_api( - url_draft_registrations, payload_alt, auth=user.auth, - expect_errors=True) + url_draft_registrations, + payload_alt, + auth=user.auth, + expect_errors=True + ) assert res.status_code == 404 - # test_cannot_create_draft_from_deleted_node + def test_cannot_create_draft_from_deleted_node(self, app, user, payload_alt, project_public, url_draft_registrations): project = ProjectFactory(is_public=True, creator=user) project.is_deleted = True project.save() payload_alt['data']['relationships']['branched_from']['data']['id'] = project._id res = app.post_json_api( - url_draft_registrations, payload_alt, - auth=user.auth, expect_errors=True) + url_draft_registrations, + payload_alt, + auth=user.auth, + expect_errors=True + ) assert res.status_code == 410 assert res.json['errors'][0]['detail'] == 'The requested node is no longer available.' - # test_cannot_create_draft_from_collection + def test_cannot_create_draft_from_collection(self, app, user, payload_alt, project_public, url_draft_registrations): collection = CollectionFactory(creator=user) payload_alt['data']['relationships']['branched_from']['data']['id'] = collection._id res = app.post_json_api( @@ -155,8 +251,9 @@ def test_cannot_create_draft_errors(self, app, user, payload_alt, project_public expect_errors=True) assert res.status_code == 404 - def test_draft_registration_attributes_copied_from_node(self, app, project_public, - url_draft_registrations, user, payload_alt): + def test_draft_registration_attributes_copied_from_node( + self, app, project_public, url_draft_registrations, user, payload_alt + ): write_contrib = AuthUserFactory() read_contrib = AuthUserFactory() @@ -175,8 +272,9 @@ def test_draft_registration_attributes_copied_from_node(self, app, project_publi project_public.add_contributor(write_contrib, WRITE) project_public.add_contributor(read_contrib, READ) + # Only an admin can create a DraftRegistration res = app.post_json_api(url_draft_registrations, payload_alt, auth=write_contrib.auth, expect_errors=True) - assert res.status_code == 201 + assert res.status_code == 403 res = app.post_json_api(url_draft_registrations, payload_alt, auth=read_contrib.auth, expect_errors=True) assert res.status_code == 403 @@ -196,67 +294,55 @@ def test_draft_registration_attributes_copied_from_node(self, app, project_publi assert 'subjects' in relationships assert 'contributors' in relationships - def test_cannot_create_draft( - self, app, user_write_contrib, - user_read_contrib, user_non_contrib, - project_public, payload_alt, group, - url_draft_registrations, group_mem): - - # test_write_only_contributor_cannot_create_draft + def test_write_only_contributor_cannot_create_draft( + self, app, user_write_contrib, project_public, payload_alt, url_draft_registrations + ): assert user_write_contrib in project_public.contributors.all() res = app.post_json_api( url_draft_registrations, payload_alt, auth=user_write_contrib.auth, - expect_errors=True) - assert res.status_code == 201 + expect_errors=True + ) + assert res.status_code == 403 - # test_read_only_contributor_cannot_create_draft + def test_read_only_contributor_cannot_create_draft( + self, app, user_write_contrib, user_read_contrib, project_public, payload_alt, url_draft_registrations + ): assert user_read_contrib in project_public.contributors.all() res = app.post_json_api( url_draft_registrations, payload_alt, auth=user_read_contrib.auth, - expect_errors=True) + expect_errors=True + ) assert res.status_code == 403 - # test_non_authenticated_user_cannot_create_draft + def test_non_authenticated_user_cannot_create_draft( + self, app, user_write_contrib, payload_alt, group, url_draft_registrations + ): res = app.post_json_api( url_draft_registrations, - payload_alt, expect_errors=True) + payload_alt, + expect_errors=True + ) assert res.status_code == 401 - # test_logged_in_non_contributor_cannot_create_draft + def test_logged_in_non_contributor_cannot_create_draft( + self, app, user_non_contrib, payload_alt, url_draft_registrations + ): + res = app.post_json_api( url_draft_registrations, payload_alt, auth=user_non_contrib.auth, - expect_errors=True) + expect_errors=True + ) assert res.status_code == 403 - # test_group_admin_cannot_create_draft - res = app.post_json_api( - url_draft_registrations, - payload_alt, - auth=group_mem.auth, - expect_errors=True) - assert res.status_code == 201 - - # test_group_write_contrib_cannot_create_draft - project_public.remove_osf_group(group) - project_public.add_osf_group(group, WRITE) - res = app.post_json_api( - url_draft_registrations, - payload_alt, - auth=group_mem.auth, - expect_errors=True) - assert res.status_code == 201 - - def test_create_project_based_draft_does_not_email_initiator( - self, app, user, url_draft_registrations, payload): - post_url = url_draft_registrations + 'embed=branched_from&embed=initiator' + def test_create_project_based_draft_does_not_email_initiator(self, app, user, url_draft_registrations, payload): with mock.patch.object(mails, 'send_mail') as mock_send_mail: - app.post_json_api(post_url, payload, auth=user.auth) + app.post_json_api(f'{url_draft_registrations}?embed=branched_from&embed=initiator', payload, auth=user.auth) assert not mock_send_mail.called @@ -320,7 +406,7 @@ def test_affiliated_institutions_are_copied_from_user(self, app, user, url_draft assert list(draft_registration.affiliated_institutions.all()) == list(user.get_affiliated_institutions()) -class TestDraftRegistrationCreateWithoutNode(TestDraftRegistrationCreate): +class TestDraftRegistrationCreateWithoutNode(AbstractDraftRegistrationTestCase): @pytest.fixture() def url_draft_registrations(self): return f'/{API_BASE}draft_registrations/?' @@ -346,13 +432,14 @@ def test_admin_can_create_draft( assert draft.creator == user assert draft.has_permission(user, ADMIN) is True - def test_create_no_project_draft_emails_initiator( - self, app, user, url_draft_registrations, payload): - post_url = url_draft_registrations + 'embed=branched_from&embed=initiator' - + def test_create_no_project_draft_emails_initiator(self, app, user, url_draft_registrations, payload): # Intercepting the send_mail call from website.project.views.contributor.notify_added_contributor with mock.patch.object(mails, 'send_mail') as mock_send_mail: - resp = app.post_json_api(post_url, payload, auth=user.auth) + resp = app.post_json_api( + f'{url_draft_registrations}?embed=branched_from&embed=initiator', + payload, + auth=user.auth + ) assert mock_send_mail.called # Python 3.6 does not support mock.call_args.args/kwargs @@ -363,7 +450,9 @@ def test_create_no_project_draft_emails_initiator( assert mock_send_kwargs['user'] == user assert mock_send_kwargs['node'] == DraftRegistration.load(resp.json['data']['id']) - def test_create_draft_with_provider(self, app, user, url_draft_registrations, non_default_provider, payload_with_non_default_provider): + def test_create_draft_with_provider( + self, app, user, url_draft_registrations, non_default_provider, payload_with_non_default_provider + ): res = app.post_json_api(url_draft_registrations, payload_with_non_default_provider, auth=user.auth) assert res.status_code == 201 data = res.json['data'] @@ -373,14 +462,9 @@ def test_create_draft_with_provider(self, app, user, url_draft_registrations, no draft = DraftRegistration.load(data['id']) assert draft.provider == non_default_provider - # Overrides TestDraftRegistrationList - def test_cannot_create_draft( - self, app, user_write_contrib, - user_read_contrib, user_non_contrib, - project_public, payload, group, - url_draft_registrations, group_mem): - - # test_write_contrib (no node supplied, so any logged in user can create) + def test_write_contrib(self, app, user, project_public, payload, url_draft_registrations, user_write_contrib): + """(no node supplied, so any logged in user can create) + """ assert user_write_contrib in project_public.contributors.all() res = app.post_json_api( url_draft_registrations, @@ -388,7 +472,9 @@ def test_cannot_create_draft( auth=user_write_contrib.auth) assert res.status_code == 201 - # test_read_only (no node supplied, so any logged in user can create) + def test_read_only(self, app, user, url_draft_registrations, user_read_contrib, project_public, payload): + '''(no node supplied, so any logged in user can create) + ''' assert user_read_contrib in project_public.contributors.all() res = app.post_json_api( url_draft_registrations, @@ -396,24 +482,24 @@ def test_cannot_create_draft( auth=user_read_contrib.auth) assert res.status_code == 201 - # test_non_authenticated_user_cannot_create_draft + def test_non_authenticated_user_cannot_create_draft(self, app, user, url_draft_registrations, payload): res = app.post_json_api( url_draft_registrations, - payload, expect_errors=True) + payload, + expect_errors=True + ) assert res.status_code == 401 - # test_logged_in_non_contributor (no node supplied, so any logged in user can create) + def test_logged_in_non_contributor(self, app, user, url_draft_registrations, user_non_contrib, payload): + '''(no node supplied, so any logged in user can create) + ''' res = app.post_json_api( url_draft_registrations, payload, - auth=user_non_contrib.auth) + auth=user_non_contrib.auth + ) assert res.status_code == 201 - # Overrides TestDraftRegistrationList - def test_cannot_create_draft_errors(self): - # The original test assumes a node is being passed in - return - def test_draft_registration_attributes_not_copied_from_node(self, app, project_public, url_draft_registrations, user, payload): diff --git a/api_tests/files/views/test_file_detail.py b/api_tests/files/views/test_file_detail.py index 752240229292..a80b9319daee 100644 --- a/api_tests/files/views/test_file_detail.py +++ b/api_tests/files/views/test_file_detail.py @@ -31,6 +31,9 @@ SessionStore = import_module(django_conf_settings.SESSION_ENGINE).SessionStore +from addons.base.views import get_authenticated_resource +from framework.exceptions import HTTPError + # stolen from^W^Winspired by DRF # rest_framework.fields.DateTimeField.to_representation def _dt_to_iso8601(value): @@ -639,6 +642,10 @@ def file(self, root_node, user): }).save() return file + @pytest.fixture() + def file_url(self, file): + return f'/{API_BASE}files/{file._id}/' + def test_listing(self, app, user, file): file.create_version(user, { 'object': '0683m38e', @@ -705,6 +712,67 @@ def test_load_and_property(self, app, user, file): expect_errors=True, auth=user.auth, ).status_code == 405 + def test_retracted_registration_file(self, app, user, file_url, file): + resource = RegistrationFactory(is_public=True) + retraction = resource.retract_registration( + user=resource.creator, + justification='Justification for retraction', + save=True, + moderator_initiated=False + ) + + retraction.accept() + resource.save() + resource.refresh_from_db() + + file.target = resource + file.save() + + res = app.get(file_url, auth=user.auth, expect_errors=True) + assert res.status_code == 410 + + def test_retracted_file_returns_410(self, app, user, file_url, file): + resource = RegistrationFactory(is_public=True) + retraction = resource.retract_registration( + user=resource.creator, + justification='Justification for retraction', + save=True, + moderator_initiated=False + ) + + retraction.accept() + resource.save() + resource.refresh_from_db() + + file.target = resource + file.save() + + res = app.get(file_url, auth=user.auth, expect_errors=True) + assert res.status_code == 410 + + def test_get_authenticated_resource_retracted(self): + resource = RegistrationFactory(is_public=True) + + assert resource.is_retracted is False + + retraction = resource.retract_registration( + user=resource.creator, + justification='Justification for retraction', + save=True, + moderator_initiated=False + ) + + retraction.accept() + resource.save() + resource.refresh_from_db() + + assert resource.is_retracted is True + + with pytest.raises(HTTPError) as excinfo: + get_authenticated_resource(resource._id) + + assert excinfo.value.code == 410 + @pytest.mark.django_db class TestFileTagging: @@ -916,20 +984,20 @@ def test_withdrawn_preprint_files(self, app, file_url, preprint, user, other_use # Unauthenticated res = app.get(file_url, expect_errors=True) - assert res.status_code == 401 + assert res.status_code == 410 # Noncontrib res = app.get(file_url, auth=other_user.auth, expect_errors=True) - assert res.status_code == 403 + assert res.status_code == 410 # Write contributor preprint.add_contributor(other_user, WRITE, save=True) res = app.get(file_url, auth=other_user.auth, expect_errors=True) - assert res.status_code == 403 + assert res.status_code == 410 # Admin contrib res = app.get(file_url, auth=user.auth, expect_errors=True) - assert res.status_code == 403 + assert res.status_code == 410 @pytest.mark.django_db class TestShowAsUnviewed: diff --git a/api_tests/institutions/views/test_institution_auth.py b/api_tests/institutions/views/test_institution_auth.py index abb7bce5b3c1..670a6ee31b4d 100644 --- a/api_tests/institutions/views/test_institution_auth.py +++ b/api_tests/institutions/views/test_institution_auth.py @@ -12,7 +12,7 @@ from framework.auth import signals, Auth from framework.auth.core import get_user -from framework.auth.views import send_confirm_email +from framework.auth.views import send_confirm_email_async from osf.models import OSFUser, InstitutionAffiliation, InstitutionStorageRegion from osf.models.institution import SsoFilterCriteriaAction @@ -456,7 +456,7 @@ def test_user_external_unconfirmed(self, app, institution, url_auth_institution) assert user.external_identity # Send confirm email in order to add new email verifications - send_confirm_email( + send_confirm_email_async( user, user.username, external_id_provider=external_id_provider, diff --git a/api_tests/nodes/views/test_node_draft_registration_detail.py b/api_tests/nodes/views/test_node_draft_registration_detail.py index a4acf62be51b..33e0a25b21a9 100644 --- a/api_tests/nodes/views/test_node_draft_registration_detail.py +++ b/api_tests/nodes/views/test_node_draft_registration_detail.py @@ -9,41 +9,21 @@ AuthUserFactory, RegistrationFactory, ) -from osf.utils.permissions import WRITE, READ, ADMIN -from api_tests.nodes.views.test_node_draft_registration_list import DraftRegistrationTestCase +from osf.utils.permissions import ADMIN +from api_tests.nodes.views.test_node_draft_registration_list import AbstractDraftRegistrationTestCase +from framework.auth.core import Auth SCHEMA_VERSION = 2 @pytest.mark.django_db -class TestDraftRegistrationDetail(DraftRegistrationTestCase): - - @pytest.fixture() - def schema(self): - return RegistrationSchema.objects.get( - name='OSF-Standard Pre-Data Collection Registration', - schema_version=SCHEMA_VERSION) - - @pytest.fixture() - def draft_registration(self, user, project_public, schema): - return DraftRegistrationFactory( - initiator=user, - registration_schema=schema, - branched_from=project_public - ) - - @pytest.fixture() - def project_other(self, user): - return ProjectFactory(creator=user) +class TestDraftRegistrationDetail(AbstractDraftRegistrationTestCase): @pytest.fixture() def url_draft_registrations(self, project_public, draft_registration): - return '/{}nodes/{}/draft_registrations/{}/?{}'.format( - API_BASE, project_public._id, draft_registration._id, 'version=2.19') + return f'/{API_BASE}nodes/{project_public._id}/draft_registrations/{draft_registration._id}/?version=2.19' - def test_admin_can_view_draft( - self, app, user, draft_registration, project_public, - schema, url_draft_registrations, group_mem): + def test_node_admin_can_view_draft(self, app, user, draft_registration, schema, url_draft_registrations): res = app.get(url_draft_registrations, auth=user.auth) assert res.status_code == 200 data = res.json['data'] @@ -51,75 +31,55 @@ def test_admin_can_view_draft( assert data['id'] == draft_registration._id assert data['attributes']['registration_metadata'] == {} - def test_admin_group_member_can_view( - self, app, user, draft_registration, project_public, - schema, url_draft_registrations, group_mem): - - res = app.get(url_draft_registrations, auth=group_mem.auth) + def test_read_contributor_can_view_draft(self, app, user_read_contrib, url_draft_registrations): + """ + Note this is the Node permissions not DraftRegistration permission + """ + res = app.get(url_draft_registrations, auth=user_read_contrib.auth) assert res.status_code == 200 - def test_cannot_view_draft( - self, app, user_write_contrib, project_public, - user_read_contrib, user_non_contrib, - url_draft_registrations, group, group_mem): - - # test_read_only_contributor_cannot_view_draft - res = app.get( - url_draft_registrations, - auth=user_read_contrib.auth, - expect_errors=True) - assert res.status_code == 403 - - # test_read_write_contributor_cannot_view_draft - res = app.get( - url_draft_registrations, - auth=user_write_contrib.auth, - expect_errors=True) - assert res.status_code == 403 + def test_write_contributor_can_view_draft(self, app, user_write_contrib, url_draft_registrations): + """ + Note this is the Node permissions not DraftRegistration permission + """ + res = app.get(url_draft_registrations, auth=user_write_contrib.auth) + assert res.status_code == 200 - # test_logged_in_non_contributor_cannot_view_draft - res = app.get( - url_draft_registrations, - auth=user_non_contrib.auth, - expect_errors=True) - assert res.status_code == 403 + def test_logged_in_non_contributor_cannot_view_draft(self, app, user_non_contrib, url_draft_registrations): + res = app.get(url_draft_registrations, auth=user_non_contrib.auth, expect_errors=True) + assert res.status_code == 200 - # test_unauthenticated_user_cannot_view_draft + def test_unauthenticated_user_cannot_view_draft(self, app, url_draft_registrations): res = app.get(url_draft_registrations, expect_errors=True) assert res.status_code == 401 - # test_group_mem_read_cannot_view - project_public.remove_osf_group(group) - project_public.add_osf_group(group, READ) - res = app.get(url_draft_registrations, auth=group_mem.auth, expect_errors=True) - assert res.status_code == 403 - - def test_cannot_view_deleted_draft( - self, app, user, url_draft_registrations): + def test_cannot_view_deleted_draft(self, app, user, url_draft_registrations): res = app.delete_json_api(url_draft_registrations, auth=user.auth) assert res.status_code == 204 res = app.get( url_draft_registrations, auth=user.auth, - expect_errors=True) + expect_errors=True + ) assert res.status_code == 410 - def test_draft_must_be_branched_from_node_in_kwargs( - self, app, user, project_other, draft_registration): - url = '/{}nodes/{}/draft_registrations/{}/'.format( - API_BASE, project_other._id, draft_registration._id) - res = app.get(url, auth=user.auth, expect_errors=True) + def test_draft_must_be_branched_from_node_in_kwargs(self, app, user, project_other, draft_registration): + res = app.get( + f'/{API_BASE}nodes/{project_other._id}/draft_registrations/{draft_registration._id}/', + auth=user.auth, + expect_errors=True + ) assert res.status_code == 400 errors = res.json['errors'][0] assert errors['detail'] == 'This draft registration is not created from the given node.' def test_draft_registration_serializer_usage(self, app, user, project_public, draft_registration): # Tests the usage of DraftRegistrationDetailSerializer for version 2.20 - url_draft_registrations = '/{}nodes/{}/draft_registrations/{}/?{}'.format( - API_BASE, project_public._id, draft_registration._id, 'version=2.20') - - res = app.get(url_draft_registrations, auth=user.auth) + res = app.get( + f'/{API_BASE}nodes/{project_public._id}/draft_registrations/{draft_registration._id}/?version=2.20', + auth=user.auth + ) assert res.status_code == 200 data = res.json['data'] @@ -128,8 +88,7 @@ def test_draft_registration_serializer_usage(self, app, user, project_public, dr assert data['attributes']['description'] assert data['relationships']['affiliated_institutions'] - def test_can_view_after_added( - self, app, schema, draft_registration, url_draft_registrations): + def test_can_view_after_added(self, app, schema, draft_registration, url_draft_registrations): user = AuthUserFactory() project = draft_registration.branched_from project.add_contributor(user, ADMIN) @@ -138,21 +97,11 @@ def test_can_view_after_added( @pytest.mark.django_db -class TestDraftRegistrationUpdate(DraftRegistrationTestCase): - - @pytest.fixture() - def schema(self): - return RegistrationSchema.objects.get( - name='OSF-Standard Pre-Data Collection Registration', - schema_version=SCHEMA_VERSION) +class TestDraftRegistrationUpdate(AbstractDraftRegistrationTestCase): @pytest.fixture() - def draft_registration(self, user, project_public, schema): - return DraftRegistrationFactory( - initiator=user, - registration_schema=schema, - branched_from=project_public - ) + def url_draft_registrations(self, project_public, draft_registration): + return f'/{API_BASE}nodes/{project_public._id}/draft_registrations/{draft_registration._id}/?version=2.19' @pytest.fixture() def reg_schema(self): @@ -170,20 +119,13 @@ def draft_registration_prereg(self, user, project_public, reg_schema): ) @pytest.fixture() - def metadata_registration( - self, metadata, - draft_registration_prereg): + def metadata_registration(self, metadata, draft_registration_prereg): return metadata(draft_registration_prereg) @pytest.fixture() def project_other(self, user): return ProjectFactory(creator=user) - @pytest.fixture() - def url_draft_registrations(self, project_public, draft_registration): - return '/{}nodes/{}/draft_registrations/{}/?{}'.format( - API_BASE, project_public._id, draft_registration._id, 'version=2.19') - @pytest.fixture() def payload(self, draft_registration): return { @@ -267,12 +209,10 @@ def test_draft_must_be_branched_from_node( errors = res.json['errors'][0] assert errors['detail'] == 'This draft registration is not created from the given node.' - def test_cannot_update_draft( - self, app, user_write_contrib, project_public, - user_read_contrib, user_non_contrib, - payload, url_draft_registrations, group, group_mem): + def test_read_only_contributor_cannot_update_draft( + self, app, user_read_contrib, payload, url_draft_registrations, + ): - # test_read_only_contributor_cannot_update_draft res = app.put_json_api( url_draft_registrations, payload, @@ -280,37 +220,20 @@ def test_cannot_update_draft( expect_errors=True) assert res.status_code == 403 - # test_logged_in_non_contributor_cannot_update_draft + def test_logged_in_non_contributor_cannot_update_draft( + self, app, user_non_contrib, payload, url_draft_registrations, + ): res = app.put_json_api( url_draft_registrations, payload, auth=user_non_contrib.auth, - expect_errors=True) - assert res.status_code == 403 - - # test_unauthenticated_user_cannot_update_draft - res = app.put_json_api( - url_draft_registrations, - payload, expect_errors=True) - assert res.status_code == 401 - - # test_osf_group_member_admin_cannot_update_draft - res = app.put_json_api( - url_draft_registrations, - payload, expect_errors=True, - auth=group_mem.auth + expect_errors=True ) assert res.status_code == 403 - # test_osf_group_member_write_cannot_update_draft - project_public.remove_osf_group(group) - project_public.add_osf_group(group, WRITE) - res = app.put_json_api( - url_draft_registrations, - payload, expect_errors=True, - auth=group_mem.auth - ) - assert res.status_code == 403 + def test_unauthenticated_user_cannot_update_draft(self, app, payload, url_draft_registrations): + res = app.put_json_api(url_draft_registrations, payload, expect_errors=True) + assert res.status_code == 401 def test_registration_metadata_does_not_need_to_be_supplied( self, app, user, payload, url_draft_registrations): @@ -528,21 +451,7 @@ def test_multiple_choice_question_value_in_registration_responses_must_match_val @pytest.mark.django_db -class TestDraftRegistrationPatch(DraftRegistrationTestCase): - - @pytest.fixture() - def schema(self): - return RegistrationSchema.objects.get( - name='OSF-Standard Pre-Data Collection Registration', - schema_version=SCHEMA_VERSION) - - @pytest.fixture() - def draft_registration(self, user, project_public, schema): - return DraftRegistrationFactory( - initiator=user, - registration_schema=schema, - branched_from=project_public - ) +class TestDraftRegistrationPatch(AbstractDraftRegistrationTestCase): @pytest.fixture() def reg_schema(self): @@ -562,10 +471,6 @@ def draft_registration_prereg(self, user, project_public, reg_schema): def metadata_registration(self, metadata, draft_registration_prereg): return metadata(draft_registration_prereg) - @pytest.fixture() - def project_other(self, user): - return ProjectFactory(creator=user) - @pytest.fixture() def url_draft_registrations(self, project_public, draft_registration): return '/{}nodes/{}/draft_registrations/{}/?{}'.format( @@ -604,111 +509,69 @@ def test_admin_can_update_draft( assert schema._id in data['relationships']['registration_schema']['links']['related']['href'] assert data['attributes']['registration_metadata'] == payload['data']['attributes']['registration_metadata'] - def test_cannot_update_draft( - self, app, user_write_contrib, - user_read_contrib, user_non_contrib, - payload, url_draft_registrations, group_mem): - - # test_read_only_contributor_cannot_update_draft + def test_read_only_contributor_cannot_update_draft( + self, app, user_read_contrib, payload, url_draft_registrations + ): res = app.patch_json_api( url_draft_registrations, payload, auth=user_read_contrib.auth, - expect_errors=True) + expect_errors=True + ) assert res.status_code == 403 - # test_logged_in_non_contributor_cannot_update_draft + def test_logged_in_non_contributor_cannot_update_draft( + self, app, user_non_contrib, payload, url_draft_registrations + ): res = app.patch_json_api( url_draft_registrations, payload, auth=user_non_contrib.auth, - expect_errors=True) + expect_errors=True + ) assert res.status_code == 403 - # test_unauthenticated_user_cannot_update_draft + def test_unauthenticated_user_cannot_update_draft( + self, app, user_non_contrib, payload, url_draft_registrations + ): res = app.patch_json_api( url_draft_registrations, - payload, expect_errors=True) + payload, + expect_errors=True + ) assert res.status_code == 401 - # group admin cannot update draft - res = app.patch_json_api( - url_draft_registrations, - payload, - auth=group_mem.auth, - expect_errors=True) - assert res.status_code == 403 @pytest.mark.django_db -class TestDraftRegistrationDelete(DraftRegistrationTestCase): - - @pytest.fixture() - def schema(self): - return RegistrationSchema.objects.get( - name='OSF-Standard Pre-Data Collection Registration', - schema_version=SCHEMA_VERSION) - - @pytest.fixture() - def draft_registration(self, user, project_public, schema): - return DraftRegistrationFactory( - initiator=user, - registration_schema=schema, - branched_from=project_public - ) - - @pytest.fixture() - def project_other(self, user): - return ProjectFactory(creator=user) +class TestDraftRegistrationDelete(AbstractDraftRegistrationTestCase): @pytest.fixture() def url_draft_registrations(self, project_public, draft_registration): - return '/{}nodes/{}/draft_registrations/{}/?{}'.format( - API_BASE, project_public._id, draft_registration._id, 'version=2.19') + return f'/{API_BASE}nodes/{project_public._id}/draft_registrations/{draft_registration._id}/?version=2.19' def test_admin_can_delete_draft(self, app, user, url_draft_registrations, project_public): res = app.delete_json_api(url_draft_registrations, auth=user.auth) assert res.status_code == 204 - def test_cannot_delete_draft( - self, app, user_write_contrib, project_public, - user_read_contrib, user_non_contrib, - url_draft_registrations, group, group_mem): - - # test_read_only_contributor_cannot_delete_draft - res = app.delete_json_api( - url_draft_registrations, - auth=user_read_contrib.auth, - expect_errors=True) + def test_read_only_contributor_cannot_delete_draft(self, app, user_read_contrib, url_draft_registrations): + res = app.delete_json_api(url_draft_registrations, auth=user_read_contrib.auth, expect_errors=True) assert res.status_code == 403 - # test_read_write_contributor_cannot_delete_draft - res = app.delete_json_api( - url_draft_registrations, - auth=user_write_contrib.auth, - expect_errors=True) + def test_read_write_draft_contributor_cannot_delete_draft( + self, app, user_write_contrib, url_draft_registrations, project_public + ): + project_public.remove_contributor(user_write_contrib, Auth(user_write_contrib)) # Draft contributor only + res = app.delete_json_api(url_draft_registrations, auth=user_write_contrib.auth, expect_errors=True) assert res.status_code == 403 - # test_logged_in_non_contributor_cannot_delete_draft - res = app.delete_json_api( - url_draft_registrations, - auth=user_non_contrib.auth, - expect_errors=True) + def test_logged_in_non_contributor_cannot_delete_draft(self, app, user_non_contrib, url_draft_registrations): + res = app.delete_json_api(url_draft_registrations, auth=user_non_contrib.auth, expect_errors=True) assert res.status_code == 403 - # test_unauthenticated_user_cannot_delete_draft + def test_unauthenticated_user_cannot_delete_draft(self, app, url_draft_registrations): res = app.delete_json_api(url_draft_registrations, expect_errors=True) assert res.status_code == 401 - # test_group_member_admin_cannot_delete_draft - res = app.delete_json_api(url_draft_registrations, expect_errors=True, auth=group_mem.auth) - assert res.status_code == 403 - - # test_group_member_write_cannot_delete_draft - project_public.remove_osf_group(group) - project_public.add_osf_group(group, WRITE) - res = app.delete_json_api(url_draft_registrations, expect_errors=True, auth=group_mem.auth) - assert res.status_code == 403 - def test_draft_that_has_been_registered_cannot_be_deleted( self, app, user, project_public, draft_registration, url_draft_registrations): reg = RegistrationFactory(project=project_public) diff --git a/api_tests/nodes/views/test_node_draft_registration_list.py b/api_tests/nodes/views/test_node_draft_registration_list.py index 3a3c06f6c18c..66bee66739c3 100644 --- a/api_tests/nodes/views/test_node_draft_registration_list.py +++ b/api_tests/nodes/views/test_node_draft_registration_list.py @@ -28,12 +28,16 @@ def invisible_and_inactive_schema(): @pytest.mark.django_db -class DraftRegistrationTestCase: +class AbstractDraftRegistrationTestCase: @pytest.fixture() def user(self): return AuthUserFactory() + @pytest.fixture() + def user_admin_contrib(self, user): + return AuthUserFactory() + @pytest.fixture() def user_write_contrib(self): return AuthUserFactory() @@ -55,7 +59,7 @@ def group(self, group_mem): return OSFGroupFactory(creator=group_mem) @pytest.fixture() - def project_public(self, user, user_write_contrib, user_read_contrib, group, group_mem): + def project_public(self, user, user_admin_contrib, user_write_contrib, user_read_contrib, group, group_mem): project_public = ProjectFactory(is_public=True, creator=user) project_public.add_contributor( user_write_contrib, @@ -63,11 +67,22 @@ def project_public(self, user, user_write_contrib, user_read_contrib, group, gro project_public.add_contributor( user_read_contrib, permissions=permissions.READ) + project_public.add_contributor( + user_admin_contrib, + permissions=permissions.ADMIN) project_public.save() project_public.add_osf_group(group, permissions.ADMIN) project_public.add_tag('hello', Auth(user), save=True) return project_public + @pytest.fixture() + def draft_registration(self, user, project_public, schema): + return DraftRegistrationFactory( + initiator=user, + registration_schema=schema, + branched_from=project_public + ) + @pytest.fixture() def metadata(self): def metadata(draft): @@ -90,15 +105,96 @@ def metadata(draft): return test_metadata return metadata + @pytest.fixture() + def schema(self): + return RegistrationSchema.objects.get( + name='OSF-Standard Pre-Data Collection Registration', + schema_version=SCHEMA_VERSION + ) + + @pytest.fixture() + def metaschema_open_ended(self): + return RegistrationSchema.objects.get( + name='Open-Ended Registration', + schema_version=OPEN_ENDED_SCHEMA_VERSION + ) + + @pytest.fixture() + def project_other(self, user): + return ProjectFactory(creator=user) + + @pytest.fixture() + def payload(self, metaschema_open_ended, provider): + return { + 'data': { + 'type': 'draft_registrations', + 'attributes': {}, + 'relationships': { + 'registration_schema': { + 'data': { + 'type': 'registration_schema', + 'id': metaschema_open_ended._id + } + }, + 'provider': { + 'data': { + 'type': 'registration-providers', + 'id': provider._id, + } + } + } + } + } + + @pytest.fixture() + def provider(self): + return RegistrationProvider.get_default() + + @pytest.fixture() + def non_default_provider(self, metaschema_open_ended): + non_default_provider = RegistrationProviderFactory() + non_default_provider.schemas.add(metaschema_open_ended) + non_default_provider.save() + return non_default_provider + + @pytest.fixture() + def payload_with_non_default_provider(self, metaschema_open_ended, non_default_provider): + return { + 'data': { + 'type': 'draft_registrations', + 'attributes': {}, + 'relationships': { + 'registration_schema': { + 'data': { + 'type': 'registration_schema', + 'id': metaschema_open_ended._id + } + }, + 'provider': { + 'data': { + 'type': 'registration-providers', + 'id': non_default_provider._id, + } + } + } + } + } + @pytest.mark.django_db -class TestDraftRegistrationList(DraftRegistrationTestCase): +class TestDraftRegistrationList(AbstractDraftRegistrationTestCase): + + @pytest.fixture() + def url_draft_registrations(self, project_public): + # Specifies version to test functionality when using DraftRegistrationLegacySerializer + return f'/{API_BASE}nodes/{project_public._id}/draft_registrations/?version=2.19' @pytest.fixture() def schema(self): return RegistrationSchema.objects.get( name='Open-Ended Registration', - schema_version=OPEN_ENDED_SCHEMA_VERSION) + schema_version=OPEN_ENDED_SCHEMA_VERSION + ) @pytest.fixture() def draft_registration(self, user, project_public, schema): @@ -108,15 +204,9 @@ def draft_registration(self, user, project_public, schema): branched_from=project_public ) - @pytest.fixture() - def url_draft_registrations(self, project_public): - # Specifies version to test functionality when using DraftRegistrationLegacySerializer - return '/{}nodes/{}/draft_registrations/?{}'.format( - API_BASE, project_public._id, 'version=2.19') - - def test_admin_can_view_draft_list( - self, app, user, draft_registration, project_public, - schema, url_draft_registrations): + def test_draft_admin_can_view_draft_list( + self, app, user, draft_registration, project_public, schema, url_draft_registrations + ): res = app.get(url_draft_registrations, auth=user.auth) assert res.status_code == 200 data = res.json['data'] @@ -126,54 +216,27 @@ def test_admin_can_view_draft_list( assert data[0]['id'] == draft_registration._id assert data[0]['attributes']['registration_metadata'] == {} - def test_osf_group_with_admin_permissions_can_view( - self, app, user, draft_registration, project_public, - schema, url_draft_registrations): - group_mem = AuthUserFactory() - group = OSFGroupFactory(creator=group_mem) - project_public.add_osf_group(group, permissions.ADMIN) - res = app.get(url_draft_registrations, auth=group_mem.auth, expect_errors=True) + def test_read_only_contributor_can_view_draft_list(self, app, user_read_contrib, url_draft_registrations): + res = app.get(url_draft_registrations, auth=user_read_contrib.auth) assert res.status_code == 200 - data = res.json['data'] - assert len(data) == 1 - assert schema._id in data[0]['relationships']['registration_schema']['links']['related']['href'] - - def test_cannot_view_draft_list( - self, app, user_write_contrib, project_public, - user_read_contrib, user_non_contrib, - url_draft_registrations, group, group_mem): - # test_read_only_contributor_cannot_view_draft_list - res = app.get( - url_draft_registrations, - auth=user_read_contrib.auth, - expect_errors=True) - assert res.status_code == 403 - - # test_read_write_contributor_cannot_view_draft_list - res = app.get( - url_draft_registrations, - auth=user_write_contrib.auth, - expect_errors=True) - assert res.status_code == 403 + def test_read_write_contributor_can_view_draft_list(self, app, user_write_contrib, url_draft_registrations): + res = app.get(url_draft_registrations, auth=user_write_contrib.auth) + assert res.status_code == 200 - # test_logged_in_non_contributor_cannot_view_draft_list - res = app.get( - url_draft_registrations, - auth=user_non_contrib.auth, - expect_errors=True) - assert res.status_code == 403 + def test_draft_contributor_not_project_contributor_can_view_draft_list(self, app, user_non_contrib, draft_registration, project_public, url_draft_registrations): + draft_registration.add_contributor(contributor=user_non_contrib, auth=Auth(draft_registration.initiator), save=True) + assert not project_public.is_contributor(user_non_contrib) + assert draft_registration.is_contributor(user_non_contrib) + res = app.get(url_draft_registrations, auth=user_non_contrib.auth) + assert res.status_code == 200 + data = res.json['data'] + assert len(data) == 1 - # test_unauthenticated_user_cannot_view_draft_list + def test_unauthenticated_user_cannot_view_draft_list(self, app, url_draft_registrations): res = app.get(url_draft_registrations, expect_errors=True) assert res.status_code == 401 - # test_osf_group_with_read_permissions - project_public.remove_osf_group(group) - project_public.add_osf_group(group, permissions.READ) - res = app.get(url_draft_registrations, auth=group_mem.auth, expect_errors=True) - assert res.status_code == 403 - def test_deleted_draft_registration_does_not_show_up_in_draft_list( self, app, user, draft_registration, url_draft_registrations): draft_registration.deleted = timezone.now() @@ -227,24 +290,11 @@ def test_draft_registration_serializer_usage(self, app, user, project_public, dr @pytest.mark.django_db -class TestDraftRegistrationCreate(DraftRegistrationTestCase): +class TestDraftRegistrationCreate(AbstractDraftRegistrationTestCase): @pytest.fixture() - def provider(self): - return RegistrationProvider.get_default() - - @pytest.fixture() - def non_default_provider(self, metaschema_open_ended): - non_default_provider = RegistrationProviderFactory() - non_default_provider.schemas.add(metaschema_open_ended) - non_default_provider.save() - return non_default_provider - - @pytest.fixture() - def metaschema_open_ended(self): - return RegistrationSchema.objects.get( - name='Open-Ended Registration', - schema_version=OPEN_ENDED_SCHEMA_VERSION) + def url_draft_registrations(self, project_public): + return f'/{API_BASE}nodes/{project_public._id}/draft_registrations/?version=2.19' @pytest.fixture() def payload(self, metaschema_open_ended, provider): @@ -269,37 +319,7 @@ def payload(self, metaschema_open_ended, provider): } } - @pytest.fixture() - def payload_with_non_default_provider(self, metaschema_open_ended, non_default_provider): - return { - 'data': { - 'type': 'draft_registrations', - 'attributes': {}, - 'relationships': { - 'registration_schema': { - 'data': { - 'type': 'registration_schema', - 'id': metaschema_open_ended._id - } - }, - 'provider': { - 'data': { - 'type': 'registration-providers', - 'id': non_default_provider._id, - } - } - } - } - } - - @pytest.fixture() - def url_draft_registrations(self, project_public): - return '/{}nodes/{}/draft_registrations/?{}'.format( - API_BASE, project_public._id, 'version=2.19') - - def test_type_is_draft_registrations( - self, app, user, metaschema_open_ended, - url_draft_registrations): + def test_type_is_draft_registrations(self, app, user, metaschema_open_ended, url_draft_registrations): draft_data = { 'data': { 'type': 'nodes', @@ -335,13 +355,10 @@ def test_admin_can_create_draft( assert data['embeds']['branched_from']['data']['id'] == project_public._id assert data['embeds']['initiator']['data']['id'] == user._id - def test_cannot_create_draft( - self, app, user_write_contrib, - user_read_contrib, user_non_contrib, - project_public, payload, group, - url_draft_registrations, group_mem): + def test_write_only_contributor_cannot_create_draft( + self, app, user_write_contrib, project_public, payload, url_draft_registrations + ): - # test_write_only_contributor_cannot_create_draft assert user_write_contrib in project_public.contributors.all() res = app.post_json_api( url_draft_registrations, @@ -350,7 +367,9 @@ def test_cannot_create_draft( expect_errors=True) assert res.status_code == 403 - # test_read_only_contributor_cannot_create_draft + def test_read_only_contributor_cannot_create_draft( + self, app, user_read_contrib, project_public, payload, url_draft_registrations + ): assert user_read_contrib in project_public.contributors.all() res = app.post_json_api( url_draft_registrations, @@ -359,13 +378,17 @@ def test_cannot_create_draft( expect_errors=True) assert res.status_code == 403 - # test_non_authenticated_user_cannot_create_draft + def test_non_authenticated_user_cannot_create_draft(self, app, user_read_contrib, payload, url_draft_registrations): res = app.post_json_api( url_draft_registrations, - payload, expect_errors=True) + payload, + expect_errors=True + ) assert res.status_code == 401 - # test_logged_in_non_contributor_cannot_create_draft + def test_logged_in_non_contributor_cannot_create_draft( + self, app, user_non_contrib, payload, url_draft_registrations + ): res = app.post_json_api( url_draft_registrations, payload, @@ -373,24 +396,6 @@ def test_cannot_create_draft( expect_errors=True) assert res.status_code == 403 - # test_group_admin_cannot_create_draft - res = app.post_json_api( - url_draft_registrations, - payload, - auth=group_mem.auth, - expect_errors=True) - assert res.status_code == 403 - - # test_group_write_contrib_cannot_create_draft - project_public.remove_osf_group(group) - project_public.add_osf_group(group, permissions.WRITE) - res = app.post_json_api( - url_draft_registrations, - payload, - auth=group_mem.auth, - expect_errors=True) - assert res.status_code == 403 - def test_schema_validation( self, app, user, provider, non_default_provider, payload, payload_with_non_default_provider, url_draft_registrations, metaschema_open_ended): # Schema validation for a default provider without defined schemas with any schema is tested by `test_admin_can_create_draft` diff --git a/api_tests/registrations/views/test_registration_list.py b/api_tests/registrations/views/test_registration_list.py index b9604b83501c..4eca7295f7a5 100644 --- a/api_tests/registrations/views/test_registration_list.py +++ b/api_tests/registrations/views/test_registration_list.py @@ -7,7 +7,7 @@ from api.base.settings.defaults import API_BASE from api.base.versioning import CREATE_REGISTRATION_FIELD_CHANGE_VERSION -from api_tests.nodes.views.test_node_draft_registration_list import DraftRegistrationTestCase +from api_tests.nodes.views.test_node_draft_registration_list import AbstractDraftRegistrationTestCase from api_tests.subjects.mixins import SubjectsFilterMixin from api_tests.registrations.filters.test_filters import RegistrationListFilteringMixin from api_tests.utils import create_test_file @@ -585,7 +585,7 @@ def url(self): return f'/{API_BASE}registrations/' -class TestNodeRegistrationCreate(DraftRegistrationTestCase): +class TestNodeRegistrationCreate(AbstractDraftRegistrationTestCase): """ Tests for creating registration through old workflow - POST NodeRegistrationList diff --git a/api_tests/users/views/test_user_draft_registration_list.py b/api_tests/users/views/test_user_draft_registration_list.py index 272a9a73d9c5..877f219f1c6a 100644 --- a/api_tests/users/views/test_user_draft_registration_list.py +++ b/api_tests/users/views/test_user_draft_registration_list.py @@ -3,7 +3,7 @@ from api.base.settings.defaults import API_BASE from api.users.views import UserDraftRegistrations -from api_tests.nodes.views.test_node_draft_registration_list import DraftRegistrationTestCase +from api_tests.nodes.views.test_node_draft_registration_list import AbstractDraftRegistrationTestCase from api_tests.utils import only_supports_methods from osf.models import RegistrationSchema from osf_tests.factories import ( @@ -17,7 +17,11 @@ @pytest.mark.django_db -class TestDraftRegistrationList(DraftRegistrationTestCase): +class TestUserDraftRegistrationList(AbstractDraftRegistrationTestCase): + + @pytest.fixture() + def url_draft_registrations(self, project_public): + return f'/{API_BASE}users/me/draft_registrations/' @pytest.fixture() def other_admin(self, project_public): @@ -29,7 +33,8 @@ def other_admin(self, project_public): def schema(self): return RegistrationSchema.objects.get( name='Open-Ended Registration', - schema_version=SCHEMA_VERSION) + schema_version=SCHEMA_VERSION + ) @pytest.fixture() def draft_registration(self, user, project_public, schema): @@ -46,10 +51,9 @@ def url_draft_registrations(self, project_public): def test_unacceptable_methods(self): assert only_supports_methods(UserDraftRegistrations, ['GET']) - def test_view_permissions( - self, app, user, other_admin, draft_registration, - user_write_contrib, user_read_contrib, user_non_contrib, - schema, url_draft_registrations): + def test_non_contrib_view_permissions( + self, app, user, other_admin, draft_registration, schema, url_draft_registrations + ): res = app.get(url_draft_registrations, auth=user.auth) assert res.status_code == 200 data = res.json['data'] @@ -58,33 +62,33 @@ def test_view_permissions( assert data[0]['id'] == draft_registration._id assert data[0]['attributes']['registration_metadata'] == {} - res = app.get(url_draft_registrations, auth=user.auth) - assert res.status_code == 200 - data = res.json['data'] - assert len(data) == 1 - assert schema._id in data[0]['relationships']['registration_schema']['links']['related']['href'] - assert data[0]['id'] == draft_registration._id - assert data[0]['attributes']['registration_metadata'] == {} - - # test_read_only_contributor_can_view_draft_list + def test_read_only_contributor_can_view_draft_list( + self, app, draft_registration, user_read_contrib, url_draft_registrations + ): res = app.get( url_draft_registrations, - auth=user_read_contrib.auth) + auth=user_read_contrib.auth + ) assert len(res.json['data']) == 1 - # test_read_write_contributor_can_view_draft_list + def test_read_write_contributor_can_view_draft_list( + self, app, user, other_admin, draft_registration, user_write_contrib, url_draft_registrations + ): res = app.get( url_draft_registrations, - auth=user_write_contrib.auth) + auth=user_write_contrib.auth + ) assert len(res.json['data']) == 1 - # test_logged_in_non_contributor_cannot_view_draft_list + def test_logged_in_non_contributor_cannot_view_draft_list( + self, app, user, draft_registration, user_non_contrib, url_draft_registrations + ): res = app.get( url_draft_registrations, auth=user_non_contrib.auth) assert len(res.json['data']) == 0 - # test_unauthenticated_user_cannot_view_draft_list + def test_unauthenticated_user_cannot_view_draft_list(self, app, url_draft_registrations): res = app.get(url_draft_registrations, expect_errors=True) assert res.status_code == 401 diff --git a/api_tests/users/views/test_user_settings.py b/api_tests/users/views/test_user_settings.py index d62b47405e70..227e0b36a4e5 100644 --- a/api_tests/users/views/test_user_settings.py +++ b/api_tests/users/views/test_user_settings.py @@ -215,7 +215,7 @@ def test_unconfirmed_email_included(self, app, url, payload, user_one, unconfirm assert res.status_code == 200 assert unconfirmed_address in [result['attributes']['email_address'] for result in res.json['data']] - @mock.patch('api.users.serializers.send_confirm_email') + @mock.patch('api.users.serializers.send_confirm_email_async') def test_create_new_email_current_user(self, mock_send_confirm_mail, user_one, user_two, app, url, payload): new_email = 'hhh@wwe.test' payload['data']['attributes']['email_address'] = new_email @@ -228,7 +228,7 @@ def test_create_new_email_current_user(self, mock_send_confirm_mail, user_one, u assert new_email in user_one.unconfirmed_emails assert mock_send_confirm_mail.called - @mock.patch('api.users.serializers.send_confirm_email') + @mock.patch('api.users.serializers.send_confirm_email_async') def test_create_new_email_not_current_user(self, mock_send_confirm_mail, app, url, payload, user_one, user_two): new_email = 'HHH@wwe.test' payload['data']['attributes']['email_address'] = new_email @@ -238,7 +238,7 @@ def test_create_new_email_not_current_user(self, mock_send_confirm_mail, app, ur assert new_email not in user_one.unconfirmed_emails assert not mock_send_confirm_mail.called - @mock.patch('api.users.serializers.send_confirm_email') + @mock.patch('api.users.serializers.send_confirm_email_async') def test_create_email_already_exists(self, mock_send_confirm_mail, app, url, payload, user_one): new_email = 'hello@email.test' Email.objects.create(address=new_email, user=user_one) @@ -582,23 +582,23 @@ def test_resend_confirmation_email(self, mock_send_confirm_email, app, user_one, url = f'{unconfirmed_url}?resend_confirmation=True' res = app.get(url, auth=user_one.auth) assert res.status_code == 202 - assert mock_send_confirm_email.called - call_count = mock_send_confirm_email.call_count + assert mock_send_confirm_email_async.called + call_count = mock_send_confirm_email_async.call_count # make sure setting false does not send confirm email url = f'{unconfirmed_url}?resend_confirmation=False' res = app.get(url, auth=user_one.auth) # should return 200 instead of 202 because nothing has been done assert res.status_code == 200 - assert mock_send_confirm_email.call_count + assert mock_send_confirm_email_async.call_count # make sure normal GET request does not re-send confirmation email res = app.get(unconfirmed_url, auth=user_one.auth) - assert mock_send_confirm_email.call_count == call_count + assert mock_send_confirm_email_async.call_count == call_count assert res.status_code == 200 # resend confirmation with confirmed email address does not send confirmation email url = f'{confirmed_url}?resend_confirmation=True' res = app.get(url, auth=user_one.auth) - assert mock_send_confirm_email.call_count == call_count + assert mock_send_confirm_email_async.call_count == call_count assert res.status_code == 200 diff --git a/docker-compose.yml b/docker-compose.yml index ce4e3ea06189..868922640c7d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -366,8 +366,7 @@ services: environment: DJANGO_SETTINGS_MODULE: api.base.settings volumes: - - ./pyproject.toml:/code/pyproject.toml - - ./poetry.lock:/code/poetry.lock + - ./:/code:cached - osf_requirements_3_12_vol:/python3.12 assets: diff --git a/framework/auth/views.py b/framework/auth/views.py index 1b3f5fc425c1..ac099b487967 100644 --- a/framework/auth/views.py +++ b/framework/auth/views.py @@ -22,6 +22,7 @@ from framework.celery_tasks.handlers import enqueue_task from framework.exceptions import HTTPError from framework.flask import redirect # VOL-aware redirect +from framework.postcommit_tasks.handlers import enqueue_postcommit_task from framework.sessions.utils import remove_sessions_for_user from framework.sessions import get_session from framework.utils import throttle_period_expired @@ -800,7 +801,6 @@ def unconfirmed_email_add(auth=None): 'removed_email': json_body['address'] }, 200 - def send_confirm_email(user, email, renew=False, external_id_provider=None, external_id=None, destination=None): """ Sends `user` a confirmation to the given `email`. @@ -815,7 +815,6 @@ def send_confirm_email(user, email, renew=False, external_id_provider=None, exte :return: :raises: KeyError if user does not have a confirmation token for the given email. """ - confirmation_url = user.get_confirmation_url( email, external=True, @@ -872,6 +871,9 @@ def send_confirm_email(user, email, renew=False, external_id_provider=None, exte logo=logo if logo else settings.OSF_LOGO ) +def send_confirm_email_async(user, email, renew=False, external_id_provider=None, external_id=None, destination=None): + enqueue_postcommit_task(send_confirm_email, (user, email, renew, external_id_provider, external_id, destination), {}) + def register_user(**kwargs): """ @@ -942,7 +944,7 @@ def register_user(**kwargs): ) if settings.CONFIRM_REGISTRATIONS_BY_EMAIL: - send_confirm_email(user, email=user.username) + send_confirm_email_async(user, email=user.username) message = language.REGISTRATION_SUCCESS.format(email=user.username) return {'message': message} else: @@ -991,7 +993,7 @@ def resend_confirmation_post(auth): if user: if throttle_period_expired(user.email_last_sent, settings.SEND_EMAIL_THROTTLE): try: - send_confirm_email(user, clean_email, renew=True) + send_confirm_email_async(user, clean_email, renew=True) except KeyError: # already confirmed, redirect to dashboard status_message = f'This email {clean_email} has already been confirmed.' @@ -1096,7 +1098,7 @@ def external_login_email_post(): # 2. add unconfirmed email and send confirmation email user.add_unconfirmed_email(clean_email, external_identity=external_identity) user.save() - send_confirm_email( + send_confirm_email_async( user, clean_email, external_id_provider=external_id_provider, @@ -1126,7 +1128,7 @@ def external_login_email_post(): # TODO: [#OSF-6934] update social fields, verified social fields cannot be modified user.save() # 3. send confirmation email - send_confirm_email( + send_confirm_email_async( user, user.username, external_id_provider=external_id_provider, diff --git a/osf/management/commands/create_test_notifications.py b/osf/management/commands/create_test_notifications.py new file mode 100644 index 000000000000..8d3549c3f104 --- /dev/null +++ b/osf/management/commands/create_test_notifications.py @@ -0,0 +1,27 @@ +from django.core.management.base import BaseCommand +from osf.models.notifications import NotificationSubscription +from osf.models import OSFUser, Node +from django.utils.crypto import get_random_string +from django.utils import timezone + +class Command(BaseCommand): + help = 'Create duplicate notifications for testing' + + def handle(self, *args, **kwargs): + user = OSFUser.objects.first() + node = Node.objects.first() + event_name = 'file_added' + + for _ in range(3): + unique_id = get_random_string(length=32) + notification = NotificationSubscription.objects.create( + user=user, + node=node, + event_name=event_name, + _id=unique_id, + created=timezone.now() + ) + notification.email_transactional.add(user) + notification.save() + + self.stdout.write(self.style.SUCCESS('Successfully created duplicate notifications')) diff --git a/osf/models/node.py b/osf/models/node.py index d341014f336a..9e342308f441 100644 --- a/osf/models/node.py +++ b/osf/models/node.py @@ -697,8 +697,11 @@ def csl(self): # formats node information into CSL format for citation parsing if doi: csl['DOI'] = doi - if self.logs.exists(): - csl['issued'] = datetime_to_csl(self.logs.latest().date) + if self.registered_date: + csl['issued'] = datetime_to_csl(self.registered_date) + else: + if self.logs.exists(): + csl['issued'] = datetime_to_csl(self.logs.latest().date) return csl diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000000..7fb762e01b0e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,107 @@ +# Base requirements for running the OSF. +# NOTE: This does not include addon, development or release requirements. +# NOTE: When updating pinned version, you may also need to update constraints.txt +# To install addon requirements: inv requirements --addons +# To install dev requirements: inv requirements --dev +# To install release requirements: inv requirements --release +wheel==0.42.0 +invoke==2.2.0 +Werkzeug==3.0.1 +Flask==3.0.2 +Mako==1.3.2 +Markdown==3.5.2 +WTForms==3.1.2 +celery==5.3.6 +kombu==5.3.5 +itsdangerous==2.1.2 +lxml==5.1.0 +mailchimp3==3.0.21 +nameparser==1.1.3 +bcrypt==4.1.2 +python-dateutil==2.9.0 +pytz==2024.1 +bleach==6.1.0 +bleach[css]==6.1.0 +pillow==10.2.0 +Markupsafe==2.1.5 +blinker==1.7.0 +furl==2.1.3 +elasticsearch2==2.5.1 +elasticsearch==6.8.2 # max version to support elasticsearch6 +elasticsearch-dsl==6.4.0 # max version to support elasticsearch6 +elastic-transport==8.13.0 +google-api-python-client==2.123.0 +google-auth==2.29.0 +Babel==2.14.0 +citeproc-py==0.6.0 +boto3==1.34.60 +django-waffle==4.1.0 +pymongo[ocsp]==3.13.0 # install to get bson module +PyYAML==6.0.1 +tqdm==4.66.2 +email-validator==2.1.1 +# Python markdown extensions for comment emails +markdown-del-ins==1.0.0 + +certifi==2024.2.2 +sendgrid==6.11.0 + +requests==2.31.0 +urllib3==1.26.18 # still <2.0 because elasticseach2 lib doesn't supprort urllib3>=2.0 +oauthlib==3.2.2 +requests-oauthlib==1.3.1 +sentry-sdk[django, flask, celery]==2.2.0 +django-redis==5.4.0 + +# API requirements +Django==4.2.13 +djangorestframework==3.15.1 +django-cors-headers==4.3.1 +djangorestframework-bulk==0.2.1 +django-bulk-update==2.2.0 +hashids==1.3.1 +pyjwt==2.8.0 +django-celery-beat==2.6.0 +django-celery-results==2.5.1 +pyjwe==1.0.0 +# Required by pyjwe and ndg-httpsclient +cryptography==42.0.5 +#rpds-py==0.18.0 +jsonschema==4.21.1 + +django-guardian==2.4.0 + +# Admin requirements +# django-webpack-loader==3.1.0 +git+https://github.com/CenterForOpenScience/django-webpack-loader.git@af8438c2da909ec9f2188a6c07c9d2caad0f7e93 # branch is feature/v1-webpack-stats +django-sendgrid-v5==1.2.3 # metadata says python 3.10 not supported, but tests pass + +# Analytics requirements +keen==0.7.0 +geoip2==4.7.0 + +# OSF models +django-typed-models==0.14.0 +django-storages==1.14.3 +google-cloud-storage==2.16.0 # dependency of django-storages, hard-pin to version +django-dirtyfields==1.9.2 +django-extensions==3.2.3 +psycopg2==2.9.9 --no-binary psycopg2 +packaging==24.0 +# Reviews requirements +transitions==0.8.11 + +# identifiers +datacite==1.1.3 + +# metadata +rdflib==7.0.0 +packaging==24.0 + +colorlog==6.8.2 +# Metrics +git+https://github.com/CenterForOpenScience/django-elasticsearch-metrics.git@f5b9312914154e213aa01731e934c593e3434269 # branch is feature/pin-esdsl + +# Impact Metrics CSV Export +djangorestframework-csv==3.0.2 +gevent==24.2.1 diff --git a/requirements/dev.txt b/requirements/dev.txt new file mode 100644 index 000000000000..dafea0f22ace --- /dev/null +++ b/requirements/dev.txt @@ -0,0 +1,38 @@ +-r ../requirements.txt + +# Requirements that are used in the development environment only + +# Testing +pytest==7.4.4 +pytest-socket==0.7.0 +pytest-xdist==3.5.0 +pytest-django==4.8.0 +python-coveralls==2.9.3 # do we need it? +pytest-testmon==2.1.0 +pytest-asyncio==0.23.5 +pytest-html==4.1.1 +factory-boy==3.3.0 +webtest-plus==1.0.0 +Faker==23.2.1 +schema==0.7.4 +responses==0.25.0 + +# Syntax checking +flake8==7.0.0 +flake8-mutable==1.2.0 +pre-commit==3.7.1 + +# Django Debug Toolbar for local development +django-debug-toolbar==4.3.0 + +# Ipdb +ipdb==0.13.13 + +# PyDevD (Remote Debugging) +pydevd==3.0.3 + +# n+1 query detection +nplusone==1.0.0 + +# Profiling +django-silk==5.1.0 diff --git a/requirements/release.txt b/requirements/release.txt new file mode 100644 index 000000000000..e9a4b575f731 --- /dev/null +++ b/requirements/release.txt @@ -0,0 +1,8 @@ +-r ../requirements.txt + +# Requirements to be installed on server deployments + +# newrelic APM agent +newrelic==9.7.1 +# uwsgi +uwsgi==2.0.24 diff --git a/tests/test_views.py b/tests/test_views.py index 406ffb66ede1..2fbce77fc599 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -3256,7 +3256,7 @@ def test_register_email_without_accepted_tos(self, _): user = OSFUser.objects.get(username=email) assert user.accepted_terms_of_service is None - @mock.patch('framework.auth.views.send_confirm_email') + @mock.patch('framework.auth.views.send_confirm_email_async') def test_register_scrubs_username(self, _): url = api_url_for('register_user') name = "Eunice O' \"Cornwallis\"" @@ -3438,8 +3438,8 @@ def test_register_after_being_invited_as_unreg_contributor(self, mock_update_sea assert new_user.check_password(password) assert new_user.fullname == real_name - @mock.patch('framework.auth.views.send_confirm_email') - def test_register_sends_user_registered_signal(self, mock_send_confirm_email): + @mock.patch('framework.auth.views.send_confirm_email_async') + def test_register_sends_user_registered_signal(self, mock_send_confirm_email_async): url = api_url_for('register_user') name, email, password = fake.name(), fake_email(), 'underpressure' with capture_signals() as mock_signals: diff --git a/website/profile/views.py b/website/profile/views.py index 3e377157bb63..c4306b921255 100644 --- a/website/profile/views.py +++ b/website/profile/views.py @@ -14,7 +14,7 @@ from framework.auth.decorators import must_be_logged_in from framework.auth.decorators import must_be_confirmed from framework.auth.exceptions import ChangePasswordError -from framework.auth.views import send_confirm_email +from framework.auth.views import send_confirm_email_async from framework.auth.signals import ( user_account_merged, user_account_deactivated, @@ -83,7 +83,7 @@ def resend_confirmation(auth): # TODO: This setting is now named incorrectly. if settings.CONFIRM_REGISTRATIONS_BY_EMAIL: - send_confirm_email(user, email=address) + send_confirm_email_async(user, email=address) user.email_last_sent = timezone.now() user.save() @@ -166,7 +166,7 @@ def update_user(auth): if not throttle_period_expired(user.email_last_sent, settings.SEND_EMAIL_THROTTLE): raise HTTPError(http_status.HTTP_400_BAD_REQUEST, data={'message_long': 'Too many requests. Please wait a while before adding an email to your account.'}) - send_confirm_email(user, email=address) + send_confirm_email_async(user, email=address) ############ # Username #