From 6916953e75a5210d15bf69ed0520e3acec43adbe Mon Sep 17 00:00:00 2001 From: mkovalua Date: Tue, 31 Mar 2026 18:50:10 +0300 Subject: [PATCH 01/34] implement share_reindex for none and false reindexed resources --- admin/base/urls.py | 1 + admin/share_reindex/__init__.py | 0 admin/share_reindex/urls.py | 9 ++ admin/share_reindex/views.py | 52 +++++++ admin/templates/base.html | 3 + admin/templates/share_reindex/list.html | 133 ++++++++++++++++++ admin/templates/util/pagination.html | 14 +- api/share/utils.py | 38 +++++ ...abstractnode_date_last_indexed_and_more.py | 53 +++++++ osf/models/files.py | 4 +- osf/models/mixins.py | 25 ++++ osf/models/node.py | 4 +- osf/models/preprint.py | 4 +- osf/models/user.py | 4 +- 14 files changed, 329 insertions(+), 15 deletions(-) create mode 100644 admin/share_reindex/__init__.py create mode 100644 admin/share_reindex/urls.py create mode 100644 admin/share_reindex/views.py create mode 100644 admin/templates/share_reindex/list.html create mode 100644 osf/migrations/0038_abstractnode_date_last_indexed_and_more.py diff --git a/admin/base/urls.py b/admin/base/urls.py index d19d2dc638b..9ff5e03a03e 100644 --- a/admin/base/urls.py +++ b/admin/base/urls.py @@ -37,6 +37,7 @@ re_path(r'^cedar_metadata_templates/', include('admin.cedar.urls', namespace='cedar_metadata_templates')), re_path(r'^draft_registrations/', include('admin.draft_registrations.urls', namespace='draft_registrations')), re_path(r'^files/', include('admin.files.urls', namespace='files')), + re_path(r'^share_reindex/', include('admin.share_reindex.urls', namespace='share_reindex')), ]), ), ] diff --git a/admin/share_reindex/__init__.py b/admin/share_reindex/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/admin/share_reindex/urls.py b/admin/share_reindex/urls.py new file mode 100644 index 00000000000..9eeb5d6d290 --- /dev/null +++ b/admin/share_reindex/urls.py @@ -0,0 +1,9 @@ +from django.urls import re_path +from . import views + +app_name = 'admin' + +urlpatterns = [ + re_path(r'^$', views.FailedShareIndexedGuidList.as_view(), name='list'), + re_path(r'^(?P[^/]+)/$', views.FailedShareIndexedGuidReindex.as_view(), name='reindex-share-resource'), +] diff --git a/admin/share_reindex/views.py b/admin/share_reindex/views.py new file mode 100644 index 00000000000..0d68194f7e5 --- /dev/null +++ b/admin/share_reindex/views.py @@ -0,0 +1,52 @@ +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.urls import reverse +from django.shortcuts import redirect +from django.views.generic import ListView, View +from osf.models import Guid +from urllib.parse import urlencode +from api.share.utils import get_not_indexed_guids_for_resource_with_no_indexed_guid, task__reindex_failed_or_not_indexed_resource_into_share + +class FailedShareIndexedGuidList(PermissionRequiredMixin, ListView): + paginate_by = 25 + template_name = 'share_reindex/list.html' + permission_required = 'osf.update_share_reindex' + raise_exception = True + model = Guid + + def get_queryset(self): + resource_type = self.request.GET.get('type', 'projects') + return get_not_indexed_guids_for_resource_with_no_indexed_guid(resource_type) + + def get_context_data(self, **kwargs): + query_set = kwargs.pop('object_list', self.object_list) + page_size = self.get_paginate_by(query_set) + paginator, page, query_set, is_paginated = self.paginate_queryset(query_set, page_size) + kwargs.setdefault('items_to_index', query_set) + kwargs.setdefault('page', page) + resource_type = self.request.GET.get('type', 'projects') + kwargs.setdefault('selected_resource_type', resource_type) + resource_type_detail_mapping = { + 'users': 'users:user', 'preprints': 'preprints:preprint', 'registries': 'nodes:node', 'projects': 'nodes:node', 'files': 'files:file' + } + + kwargs.setdefault('resource_detail', resource_type_detail_mapping.get(resource_type)) + resource_type_guid_reindex = { + 'users': 'users:reindex-share-user', 'preprints': 'preprints:reindex-share-preprint', 'registries': 'nodes:reindex-share-node', 'projects': 'nodes:reindex-share-node' + } + kwargs.setdefault('resource_guid_reindex', resource_type_guid_reindex.get(resource_type)) + status_msg = f'Reindex of {resource_type} started, please check later.' if self.request.GET.get('status') == 'indexing' else '' + kwargs.setdefault('share_reindex_message', status_msg) + return super().get_context_data(**kwargs) + + +class FailedShareIndexedGuidReindex(PermissionRequiredMixin, View): + permission_required = 'osf.update_share_reindex' + raise_exception = True + + def post(self, request, *args, **kwargs): + resource_type = self.kwargs.get('resource_type') + # reindex 100_000 guids in background task for specific resource_type and resource is public + task__reindex_failed_or_not_indexed_resource_into_share.delay(resource_type) + base_url = reverse('share_reindex:list') + query_string = urlencode({'type': resource_type, 'status': 'indexing'}) + return redirect(f"{base_url}?{query_string}") diff --git a/admin/templates/base.html b/admin/templates/base.html index e6f10794c29..5978ce7c800 100644 --- a/admin/templates/base.html +++ b/admin/templates/base.html @@ -316,6 +316,9 @@ {% if perms.osf.change_cedarmetadatatemplate %}
  • Cedar Metadata Templates
  • {% endif %} + {% if perms.osf.update_share_reindex %} +
  • Share Reindex
  • + {% endif %} {% if perms.osf.change_maintenancestate %}
  • Maintenance Alerts
  • {% endif %} diff --git a/admin/templates/share_reindex/list.html b/admin/templates/share_reindex/list.html new file mode 100644 index 00000000000..34083b75696 --- /dev/null +++ b/admin/templates/share_reindex/list.html @@ -0,0 +1,133 @@ +{% extends "base.html" %} +{% load render_bundle from webpack_loader %} +{% load comment_extras %} + +{% load static %} +{% block top_includes %} + +{% endblock %} +{% block title %} + Share Reindex +{% endblock title %} +{% block content %} +

    Share Reindex

    + + {% include "util/pagination.html" with items=page extra_query_params="&type="|add:selected_resource_type %} + + +
    +
    +
    + + +
    +
    +
    + + SHARE Reindex All {{selected_resource_type}} + + + +
    +
    + +
    +

    {{share_reindex_message}}

    +
    + + + + + + + {% if selected_resource_type == 'projects' or selected_resource_type == 'preprints' or selected_resource_type == 'registries' %} + + {% elif selected_resource_type == 'users' %} + + {% else %} + + {% endif %} + + + {% if selected_resource_type != 'files' %} + + {% endif %} + + + + {% for item in items_to_index %} + + + {% if selected_resource_type == 'projects' or selected_resource_type == 'preprints' or selected_resource_type == 'registries' %} + + {% elif selected_resource_type == 'users' %} + + {% else %} + + {% endif %} + + + + {% if selected_resource_type != 'files' %} + + + {% endif %} + + + + {% endfor %} + +
    GuidTitleFullnameNameDatetime Last IndexedReindex
    + + {{item.first_guid}} + + {{item.title}}{{item.fullname}}{{item.name}}{{item.date_last_indexed}} + SHARE Reindex +
    + +{% endblock content %} \ No newline at end of file diff --git a/admin/templates/util/pagination.html b/admin/templates/util/pagination.html index 8a3a2d82ce6..59e726f52c9 100644 --- a/admin/templates/util/pagination.html +++ b/admin/templates/util/pagination.html @@ -3,11 +3,11 @@ +
    +
    +
    + {% include "nodes/add_file_to_osfstorage.html" with node=node %} + {% include "nodes/remove_file_from_osfstorage.html" with node=node %} +
    +

    {{ node.type|cut:'osf.'|title }}: {{ node.title }} ({{node.guid}})

    diff --git a/admin/templates/nodes/remove_file_from_osfstorage.html b/admin/templates/nodes/remove_file_from_osfstorage.html new file mode 100644 index 00000000000..215b60e430a --- /dev/null +++ b/admin/templates/nodes/remove_file_from_osfstorage.html @@ -0,0 +1,36 @@ +{% if node.is_registration and node.archived %} + + Remove File (Osfstorage) + + +{% endif %} \ No newline at end of file diff --git a/admin_tests/nodes/test_views.py b/admin_tests/nodes/test_views.py index 7de8bdb755c..39b871ced3f 100644 --- a/admin_tests/nodes/test_views.py +++ b/admin_tests/nodes/test_views.py @@ -11,6 +11,7 @@ from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType +from addons.osfstorage.models import OsfStorageFile from osf.models import ( AdminLogEntry, NodeLog, @@ -21,9 +22,11 @@ DraftRegistration, ) from admin.nodes.views import ( + NodeAddOsfStorageFileView, NodeConfirmSpamView, NodeDeleteView, NodeRemoveContributorView, + NodeRemoveOsfStorageFileView, NodeView, NodeReindexShare, NodeReindexElastic, @@ -40,6 +43,7 @@ ) from admin_tests.utilities import setup_log_view, setup_view, handle_post_view_request from api_tests.share._utils import mock_update_share +from osf.models.files import Folder from tests.utils import capture_notifications from website import settings from framework.auth.core import Auth @@ -905,3 +909,132 @@ def test_embargo_is_reset_after_reversion(self): self.registration = self.no_moderation_draft.registered_node assert self.registration.sanction is None + + +class TestOsfStorageRegistrationFileAdd(AdminTestCase): + + def _create_file(self, instance, filename): + return OsfStorageFile.create( + target_object_id=instance.id, + target_content_type=ContentType.objects.get_for_model(instance), + path=f'/{filename}', + name=filename, + materialized_path=f'/{filename}' + ) + + @property + def _view(self): + return NodeAddOsfStorageFileView() + + def check_message(self, expected_message): + assert expected_message == self.request._messages._queued_messages[0].message + + def setUp(self): + super().setUp() + self.project = ProjectFactory() + self.project2 = ProjectFactory() + self.registration_registered_from = RegistrationFactory(project=self.project) + self.registration_without_registered_from = RegistrationFactory() + self.registration_without_registered_from.registered_from = None + self.registration_without_registered_from.save() + + self.request = RequestFactory().get('/fake_path') + patch_messages(self.request) + + def test_no_guid_found(self): + self.request.POST = {'file-guid': '1234'} + view = setup_log_view(self._view, self.request, guid=self.registration_registered_from._id) + view.post(self.request) + self.check_message('No file found with the provided guid.') + + def test_no_parent_registration(self): + file = self._create_file(self.project, 'file.txt') + file.save() + file_guid = file.get_guid(create=True) + self.request.POST = {'file-guid': file_guid._id} + view = setup_log_view(self._view, self.request, guid=self.registration_without_registered_from._id) + view.post(self.request) + self.check_message('The registration does not have the parent node.') + + def test_file_is_not_attached_to_parent(self): + file = self._create_file(self.project2, 'file.txt') + file.save() + file_guid = file.get_guid(create=True) + self.request.POST = {'file-guid': file_guid._id} + view = setup_log_view(self._view, self.request, guid=self.registration_registered_from._id) + view.post(self.request) + self.check_message('The file with the provided guid is not part of the parent node.') + + def test_file_is_added_to_registration_osfstorage(self): + file = self._create_file(self.project, 'file.txt') + file.save() + file_guid = file.get_guid(create=True) + self.request.POST = {'file-guid': file_guid._id} + registration_osfstorage = self.registration_registered_from.get_addon('osfstorage') + # create archive folder for a registration + registration_osfstorage.get_root()._create_child(name=registration_osfstorage.archive_folder_name, kind=Folder) + view = setup_log_view(self._view, self.request, guid=self.registration_registered_from._id) + view.post(self.request) + + # check that file is added to registration osfstorage under archive folder + assert registration_osfstorage.get_root().children.get( + name=registration_osfstorage.archive_folder_name + ).children.filter(name=file.name).exists() + + +class TestOsfStorageRegistrationFileRemove(AdminTestCase): + + def _create_file(self, instance, filename): + return OsfStorageFile.create( + target_object_id=instance.id, + target_content_type=ContentType.objects.get_for_model(instance), + path=f'/{filename}', + name=filename, + materialized_path=f'/{filename}' + ) + + @property + def _view(self): + return NodeRemoveOsfStorageFileView() + + def check_message(self, expected_message): + assert expected_message == self.request._messages._queued_messages[0].message + + def setUp(self): + super().setUp() + self.project = ProjectFactory() + self.registration_registered_from = RegistrationFactory(project=self.project) + + self.request = RequestFactory().get('/fake_path') + patch_messages(self.request) + + def test_no_guid_found(self): + self.request.POST = {'file-guid': '1234'} + view = setup_log_view(self._view, self.request, guid=self.registration_registered_from._id) + view.post(self.request) + self.check_message('No file found with the provided guid.') + + def test_file_is_removed_from_registration_osfstorage(self): + file = self._create_file(self.project, 'file2.txt') + file.save() + file_guid = file.get_guid(create=True) + self.request.POST = {'file-guid': file_guid._id} + registration_osfstorage = self.registration_registered_from.get_addon('osfstorage') + # create archive folder for a registration + registration_osfstorage.get_root()._create_child(name=registration_osfstorage.archive_folder_name, kind=Folder) + # add file to osfstorage + view = setup_log_view(NodeAddOsfStorageFileView(), self.request, guid=self.registration_registered_from._id) + view.post(self.request) + + # file exists in archive folder + assert registration_osfstorage.get_root().children.get( + name=registration_osfstorage.archive_folder_name + ).children.filter(name=file.name).exists() + + view = setup_log_view(NodeRemoveOsfStorageFileView(), self.request, guid=self.registration_registered_from._id) + view.post(self.request) + + # check that file is removed from registration osfstorage + assert not registration_osfstorage.get_root().children.get( + name=registration_osfstorage.archive_folder_name + ).children.filter(name=file.name).exists() From 4b51e36b366eb3aab0adea2c4c9f9c5bb7bccd26 Mon Sep 17 00:00:00 2001 From: Ihor Sokhan Date: Thu, 2 Apr 2026 17:35:45 +0300 Subject: [PATCH 03/34] remove registration files by their guid, not project files guids --- admin/nodes/views.py | 2 +- admin_tests/nodes/test_views.py | 15 +++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/admin/nodes/views.py b/admin/nodes/views.py index f9df96cd268..63fddd748ce 100644 --- a/admin/nodes/views.py +++ b/admin/nodes/views.py @@ -895,7 +895,7 @@ def post(self, request, *args, **kwargs): return redirect(self.get_success_url()) file = guid.referent - registration.files.filter(copied_from_id=file.id).delete() + registration.files.filter(id=file.id).delete() messages.success(request, 'The file was successfully removed.') return redirect(self.get_success_url()) diff --git a/admin_tests/nodes/test_views.py b/admin_tests/nodes/test_views.py index 39b871ced3f..3b9a11116dd 100644 --- a/admin_tests/nodes/test_views.py +++ b/admin_tests/nodes/test_views.py @@ -1018,11 +1018,13 @@ def test_file_is_removed_from_registration_osfstorage(self): file = self._create_file(self.project, 'file2.txt') file.save() file_guid = file.get_guid(create=True) - self.request.POST = {'file-guid': file_guid._id} - registration_osfstorage = self.registration_registered_from.get_addon('osfstorage') + # create archive folder for a registration + registration_osfstorage = self.registration_registered_from.get_addon('osfstorage') registration_osfstorage.get_root()._create_child(name=registration_osfstorage.archive_folder_name, kind=Folder) + # add file to osfstorage + self.request.POST = {'file-guid': file_guid._id} view = setup_log_view(NodeAddOsfStorageFileView(), self.request, guid=self.registration_registered_from._id) view.post(self.request) @@ -1030,11 +1032,16 @@ def test_file_is_removed_from_registration_osfstorage(self): assert registration_osfstorage.get_root().children.get( name=registration_osfstorage.archive_folder_name ).children.filter(name=file.name).exists() + # file exists but with different guid + registration_file = self.registration_registered_from.files.get(name=file.name) + registration_file.get_guid(create=True) + # delete this file with different guid + self.request.POST = {'file-guid': registration_file.guids.first()._id} view = setup_log_view(NodeRemoveOsfStorageFileView(), self.request, guid=self.registration_registered_from._id) view.post(self.request) - # check that file is removed from registration osfstorage assert not registration_osfstorage.get_root().children.get( name=registration_osfstorage.archive_folder_name - ).children.filter(name=file.name).exists() + ).children.exists() + assert not self.registration_registered_from.files.exists() From b8409f8141a5777f8d09d242f821fdbb85bab3f0 Mon Sep 17 00:00:00 2001 From: ihorsokhanexoft Date: Tue, 7 Apr 2026 08:31:04 +0300 Subject: [PATCH 04/34] [ENG-10683] Fixed typo in registration schemas permission (#11660) * [ENG-10042] Fix/eng 10042 (#11551) * Update RegistrationActionSerializer * Update RegistrationActionSerializer * [ENG-10042] Fix/eng 10042 (#11593) Revert BE changes meant to fix 10042 and replace with FE changes. * Merge develop into Feature/pbs 26 2 (#11642) * [ENG-10083] Add type<-->is_digest "relation" and update subscription (#11575) * Add is_digest_type property to NotificationType and log message in emit method * Fix is_digest handling in NotificationType.emit method * Log message update in NotificationType.emit method for is_digest handling * fix unit tests --------- Co-authored-by: Ostap Zherebetskyi * Add notification subscriptions de-duplication v2 command (#11576) Co-authored-by: Ostap Zherebetskyi * [ENG-10190][ENG-10214] Permanently update a couple of templates to match admin/admin changes Update archive_size_exceeded_user and provider_reviews_resubmission_confirmation templates * Update changelog and bump version * Exclude spam and deleted Registration from queryset (#11572) * [ENG-10135] - Fix/eng 10135 (#11587) * Exclude spam and deleted Registration from queryset * Update RegistrationActionList to check deleted flag * Fix tests * Fix test * Feature/fair signposting (#11599) * linkset initial implementation * linkset initial implementation (updating data extraction and adding serialization) * and \n in EOF instead of % * update code * get file mediatype from metadata * add unittests for linkset approach (TODO: ?) * update code * update code and unit tests and * update file related code and unit tests * refactor code after CR | implement 'describes' | check file type differs from parent project / registry / preprint * update files to compare on unittests run * implement datacite - scheme type mapping for linkset draft version (need tests updates and maybe some type yield logic ) * Update osf/metadata/osf_gathering.py Co-authored-by: abram axel booth * Update osf/metadata/serializers/linkset.py Co-authored-by: abram axel booth * implement datacite - schema mapping * update tests expected results * resolve CR * refactor code | resolve CR * remove redundant describes from file response (#11590) * [ENG-10256] 2.1.9 BE: Fix permission issue where users without permission to an object can access the metadata (#11588) * Fix permission issue where users without permission to an object can access the metadata (add decorator is_contributor_or_public_resource) * refactor code * resolve CR | refactor code * resolve CR | add unittests * add *args, **kwargs for decorated view * [ENG-10167] 2.1.6 BE: add link header to guid metadata endpoints (#11594) * add link header to guid metadata endpoints for registry and project * update code * avoid Component Registration/Project JSON Contains type: null (#11597) * [ENG-10168] 2.1.7 BE: add link header to cedar metadata records endpoints (#11596) * add link header to guid metadata endpoints for registry and project * update code * add link header to cedar metadata records endpoints * change ResearchProject for CreativeWork for projects (#11603) * [ENG-10169] 2.1.8 BE: add link header to file download URL (#11600) * avoid Component Registration/Project JSON Contains type: null * add link header to file download URL * update unittests * resolve CR --------- Co-authored-by: mkovalua Co-authored-by: abram axel booth * Bump versio no. Add CHANGELOG * [ENG-10054] feature/ror-migration (#11610) * feat(osf): script to migrate Crossref Funder IDs to ROR IDs * feat(osf): Fix fot the script to migrate Crossref Funder IDs to ROR IDs * feat(osf): Update OSF metadata model code and tests for ROR funder identifier support * feat(osf): Add DataCite client tests for ROR funder identifier support * feat(osf): update migration script to remove unmapped crossref funders * add another stat to the migration script --------- Co-authored-by: Andriy Sheredko * bump version & update changelog * add command to migrate ror funder names * bump version & update changelog * [ENG-10538] Post-NR Project PR (#11623) * Create global_file_updated and global_reviews subscriptions if missing * Use USER_FILE_UPDATED for group global_file_updated * Use REVIEWS_SUBMISSION_STATUS for group global_reviews * Add missing `is_digest=True` for new OSF user subscriptions * Extend otf subscription creation to apply to _node_file_updated group * Fix typo for `_is_digest` * add is_digest_type property * Add is_digest_type property to NotificationType and log message in emit method * Fix is_digest handling in NotificationType.emit method * Log message update in NotificationType.emit method for is_digest handling * fix unit tests * Move set-deafult-subscriptions out of non-effective try-except * Add fake_sent field to Notification model and update notification creation logic * add unique_together constraint * Add 'PARTIAL_SUCCESS' status to EmailTask model and update email task handling logic * NR migration [ENG-10040, ENG-10025, ENG-9854] * Remove subscription if notifications.tasks.send_moderator_email_task fails with permission error * Apply suggestion from @Ostap-Zherebetskyi * remove datetime * Add 'no_login_email_last_sent' field to OSFUser and update email task logic * Enforce and improve permission check for subscriptions * Fix typo in annotated_obj_qs for NODE_FILE_UPDATED * Add unit tests for testing node_file_updated subscription detail * Implement notifications cleanup task and related settings; add tests for functionality * Fix legacy subscription ID for NODE_FILE_UPDATED: "guid_files_updated" * Fix duplicate and mismatched type NODE_FILE(S)_UPDATED * removed email.py * Fix annotated qs for global reviews and update unit tests * Update tests for node_file(s)_updated subscription detail * Rename fixtures for notification subscription detail tests * Annotate with legacy_id for serializer to handle created subscriptions * Add unit tests for creating missing subscriptions on the fly * clear useless code * Rename migration name for NR post-release * Improve unit test: test_emit_frequency_none * Remove `seen` from `Notification` and re-make migrations * `mark_sent()` now handles `fake_sent=True`, and only `save()` once * Update default settings * remove useless import * Enhance SubscriptionList queryset with additional content types and refactor notification type handling * fix unit test * Refactor SubscriptionList queryset to use a single provider content type and update related notification handling; add script to update notification subscriptions' content types. * fix CR comments * Update comments * split into 3 files * remove populate_notification_subscriptions * Renamed files, refactor of populate notification subscriptions user global file updated * added try/except, added timers * converted populate_notification_subscriptions_user_global_reviews.py * fix batch time execution * converted populate_notification_subscriptions_node_file_updated * convert to separate update and create scripts * updated, added parameters * move to remove_after_use * add time track to last batch, fix proper time track for batch in node_file_updated * convert to use review_nt * Fix unit tests due to new constraints * Move missing subscription creation to a helper function in utils * Subscription list view now creates missing attributes on-the-fly * Note: only works for when filter on "legacy id"; logs message to sentry for tracking purpose when filter on "event names". * Fix broken `.exists()` due to complex annotated QS with `.distinct()` * fix names of tasks * Add notification subscriptions de-duplication v2 command * fix naming * Add deduplication command to notification migration * Fix notification handling by updating legacy ID suffix from '_files_updated' to '_file_updated' across utils and views; adjust related test cases accordingly. * fix unit tests * Update the name of the NotificationType NODE_FILE_UPDATED to be consistent * Rename notification type from node_files_updated to node_file_updated for consistency * clean keys * fix naming in templates, event_context * remove leftover comments * Refactor notification type references to use NotificationTypeEnum - Updated all instances of NotificationType.Type to NotificationTypeEnum in test files and application code. - Ensured consistency in notification type usage across various tests including auth, claims, events, and more. - This change improves clarity and maintainability by standardizing the notification type references. * Fix notification type reference for FILE_UPDATED * Refactor notification type references to use NotificationTypeEnum across views and scripts * fix unit tests * remove imports * fix USER_CROSSREF_DOI_PENDING event_context * sync naming * added explanation * Add Celery task to disable removed beat tasks from PeriodicTask entries * Apply suggestion from @Ostap-Zherebetskyi * Apply suggestion from @cslzchen * Add migrations to remove duplicate notification subscriptions and refactor notification model * removed comment * Refactor no-login email filtering to ensure last sent date is before last login date * Add test to exclude users logged in before no_login_email_last_sent * Refactor no-login email user filtering to exclude those with pending EmailTasks and recent no-login emails * Fix CollectionSubmission URLs to use absolute_url method for consistency * Refactor logo handling in notification methods to improve consistency and readability * Add management command to sync notification templates * Apply suggestion from @cslzchen * Add management command for deleting withdrawn or failed registration files to CeleryConfig * fix: update logo handling in send_moderator_email_task for favicon support --------- Co-authored-by: Longze Chen Co-authored-by: Ostap Zherebetskyi Co-authored-by: Bohdan Odintsov * Updated cut-off time and added comments for NR settings * Update changelog and bump version * resolve merge conflicts * respond to CR comments * fix tests * fix tests again --------- Co-authored-by: Longze Chen Co-authored-by: Ostap Zherebetskyi Co-authored-by: Vlad0n20 <137097005+Vlad0n20@users.noreply.github.com> Co-authored-by: futa-ikeda <51409893+futa-ikeda@users.noreply.github.com> Co-authored-by: mkovalua Co-authored-by: abram axel booth Co-authored-by: Fitz Elliott Co-authored-by: Andriy Sheredko Co-authored-by: Bohdan Odintsov * Feature/pbs 26 2 (#11647) * update yarn lock * update admin yarn.lock * remove redundant merge conflicts changes --------- Co-authored-by: Vlad0n20 <137097005+Vlad0n20@users.noreply.github.com> Co-authored-by: Yuhuai Liu Co-authored-by: Longze Chen Co-authored-by: Ostap Zherebetskyi Co-authored-by: futa-ikeda <51409893+futa-ikeda@users.noreply.github.com> Co-authored-by: mkovalua Co-authored-by: abram axel booth Co-authored-by: Fitz Elliott Co-authored-by: Andriy Sheredko Co-authored-by: Bohdan Odintsov --- admin/registration_schemas/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/admin/registration_schemas/views.py b/admin/registration_schemas/views.py index aa205839029..df8aef09887 100644 --- a/admin/registration_schemas/views.py +++ b/admin/registration_schemas/views.py @@ -17,7 +17,7 @@ class RegistrationSchemaDetailView(FormView, PermissionRequiredMixin, TemplateVi Allows authorized users to view and edit some attributes of a Registration Schema. """ template_name = 'registration_schemas/registration_schema.html' - permission_required = 'osf.view_registration_schema' + permission_required = 'osf.view_registrationschema' raise_exception = True form_class = RegistrationSchemaEditForm @@ -143,7 +143,7 @@ class RegistrationSchemaListView(PermissionRequiredMixin, ListView): Allows authorized users to view all Registration Schema. """ template_name = 'registration_schemas/registration_schema_list.html' - permission_required = 'osf.view_registration_schema' + permission_required = 'osf.view_registrationschema' raise_exception = True def get_queryset(self): From 4779c8957e85b7df53f3a182e1b46d8d57957cfc Mon Sep 17 00:00:00 2001 From: antkryt Date: Tue, 7 Apr 2026 09:01:47 +0300 Subject: [PATCH 05/34] [ENG-10615] Have test runner check for uncreated migrations (#11643) * check migrations when running addons tests * add missing args --- tasks/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tasks/__init__.py b/tasks/__init__.py index c78aab98163..5fef6fb834e 100755 --- a/tasks/__init__.py +++ b/tasks/__init__.py @@ -238,6 +238,12 @@ def syntax(ctx): ctx.run('pre-commit run --all-files --show-diff-on-failure', echo=True) +@task +def check_migrations(ctx): + """Check for missing Django migrations.""" + ctx.run('python3 manage.py --no-init-app makemigrations --settings api.base.settings --check', echo=True) + + @task(aliases=['req']) def requirements(ctx, base=False, addons=False, release=False, dev=True, all=True): """Install python dependencies. @@ -520,6 +526,7 @@ def test_ci_addons(ctx, numprocesses=None, coverage=False, testmon=False, junit= """ #ci_setup(ctx) syntax(ctx) + check_migrations(ctx) test_addons(ctx, numprocesses=numprocesses, coverage=coverage, testmon=testmon, junit=junit) @task From 2e0046d8cc8446f544a11417263d08365e3fc3a7 Mon Sep 17 00:00:00 2001 From: Vlad0n20 <137097005+Vlad0n20@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:40:42 +0300 Subject: [PATCH 06/34] [ENG-10575] - Update command, add tests (#11641) * Update command, add tests * Fix test --- osf/management/commands/force_archive.py | 9 +- .../management_commands/test_force_archive.py | 98 ++++++++++++++++++- 2 files changed, 104 insertions(+), 3 deletions(-) diff --git a/osf/management/commands/force_archive.py b/osf/management/commands/force_archive.py index 42d6c213fa4..7b6d18b6c65 100644 --- a/osf/management/commands/force_archive.py +++ b/osf/management/commands/force_archive.py @@ -329,7 +329,11 @@ def handle_file_operation(file_tree, reg, file_obj, log, obj_cache): elif log.action == 'osf_storage_file_updated': return modify_file_tree_recursive(reg._id, file_tree, file_obj, cached=bool(file_obj._id in obj_cache), revert=True) elif log.action == 'addon_file_moved': - parent = BaseFileNode.objects.get(_id__in=obj_cache, name='/{}'.format(log.params['source']['materialized']).rstrip('/').split('/')[-2]) + if '/' not in log.params['source']['materialized'].rstrip('/'): + # Root dir — parent is the root storage folder (name='') + parent = BaseFileNode.objects.get(_id__in=obj_cache, name='') + else: + parent = BaseFileNode.objects.get(_id__in=obj_cache, name='/{}'.format(log.params['source']['materialized']).rstrip('/').split('/')[-2]) return modify_file_tree_recursive(reg._id, file_tree, file_obj, cached=bool(file_obj._id in obj_cache), move_under=parent) elif log.action == 'addon_file_renamed': return modify_file_tree_recursive(reg._id, file_tree, file_obj, cached=bool(file_obj._id in obj_cache), rename=log.params['source']['name']) @@ -356,7 +360,8 @@ def revert_log_actions(file_tree, reg, obj_cache, permissible_addons): def build_file_tree(reg, node_settings, *args, **kwargs): n = reg.registered_from - obj_cache = set(n.files.values_list('_id', flat=True)) + ct_id = ContentType.objects.get_for_model(n.__class__()).id + obj_cache = set(BaseFileNode.objects.filter(target_object_id=n.id, target_content_type_id=ct_id).values_list('_id', flat=True)) def _recurse(file_obj, node): ct_id = ContentType.objects.get_for_model(node.__class__()).id diff --git a/osf_tests/management_commands/test_force_archive.py b/osf_tests/management_commands/test_force_archive.py index cdd134a02d3..301c8e04c03 100644 --- a/osf_tests/management_commands/test_force_archive.py +++ b/osf_tests/management_commands/test_force_archive.py @@ -4,7 +4,7 @@ from addons.osfstorage.models import OsfStorageFile, OsfStorageFolder from osf.models import NodeLog, BaseFileNode from osf.models.files import TrashedFileNode, TrashedFolder -from osf.management.commands.force_archive import get_file_obj_from_log, build_file_tree, DEFAULT_PERMISSIBLE_ADDONS +from osf.management.commands.force_archive import get_file_obj_from_log, build_file_tree, handle_file_operation, DEFAULT_PERMISSIBLE_ADDONS from osf_tests.factories import NodeFactory, RegistrationFactory @@ -192,3 +192,99 @@ def get_root(self): names = [child['object'].name for child in tree['children']] assert 'file1.txt' in names assert 'file2.txt' not in names + + @pytest.mark.django_db + def test_obj_cache_includes_folders(self, node, reg, permissible_addons): + """ + Regression: n.files is a GenericRelation to OsfStorageFile only, so folder _ids were + never in obj_cache. The fix uses BaseFileNode.objects.filter(...) which includes folders. + """ + from django.contrib.contenttypes.models import ContentType + + folder = OsfStorageFolder.create(target=node, name='myfolder') + folder.save() + root_folder = OsfStorageFolder.create(target=node, name='') + root_folder.save() + + # Demonstrate the BUG: n.files (GenericRelation to OsfStorageFile) omits folders + old_obj_cache = set(node.files.values_list('_id', flat=True)) + assert folder._id not in old_obj_cache, 'Folders must NOT appear via n.files (demonstrating the bug)' + assert root_folder._id not in old_obj_cache, 'Root folder must NOT appear via n.files (demonstrating the bug)' + + # Demonstrate the FIX: BaseFileNode.objects.filter(...) includes files AND folders + ct_id = ContentType.objects.get_for_model(node.__class__()).id + new_obj_cache = set( + BaseFileNode.objects.filter( + target_object_id=node.id, + target_content_type_id=ct_id, + ).values_list('_id', flat=True) + ) + assert folder._id in new_obj_cache, 'Folders must appear in fixed obj_cache' + assert root_folder._id in new_obj_cache, 'Root folder must appear in fixed obj_cache' + + +class TestHandleFileOperation: + + @pytest.fixture + def node(self): + return NodeFactory(title='Test Node', category='project') + + @pytest.fixture + def reg(self, node): + return RegistrationFactory(project=node, registered_date=timezone.now()) + + @pytest.mark.django_db + def test_addon_file_moved_from_root_dir(self, node, reg): + """ + Regression: when materialized='/' (root dir moved between nodes), the old code did: + '/{}'.format('/').rstrip('/') -> '' + ''.split('/') -> [''] (only 1 element) + [''][-2] -> IndexError: list index out of range + The fix detects the root-dir case and looks up the folder by name='' directly. + """ + from django.contrib.contenttypes.models import ContentType + + root_folder = node.get_addon('osfstorage').root_node + file = OsfStorageFile.create(target=node, name='file.txt') + file.save() + file.move_under(root_folder) + + ct_id = ContentType.objects.get_for_model(node.__class__()).id + obj_cache = set( + BaseFileNode.objects.filter( + target_object_id=node.id, + target_content_type_id=ct_id, + ).values_list('_id', flat=True) + ) + + file_tree = { + 'object': root_folder, + 'name': '', + 'deleted': False, + 'version': None, + 'children': [ + {'object': file, 'name': 'file.txt', 'deleted': False, 'version': None, 'children': []} + ] + } + + # materialized='/' is the actual crash case: moving a root dir between nodes + log = NodeLog.objects.create( + node=node, + action='addon_file_moved', + params={ + 'source': { + 'materialized': '/', # root dir: triggers IndexError in old code + 'name': '', + }, + 'destination': { + 'materialized': '/', + 'name': '', + } + }, + date=timezone.now(), + ) + + # Old code: '/{}'.format('/').rstrip('/') = '' -> ''.split('/')[-2] -> IndexError + # Fixed code: detects no '/' in materialized.rstrip('/') and uses name='' directly + result_tree, noop = handle_file_operation(file_tree, reg, file, log, obj_cache) + assert result_tree is not None From f67d8e63a8a7baeb993b74aacd1de826fbf67568 Mon Sep 17 00:00:00 2001 From: antkryt Date: Tue, 7 Apr 2026 16:07:30 +0300 Subject: [PATCH 07/34] make confirm ham a celery task (#11666) --- admin/users/views.py | 15 +++++--- admin_tests/users/test_views.py | 10 +++-- api/users/tasks.py | 37 +++++++++++++++++++ notifications.yaml | 7 ++++ osf/models/notification_type.py | 1 + osf/models/user.py | 29 ++++++++++++++- .../user_confirm_ham_report.html.mako | 18 +++++++++ 7 files changed, 107 insertions(+), 10 deletions(-) create mode 100644 website/templates/user_confirm_ham_report.html.mako diff --git a/admin/users/views.py b/admin/users/views.py index 6affeef300c..894efddfc58 100644 --- a/admin/users/views.py +++ b/admin/users/views.py @@ -50,7 +50,7 @@ from admin.nodes.views import NodeAddSystemTag, NodeRemoveSystemTag from admin.base.views import GuidView from api.users.services import send_password_reset_email -from api.users.tasks import merge_users +from api.users.tasks import merge_users, confirm_user_ham from website.settings import DOMAIN, OSF_SUPPORT_EMAIL from django.urls import reverse_lazy @@ -264,14 +264,19 @@ class UserConfirmHamView(UserMixin, View): def post(self, request, *args, **kwargs): user = self.get_object() - user.is_registered = True # back in the day spam users were de-registered - user.confirm_ham(save=True) + + ham_task = confirm_user_ham.delay(user._id, initiator_guid=request.user._id) update_admin_log( user_id=request.user.id, object_id=user._id, object_repr='User', - message=f'Confirmed Ham: {user._id} when user {user._id} marked as spam', - action_flag=CONFIRM_SPAM + message=f'Queued Confirm HAM task for user {user._id} (task id: {ham_task.id}).', + action_flag=CONFIRM_HAM + ) + + messages.success( + request, + f'Confirm HAM started in the background for user {user._id}. Task id: {ham_task.id}.' ) return redirect(self.get_success_url()) diff --git a/admin_tests/users/test_views.py b/admin_tests/users/test_views.py index c913a8e2347..a6ea9f07638 100644 --- a/admin_tests/users/test_views.py +++ b/admin_tests/users/test_views.py @@ -218,18 +218,22 @@ class TestHamUserRestore(AdminTestCase): def setUp(self): self.user = UserFactory() self.request = RequestFactory().post('/fake_path') + patch_messages(self.request) self.view = views.UserConfirmHamView self.view = setup_log_view(self.view, self.request, guid=self.user._id) - def test_enable_user(self): + @mock.patch('api.users.tasks.confirm_user_ham.delay') + def test_enable_user(self, mock_confirm_user_ham_delay): self.user.is_disabled = True self.user.save() assert self.user.is_disabled self.view().post(self.request) self.user.reload() - assert not self.user.is_disabled - assert self.user.spam_status == SpamStatus.HAM + mock_confirm_user_ham_delay.assert_called_with( + self.user._id, + initiator_guid=mock.ANY, + ) class TestDisableSpamUser(AdminTestCase): diff --git a/api/users/tasks.py b/api/users/tasks.py index e11ef0665e9..c207bd3adaa 100644 --- a/api/users/tasks.py +++ b/api/users/tasks.py @@ -3,6 +3,9 @@ from framework import sentry from framework.celery_tasks import app as celery_app +from osf.models import OSFUser +from osf.models.notification_type import NotificationTypeEnum + logger = logging.getLogger(__name__) @@ -32,3 +35,37 @@ def merge_users(merger_guid: str, mergee_guid: str): except Exception as exc: logger.exception(f'Unexpected error during background user merge: merger={merger_guid}, mergee={mergee_guid}') sentry.log_exception(exc) + + +@celery_app.task(bind=True, name='api.users.tasks.confirm_user_ham') +def confirm_user_ham(self, user_guid: str, initiator_guid: str | None = None): + initiator_user = OSFUser.load(initiator_guid) if initiator_guid else None + failed_ham = [] + try: + user = OSFUser.objects.get(guids___id=user_guid) + except OSFUser.DoesNotExist as exc: + sentry.log_exception(exc) + return str(exc) + else: + try: + user.is_registered = True # back in the day spam users were de-registered + failed_ham = user.confirm_ham(save=True, train_spam_services=False) + except Exception as exc: + sentry.log_exception(exc) + + try: + if initiator_user: + NotificationTypeEnum.USER_CONFIRM_HAM_REPORT.instance.emit( + user=initiator_user, + message_frequency='instantly', + event_context={ + 'user_guid': user._id, + 'failed_ham': ', '.join(failed_ham), + }, + save=False, + ) + except Exception as exc: + logger.exception('Failed to send HAM confirmation report email') + sentry.log_exception(exc) + + return True diff --git a/notifications.yaml b/notifications.yaml index b867c3a2d38..e3b7b286a30 100644 --- a/notifications.yaml +++ b/notifications.yaml @@ -318,6 +318,13 @@ notification_types: template: 'website/templates/tou_notif.html.mako' tests: [] + - name: user_confirm_ham_report + subject: 'Confirm HAM report for {user_guid}' + __docs__: Summary report sent to the admin who triggered Confirm HAM. + object_content_type_model_name: osfuser + template: 'website/templates/user_confirm_ham_report.html.mako' + tests: [] + - name: desk_registration_bulk_upload_product_owner subject: 'Registry Could Not Bulk Upload Registrations' object_content_type_model_name: osfuser diff --git a/osf/models/notification_type.py b/osf/models/notification_type.py index 538dfa969df..e0afdab7aea 100644 --- a/osf/models/notification_type.py +++ b/osf/models/notification_type.py @@ -78,6 +78,7 @@ class NotificationTypeEnum(str, Enum): USER_SPAM_FILES_DETECTED = 'user_spam_files_detected' USER_CROSSREF_DOI_PENDING = 'user_crossref_doi_pending' USER_TERMS_OF_USE_UPDATED = 'user_terms_of_use_updated' # added as a placeholder + USER_CONFIRM_HAM_REPORT = 'user_confirm_ham_report' # Node notifications NODE_FILE_UPDATED = 'node_file_updated' diff --git a/osf/models/user.py b/osf/models/user.py index 6b19a5e6f8d..52fa96e098a 100644 --- a/osf/models/user.py +++ b/osf/models/user.py @@ -1478,11 +1478,36 @@ def confirm_ham(self, save=False, train_spam_services=False): self.reactivate_account() super().confirm_ham(save=save, train_spam_services=train_spam_services) + failed_ham_ids = [] + # Don't train on resources merely associated with spam user for node in self.nodes.filter(): - node.confirm_ham(save=save, train_spam_services=train_spam_services) + try: + node.confirm_ham(save=save, train_spam_services=train_spam_services) + except Exception as exc: + sentry.log_exception(exc) + failed_ham_ids.append(node._id) + continue + + if not node.is_ham or getattr(node, 'is_deleted', False): + failed_ham_ids.append(node._id) + for preprint in self.preprints.filter(): - preprint.confirm_ham(save=save, train_spam_services=train_spam_services) + try: + preprint.confirm_ham(save=save, train_spam_services=train_spam_services) + except Exception as exc: + sentry.log_exception(exc) + failed_ham_ids.append(preprint._id) + continue + + if ( + not preprint.is_ham + or getattr(preprint, 'is_deleted', False) + or getattr(preprint, 'deleted', None) is not None + ): + failed_ham_ids.append(preprint._id) + + return failed_ham_ids @property def is_assumed_ham(self): diff --git a/website/templates/user_confirm_ham_report.html.mako b/website/templates/user_confirm_ham_report.html.mako new file mode 100644 index 00000000000..942826a58e3 --- /dev/null +++ b/website/templates/user_confirm_ham_report.html.mako @@ -0,0 +1,18 @@ +<%inherit file="notify_base.mako" /> + +<%def name="content()"> + + + + + + + From 51fa36f4e23b2695c9a6354803af3f15e89330ca Mon Sep 17 00:00:00 2001 From: Ostap-Zherebetskyi Date: Tue, 7 Apr 2026 16:10:51 +0300 Subject: [PATCH 08/34] [ENG-9810] Notification Settings Missing from Provider Settings Tab (#11678) * Fix provider notifications settings * Add auto digest recognition --- api/subscriptions/views.py | 59 ++++++++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/api/subscriptions/views.py b/api/subscriptions/views.py index 9e9ba419e10..52be8669231 100644 --- a/api/subscriptions/views.py +++ b/api/subscriptions/views.py @@ -9,6 +9,7 @@ from rest_framework.response import Response from framework.auth.oauth_scopes import CoreScopes +from framework import sentry from api.base.views import JSONAPIBaseView from api.base.filters import ListFilterMixin @@ -31,7 +32,7 @@ Guid, OSFUser, ) -from osf.models.notification_type import NotificationTypeEnum +from osf.models.notification_type import NotificationTypeEnum, NotificationType from osf.models.notification_subscription import NotificationSubscription @@ -174,12 +175,47 @@ def get_queryset(self): class AbstractProviderSubscriptionList(SubscriptionList): def get_queryset(self): - provider = AbstractProvider.objects.get(_id=self.kwargs['provider_id']) + # Get specific provider by _id and type to avoid potential conflicts with multiple provider with the same _id. + try: + provider = AbstractProvider.objects.get( + _id=self.kwargs['provider_id'], + type=self.provider_class._typedmodels_type, + ) + except AbstractProvider.MultipleObjectsReturned: + provider = AbstractProvider.objects.filter( + _id=self.kwargs['provider_id'], + type=self.provider_class._typedmodels_type, + ).first() + sentry.log_message(f''' + Multiple providers found with the same _id: [_id={self.kwargs["provider_id"]}, type={self.provider_class._typedmodels_type}]. + Using the first one with id={provider.id}.''') + + # Create missing default subscriptions for the provider if they don't exist. + notification_names = [ + NotificationTypeEnum.PROVIDER_NEW_PENDING_SUBMISSIONS.value, + NotificationTypeEnum.PROVIDER_NEW_PENDING_WITHDRAW_REQUESTS.value, + ] + content_type = ContentType.objects.get_for_model(provider.__class__) + + notification_types = NotificationType.objects.filter(name__in=notification_names) + for nt in notification_types: + NotificationSubscription.objects.get_or_create( + object_id=provider.id, + content_type=content_type, + user=self.request.user, + notification_type=nt, + defaults={ + '_is_digest': nt.is_digest_type, + 'message_frequency': 'instantly', + }, + ) + return NotificationSubscription.objects.filter( - object_id=provider, - content_type=ContentType.objects.get_for_model(provider.__class__), + object_id=provider.id, + content_type=content_type, user=self.request.user, - ) + notification_type__name__in=notification_names, + ).annotate(legacy_id=F('notification_type__name'), event_name=F('notification_type__name')) class SubscriptionDetail(JSONAPIBaseView, generics.RetrieveUpdateAPIView): view_name = 'notification-subscription-detail' @@ -222,7 +258,7 @@ def get_object(self): content_type=user_ct, then=Value(f'{user_guid}_global_reviews'), ), - default=Value(f'{user_guid}_global'), + default=F('notification_type__name'), output_field=CharField(), ), ) @@ -248,7 +284,7 @@ def update(self, request, *args, **kwargs): """ Update a notification subscription """ - self.get_object() + instance = self.get_object() if '_global_file_updated' in self.kwargs['subscription_id']: # Copy _global_file_updated subscription changes to all file subscriptions @@ -324,7 +360,14 @@ def update(self, request, *args, **kwargs): return Response(serializer.data) else: - return super().update(request, *args, **kwargs) + instance.event_name = instance.notification_type.name # Set event_name for serializer to use + + partial = kwargs.pop('partial', False) + serializer = self.get_serializer(instance, data=request.data, partial=partial) + serializer.is_valid(raise_exception=True) + self.perform_update(serializer) + + return Response(serializer.data) class AbstractProviderSubscriptionDetail(SubscriptionDetail): From 98b15aa80bf4d8acf4f7242475ceff9b519f3624 Mon Sep 17 00:00:00 2001 From: antkryt Date: Tue, 7 Apr 2026 16:24:26 +0300 Subject: [PATCH 09/34] [ENG-10338] add embargo report to admin (#11637) --- admin/nodes/urls.py | 1 + admin/nodes/views.py | 51 +++++++ admin/templates/base.html | 1 + admin/templates/nodes/embargo_report.html | 157 ++++++++++++++++++++++ osf/models/sanctions.py | 24 ++++ scripts/embargo_registrations.py | 13 +- 6 files changed, 238 insertions(+), 9 deletions(-) create mode 100644 admin/templates/nodes/embargo_report.html diff --git a/admin/nodes/urls.py b/admin/nodes/urls.py index 087a1d57e94..e350adb90fa 100644 --- a/admin/nodes/urls.py +++ b/admin/nodes/urls.py @@ -8,6 +8,7 @@ re_path(r'^flagged_spam$', views.NodeFlaggedSpamList.as_view(), name='flagged-spam'), re_path(r'^known_spam$', views.NodeKnownSpamList.as_view(), name='known-spam'), re_path(r'^known_ham$', views.NodeKnownHamList.as_view(), name='known-ham'), + re_path(r'^embargo_report/$', views.EmbargoReportView.as_view(), name='embargo-report'), re_path(r'^doi_backlog_list/$', views.DoiBacklogListView.as_view(), name='doi-backlog-list'), re_path(r'^approval_backlog_list/$', views.ApprovalBacklogListView.as_view(), name='approval-backlog-list'), re_path(r'^confirm_approve_backlog_list/$', views.ConfirmApproveBacklogView.as_view(), name='confirm-approve-backlog-list'), diff --git a/admin/nodes/views.py b/admin/nodes/views.py index 01ebad686ce..9a403131547 100644 --- a/admin/nodes/views.py +++ b/admin/nodes/views.py @@ -16,7 +16,9 @@ View, FormView, ListView, + TemplateView ) +from django.core.paginator import Paginator, InvalidPage from admin.base.forms import GuidForm from admin.base.utils import change_embargo_date @@ -39,6 +41,7 @@ SpamStatus, TrashedFile ) +from osf.models.sanctions import Embargo from osf.models.admin_log_entry import ( update_admin_log, NODE_REMOVED, @@ -474,6 +477,54 @@ def get_context_data(self, **kwargs): } +class EmbargoReportView(PermissionRequiredMixin, TemplateView): + """Report view for inspecting current and overdue embargoed registrations. + + Shows: + - pending embargoes that should have been activated + - active embargoes that are past their end date + - upcoming active embargoes + """ + template_name = 'nodes/embargo_report.html' + permission_required = 'osf.view_registration' + raise_exception = True + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + pending_embargoes = Embargo.objects.pending_embargoes().select_related('initiated_by') + active_embargoes = Embargo.objects.active_embargoes().select_related('initiated_by') + + pending_overdue_embargoes = [ + embargo for embargo in pending_embargoes + if embargo.should_be_embargoed + ] + + overdue_embargoes = [ + embargo for embargo in active_embargoes + if embargo.should_be_completed + ] + + upcoming_queryset = active_embargoes.filter( + end_date__gte=timezone.now(), + ).order_by('end_date') + + page_number = self.request.GET.get('page') or 1 + paginator = Paginator(upcoming_queryset, 10) + try: + upcoming_page = paginator.page(page_number) + except InvalidPage: + upcoming_page = paginator.page(1) + + context.update({ + 'now': timezone.now(), + 'pending_overdue_embargoes': pending_overdue_embargoes, + 'overdue_embargoes': overdue_embargoes, + 'upcoming_embargoes': upcoming_page.object_list, + 'upcoming_page': upcoming_page, + }) + return context + + class ConfirmApproveBacklogView(RegistrationListView): template_name = 'nodes/registration_approval_list.html' permission_required = 'osf.view_registrationapproval' diff --git a/admin/templates/base.html b/admin/templates/base.html index e6f10794c29..92a0541ef34 100644 --- a/admin/templates/base.html +++ b/admin/templates/base.html @@ -169,6 +169,7 @@
  • IA Backlog
  • DOI Backlog
  • Approval Backlog
  • +
  • Embargo Report
  • {% endif %} diff --git a/admin/templates/nodes/embargo_report.html b/admin/templates/nodes/embargo_report.html new file mode 100644 index 00000000000..bbc68c994d1 --- /dev/null +++ b/admin/templates/nodes/embargo_report.html @@ -0,0 +1,157 @@ +{% extends "base.html" %} +{% load node_extras %} + +{% block content %} + + +

    Upcoming Active Embargoes

    +
    +

    + Confirm HAM finished for user ${user_guid} +

    +
    +% if failed_ham: +

    Failed nodes/preprints: ${failed_ham}

    +% endif +
    + + + + + + + + + + + {% include "util/pagination.html" with items=upcoming_page status='' pagin=False order='' %} + + {% for embargo in upcoming_embargoes %} + {% with registration=embargo.registrations.first %} + {% if registration %} + + + + + + + + + {% endif %} + {% endwith %} + {% empty %} + + + + {% endfor %} + +
    RegistrationEmbargo IDStateEmbargo StartEmbargo EndInitiated By
    + + {{ registration.title | truncatechars:30 }} + + {{ embargo.id }}{{ embargo.state }}{{ embargo.initiation_date|date:"F j, Y P" }}{{ embargo.end_date|date:"F j, Y P" }} + {% if embargo.initiated_by %} + + {{ embargo.initiated_by.fullname }} + + {% else %} + — + {% endif %} +
    No active embargoes found.
    + +

    Pending Embargoes Past Pending Window

    +

    These embargoes are still awaiting approval but have passed the automatic activation window.

    + + + + + + + + + + + + + {% for embargo in pending_overdue_embargoes %} + {% with registration=embargo.registrations.first %} + {% if registration %} + + + + + + + + + {% endif %} + {% endwith %} + {% empty %} + + + + {% endfor %} + +
    RegistrationEmbargo IDStateEmbargo InitiationEmbargo EndInitiated By
    + + {{ registration.title | truncatechars:30 }} + + {{ embargo.id }}{{ embargo.state }}{{ embargo.initiation_date|date:"F j, Y P" }}{{ embargo.end_date|date:"F j, Y P" }} + {% if embargo.initiated_by %} + + {{ embargo.initiated_by.fullname }} + + {% else %} + — + {% endif %} +
    No pending embargoes past the pending window.
    + +

    Active Embargoes Past Pending Window

    +

    These embargoes have an end date in the past but the embargo is still marked as active.

    + + + + + + + + + + + + + {% for embargo in overdue_embargoes %} + {% with registration=embargo.registrations.first %} + {% if registration %} + + + + + + + + + {% endif %} + {% endwith %} + {% empty %} + + + + {% endfor %} + +
    RegistrationEmbargo IDStateEmbargo EndIs Registration Public?Initiated By
    + + {{ registration.title | truncatechars:30 }} + + {{ embargo.id }}{{ embargo.state }}{{ embargo.end_date|date:"F j, Y P" }} + {% if registration.is_public %} + Yes + {% else %} + No + {% endif %} + + {% if embargo.initiated_by %} + + {{ embargo.initiated_by.fullname }} + + {% else %} + — + {% endif %} +
    No overdue active embargoes found.
    + +{% endblock %} + diff --git a/osf/models/sanctions.py b/osf/models/sanctions.py index 5fa5d304d22..3e41a02e02b 100644 --- a/osf/models/sanctions.py +++ b/osf/models/sanctions.py @@ -456,6 +456,17 @@ def _email_template_context(self, user, node, is_authorizer=False, urls=None): return {} +class EmbargoManager(models.Manager): + + def pending_embargoes(self): + """Embargoes that are still awaiting admin approval.""" + return self.filter(state=self.model.UNAPPROVED) + + def active_embargoes(self): + """Embargoes that have been approved and are currently in effect.""" + return self.filter(state=self.model.APPROVED) + + class Embargo(SanctionCallbackMixin, EmailApprovableSanction): """Embargo object for registrations waiting to go public.""" SANCTION_TYPE = SanctionTypes.EMBARGO @@ -472,6 +483,8 @@ class Embargo(SanctionCallbackMixin, EmailApprovableSanction): initiated_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.CASCADE) for_existing_registration = models.BooleanField(default=False) + objects = EmbargoManager() + @property def is_completed(self): return self.state == self.COMPLETED @@ -497,6 +510,17 @@ def embargo_end_date(self): def pending_registration(self): return not self.for_existing_registration and self.is_pending_approval + @property + def should_be_embargoed(self): + return ( + (timezone.now() - self.initiation_date) >= osf_settings.EMBARGO_PENDING_TIME + and not self.is_deleted + ) + + @property + def should_be_completed(self): + return self.end_date and self.end_date < timezone.now() and not self.is_deleted + def _get_registration(self): return self.registrations.first() diff --git a/scripts/embargo_registrations.py b/scripts/embargo_registrations.py index 8291b611b2e..f90d5f43626 100644 --- a/scripts/embargo_registrations.py +++ b/scripts/embargo_registrations.py @@ -28,9 +28,9 @@ def main(dry_run=True): - pending_embargoes = Embargo.objects.filter(state=Embargo.UNAPPROVED) + pending_embargoes = Embargo.objects.pending_embargoes() for embargo in pending_embargoes: - if should_be_embargoed(embargo): + if embargo.should_be_embargoed: if dry_run: logger.warning('Dry run mode') try: @@ -77,9 +77,9 @@ def main(dry_run=True): transaction.savepoint_rollback(sid) - active_embargoes = Embargo.objects.filter(state=Embargo.APPROVED) + active_embargoes = Embargo.objects.active_embargoes() for embargo in active_embargoes: - if embargo.end_date < timezone.now() and not embargo.is_deleted: + if embargo.should_be_completed: if dry_run: logger.warning('Dry run mode') parent_registration = Registration.objects.get(embargo=embargo) @@ -117,11 +117,6 @@ def main(dry_run=True): transaction.savepoint_rollback(sid) -def should_be_embargoed(embargo): - """Returns true if embargo was initiated more than 48 hours prior.""" - return (timezone.now() - embargo.initiation_date) >= settings.EMBARGO_PENDING_TIME and not embargo.is_deleted - - @celery_app.task(name='scripts.embargo_registrations') def run_main(dry_run=True): if not dry_run: From 8212ede65af3061ca1f4657f4cd9f9554c2afd2b Mon Sep 17 00:00:00 2001 From: antkryt Date: Tue, 7 Apr 2026 17:02:43 +0300 Subject: [PATCH 10/34] [ENG-9907] Analytics are increasing (unique views) when a contributor is viewing their own content (Project) (BE) (#11669) * do not count contrib views * add tests for VOL; parametrize --- api/metrics/utils.py | 48 +++++++++ api/metrics/views.py | 7 ++ api_tests/metrics/test_counted_usage.py | 129 +++++++++++++++++++++++- 3 files changed, 183 insertions(+), 1 deletion(-) diff --git a/api/metrics/utils.py b/api/metrics/utils.py index 31427067093..54af7531200 100644 --- a/api/metrics/utils.py +++ b/api/metrics/utils.py @@ -1,4 +1,5 @@ import re +from urllib.parse import urlsplit import pytz @@ -6,6 +7,9 @@ from django.utils import timezone from rest_framework.exceptions import ValidationError +from osf.models import AbstractNode, Guid +from osf.metrics.counted_usage import _get_immediate_wrapper + DATETIME_FORMAT = '%Y-%m-%dT%H:%M' DATE_FORMAT = '%Y-%m-%d' @@ -114,3 +118,47 @@ def parse_date_range(query_params, is_monthly=False): start_date, end_date = parse_dates(query_params, is_monthly=is_monthly) report_date_range = {'gte': str(start_date), 'lte': str(end_date)} return report_date_range + + +def _user_has_read_on_resolved_node(user, guid_referent): + """True if ``user`` has READ on the node this referent belongs to.""" + current = guid_referent + while current is not None and not isinstance(current, AbstractNode): + current = _get_immediate_wrapper(current) + if current is None or not isinstance(current, AbstractNode): + return False + return current.contributors_and_group_members.filter(guids___id=user._id).exists() + + +def should_skip_counted_usage(user, *, item_guid=None, pageview_info=None): + """Return True when this usage should not be recorded.""" + if not getattr(user, 'is_authenticated', False): + return False + + referents = [] + seen_ids = set() + + def _add_referent(ref): + if ref is None: + return + key = (ref.__class__.__name__, ref.pk) + if key in seen_ids: + return + seen_ids.add(key) + referents.append(ref) + + if item_guid: + guid_obj = Guid.load(item_guid) + if guid_obj and guid_obj.referent: + _add_referent(guid_obj.referent) + + page_url = (pageview_info or {}).get('page_url') + if page_url: + for segment in urlsplit(page_url).path.split('/'): + if not segment or len(segment) < 5: + continue + guid_obj = Guid.load(segment) + if guid_obj and guid_obj.referent: + _add_referent(guid_obj.referent) + + return any(_user_has_read_on_resolved_node(user, ref) for ref in referents) diff --git a/api/metrics/views.py b/api/metrics/views.py index daaa684d13a..99ecf3fe347 100644 --- a/api/metrics/views.py +++ b/api/metrics/views.py @@ -43,6 +43,7 @@ from api.metrics.utils import ( parse_datetimes, parse_date_range, + should_skip_counted_usage, ) from api.nodes.permissions import MustBePublic @@ -388,6 +389,12 @@ class CountedAuthUsageView(JSONAPIBaseView): def post(self, request, *args, **kwargs): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) + if should_skip_counted_usage( + request.user, + item_guid=serializer.validated_data.get('item_guid'), + pageview_info=serializer.validated_data.get('pageview_info'), + ): + return HttpResponse(status=204) session_id, user_is_authenticated = self._get_session_id( request, client_session_id=serializer.validated_data.get('client_session_id'), diff --git a/api_tests/metrics/test_counted_usage.py b/api_tests/metrics/test_counted_usage.py index 568d663be9e..be1d986ff6d 100644 --- a/api_tests/metrics/test_counted_usage.py +++ b/api_tests/metrics/test_counted_usage.py @@ -3,13 +3,18 @@ import pytest from unittest import mock +from framework.auth.core import Auth + from osf_tests.factories import ( AuthUserFactory, - PreprintFactory, NodeFactory, + PreprintFactory, + PrivateLinkFactory, + ProjectFactory, RegistrationFactory, # UserFactory, ) +from osf.utils.permissions import ADMIN, READ, WRITE from api_tests.utils import create_test_file @@ -351,3 +356,125 @@ def test_child_registration_file(self, app, mock_save, child_reg_file_guid, chil 'surrounding_guids': None, }, ) + + +@pytest.mark.django_db +class TestContributorExclusion: + + def test_creator_pageview_not_recorded(self, app, mock_save): + user = AuthUserFactory() + project = ProjectFactory(creator=user) + payload = counted_usage_payload( + item_guid=project._id, + action_labels=['view', 'web'], + pageview_info={'page_url': f'https://osf.io/{project._id}/'}, + ) + resp = app.post_json_api(COUNTED_USAGE_URL, payload, auth=user.auth) + assert resp.status_code == 204 + assert mock_save.call_count == 0 + + @pytest.mark.parametrize( + 'permissions', + [READ, WRITE, ADMIN], + ids=['read', 'write', 'admin'], + ) + def test_contributor_pageview_not_recorded(self, app, mock_save, permissions): + creator = AuthUserFactory() + contributor = AuthUserFactory() + project = ProjectFactory(creator=creator) + project.add_contributor(contributor, permissions=permissions, auth=Auth(creator)) + payload = counted_usage_payload( + item_guid=project._id, + action_labels=['view', 'web'], + pageview_info={'page_url': f'https://osf.io/{project._id}/analytics/'}, + ) + resp = app.post_json_api(COUNTED_USAGE_URL, payload, auth=contributor.auth) + assert resp.status_code == 204 + assert mock_save.call_count == 0 + + def test_non_contributor_pageview_recorded(self, app, mock_save): + creator = AuthUserFactory() + visitor = AuthUserFactory() + project = ProjectFactory(creator=creator, is_public=True) + payload = counted_usage_payload( + item_guid=project._id, + action_labels=['view', 'web'], + pageview_info={'page_url': f'https://osf.io/{project._id}/'}, + ) + resp = app.post_json_api(COUNTED_USAGE_URL, payload, auth=visitor.auth) + assert resp.status_code == 201 + assert mock_save.call_count == 1 + + def test_parent_contributor_not_on_child_component_pageview_recorded(self, app, mock_save): + creator = AuthUserFactory() + child_owner = AuthUserFactory() + parent_reader = AuthUserFactory() + parent = ProjectFactory(creator=creator, is_public=True) + child = NodeFactory(parent=parent, creator=child_owner, is_public=True) + parent.add_contributor(parent_reader, permissions=ADMIN, auth=Auth(creator)) + assert not child.contributors_and_group_members.filter(guids___id=parent_reader._id).exists() + payload = counted_usage_payload( + item_guid=child._id, + action_labels=['view', 'web'], + pageview_info={'page_url': f'https://osf.io/{child._id}/'}, + ) + resp = app.post_json_api(COUNTED_USAGE_URL, payload, auth=parent_reader.auth) + assert resp.status_code == 201 + assert mock_save.call_count == 1 + + def test_anonymous_view_only_link_visitor_pageview_recorded(self, app, mock_save): + creator = AuthUserFactory() + project = ProjectFactory(creator=creator, is_public=False) + link = PrivateLinkFactory(anonymous=True, creator=creator) + link.nodes.add(project) + payload = counted_usage_payload( + item_guid=project._id, + action_labels=['view', 'web'], + client_session_id='vol-client-session', + pageview_info={ + 'page_url': f'https://osf.io/{project._id}/?view_only={link.key}', + }, + ) + resp = app.post_json_api(COUNTED_USAGE_URL, payload) + assert resp.status_code == 201 + assert mock_save.call_count == 1 + + def test_logged_in_non_contributor_view_only_link_pageview_recorded(self, app, mock_save): + creator = AuthUserFactory() + visitor = AuthUserFactory() + project = ProjectFactory(creator=creator, is_public=False) + link = PrivateLinkFactory(anonymous=False, creator=creator) + link.nodes.add(project) + payload = counted_usage_payload( + item_guid=project._id, + action_labels=['view', 'web'], + pageview_info={ + 'page_url': f'https://osf.io/{project._id}/files/?view_only={link.key}', + }, + ) + resp = app.post_json_api(COUNTED_USAGE_URL, payload, auth=visitor.auth) + assert resp.status_code == 201 + assert mock_save.call_count == 1 + + @pytest.mark.parametrize( + 'permissions', + [READ, WRITE, ADMIN], + ids=['read', 'write', 'admin'], + ) + def test_logged_in_contributor_view_only_link_pageview_not_recorded(self, app, mock_save, permissions): + creator = AuthUserFactory() + contributor = AuthUserFactory() + project = ProjectFactory(creator=creator, is_public=False) + project.add_contributor(contributor, permissions=permissions, auth=Auth(creator)) + link = PrivateLinkFactory(anonymous=False, creator=creator) + link.nodes.add(project) + payload = counted_usage_payload( + item_guid=project._id, + action_labels=['view', 'web'], + pageview_info={ + 'page_url': f'https://osf.io/{project._id}/?view_only={link.key}', + }, + ) + resp = app.post_json_api(COUNTED_USAGE_URL, payload, auth=contributor.auth) + assert resp.status_code == 204 + assert mock_save.call_count == 0 From 8612887ce9c8dfdac05307010ac6ad0ac542c304 Mon Sep 17 00:00:00 2001 From: Vlad0n20 <137097005+Vlad0n20@users.noreply.github.com> Date: Wed, 4 Feb 2026 07:07:11 +0200 Subject: [PATCH 11/34] [ENG-10042] Fix/eng 10042 (#11551) * Update RegistrationActionSerializer * Update RegistrationActionSerializer --- api/actions/serializers.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/api/actions/serializers.py b/api/actions/serializers.py index d572ee41654..a4ab5b71d83 100644 --- a/api/actions/serializers.py +++ b/api/actions/serializers.py @@ -26,6 +26,7 @@ ) from osf.utils.workflows import ( + ApprovalStates, DefaultStates, DefaultTriggers, ReviewStates, @@ -278,6 +279,31 @@ def create(self, validated_data): comment = validated_data.pop('comment', '') user = validated_data.pop('user') + pending_schema_response_updates = target.schema_responses.filter( + reviews_state__in=[ + ApprovalStates.UNAPPROVED.db_name, + ApprovalStates.PENDING_MODERATION.db_name, + ], + previous_response__isnull=False, # Only updates, not initial submissions + ).order_by('-created') + + if pending_schema_response_updates.exists(): + pending_response = pending_schema_response_updates.first() + short_message = 'This registration has a pending update' + long_message = ( + f'This registration has a pending schema response update (ID: {pending_response._id}) ' + f'that must be moderated. Please use the schema response actions endpoint to approve or reject ' + f'the update instead of creating a registration action.' + ) + raise HTTPError( + http_status.HTTP_400_BAD_REQUEST, + data={ + 'message_short': short_message, + 'message_long': long_message, + 'schema_response_id': pending_response._id, + }, + ) + sanction = target.sanction try: From c705963bda4b38eaf6f5775d35b7f42114e8b5e0 Mon Sep 17 00:00:00 2001 From: Vlad0n20 <137097005+Vlad0n20@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:11:02 +0200 Subject: [PATCH 12/34] [ENG-10042] Fix/eng 10042 (#11593) Revert BE changes meant to fix 10042 and replace with FE changes. --- api/actions/serializers.py | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/api/actions/serializers.py b/api/actions/serializers.py index a4ab5b71d83..d572ee41654 100644 --- a/api/actions/serializers.py +++ b/api/actions/serializers.py @@ -26,7 +26,6 @@ ) from osf.utils.workflows import ( - ApprovalStates, DefaultStates, DefaultTriggers, ReviewStates, @@ -279,31 +278,6 @@ def create(self, validated_data): comment = validated_data.pop('comment', '') user = validated_data.pop('user') - pending_schema_response_updates = target.schema_responses.filter( - reviews_state__in=[ - ApprovalStates.UNAPPROVED.db_name, - ApprovalStates.PENDING_MODERATION.db_name, - ], - previous_response__isnull=False, # Only updates, not initial submissions - ).order_by('-created') - - if pending_schema_response_updates.exists(): - pending_response = pending_schema_response_updates.first() - short_message = 'This registration has a pending update' - long_message = ( - f'This registration has a pending schema response update (ID: {pending_response._id}) ' - f'that must be moderated. Please use the schema response actions endpoint to approve or reject ' - f'the update instead of creating a registration action.' - ) - raise HTTPError( - http_status.HTTP_400_BAD_REQUEST, - data={ - 'message_short': short_message, - 'message_long': long_message, - 'schema_response_id': pending_response._id, - }, - ) - sanction = target.sanction try: From 0805fef72ea05e85b48babcf0264d34a272c695d Mon Sep 17 00:00:00 2001 From: Ihor Sokhan Date: Thu, 12 Mar 2026 09:53:25 +0200 Subject: [PATCH 13/34] fixed non-consistent COI --- admin/preprints/urls.py | 1 + admin/preprints/views.py | 16 ++++++++++ admin/templates/preprints/fix_coi.html | 21 +++++++++++++ admin/templates/preprints/preprint.html | 1 + admin_tests/preprints/test_views.py | 39 +++++++++++++++++++++++++ 5 files changed, 78 insertions(+) create mode 100644 admin/templates/preprints/fix_coi.html diff --git a/admin/preprints/urls.py b/admin/preprints/urls.py index d6eabdd870c..960e2e297e5 100644 --- a/admin/preprints/urls.py +++ b/admin/preprints/urls.py @@ -17,6 +17,7 @@ re_path(r'^(?P\w+)/remove_user/(?P[a-z0-9]+)/$', views.PreprintRemoveContributorView.as_view(), name='remove-user'), re_path(r'^(?P\w+)/make_private/$', views.PreprintMakePrivate.as_view(), name='make-private'), re_path(r'^(?P\w+)/fix_editing/$', views.PreprintFixEditing.as_view(), name='fix-editing'), + re_path(r'^(?P\w+)/fix_coi/$', views.PreprintFixCOI.as_view(), name='fix-coi'), re_path(r'^(?P\w+)/make_public/$', views.PreprintMakePublic.as_view(), name='make-public'), re_path(r'^(?P\w+)/remove/$', views.PreprintDeleteView.as_view(), name='remove'), re_path(r'^(?P\w+)/restore/$', views.PreprintDeleteView.as_view(), name='restore'), diff --git a/admin/preprints/views.py b/admin/preprints/views.py index 99d18d83650..3153ae301c8 100644 --- a/admin/preprints/views.py +++ b/admin/preprints/views.py @@ -668,6 +668,22 @@ def post(self, request, *args, **kwargs): return redirect(self.get_success_url()) +class PreprintFixCOI(PreprintMixin, View): + """ Allows an authorized user to manually fix conflict of interest field. + """ + permission_required = 'osf.change_preprint' + + def post(self, request, *args, **kwargs): + preprint = self.get_object() + if preprint.conflict_of_interest_statement and not preprint.has_coi: + preprint.update_has_coi(auth=request, has_coi=True, ignore_permission=True) + messages.success(request, 'The COI was successfully fixed.') + else: + messages.error(request, 'The COI is either already fixed or the preprint does not have conflict of interest set.') + + return redirect(self.get_success_url()) + + class PreprintMakePublic(PreprintMixin, View): """ Allows an authorized user to manually make a private preprint public. """ diff --git a/admin/templates/preprints/fix_coi.html b/admin/templates/preprints/fix_coi.html new file mode 100644 index 00000000000..6ee06e66130 --- /dev/null +++ b/admin/templates/preprints/fix_coi.html @@ -0,0 +1,21 @@ +{% if perms.osf.change_node %} + + Fix COI + + +{% endif %} diff --git a/admin/templates/preprints/preprint.html b/admin/templates/preprints/preprint.html index d716b60aeb7..36066a94ccf 100644 --- a/admin/templates/preprints/preprint.html +++ b/admin/templates/preprints/preprint.html @@ -28,6 +28,7 @@ {% include "preprints/make_public.html" with preprint=preprint %} {% include "preprints/make_published.html" with preprint=preprint %} {% include "preprints/fix_editing.html" with preprint=preprint %} + {% include "preprints/fix_coi.html" with preprint=preprint %} {% include "preprints/assign_new_version.html" with preprint=preprint %}
    diff --git a/admin_tests/preprints/test_views.py b/admin_tests/preprints/test_views.py index efd571a5772..5582c1b07eb 100644 --- a/admin_tests/preprints/test_views.py +++ b/admin_tests/preprints/test_views.py @@ -960,3 +960,42 @@ def test_osf_admin_can_create_new_version_with_unregistered_contributors(self, p preprint.refresh_from_db() assert len(preprint.get_preprint_versions()) == 2 + + +@pytest.mark.urls('admin.base.urls') +class TestPreprintFixCOIView: + + @pytest.fixture() + def plain_view(self): + return views.PreprintFixCOI + + def test_admin_user_can_fix_coi_only_when_coi_is_set(self, user, preprint, plain_view): + admin_group = Group.objects.get(name='osf_admin') + preprint.has_coi = False + preprint.conflict_of_interest_statement = 'some text' + preprint.save() + + request = RequestFactory().post(reverse('preprints:fix-coi', kwargs={'guid': preprint._id})) + request.user = user + patch_messages(request) + + admin_group.permissions.add(Permission.objects.get(codename='change_preprint')) + user.groups.add(admin_group) + + plain_view.as_view()(request, guid=preprint._id) + preprint.reload() + + assert preprint.has_coi + assert preprint.conflict_of_interest_statement == 'some text' + + # this case is not fixable because the preprint doesn't have COI statement set but has_coi = True + # which means we should add COI text by ourselves + preprint.has_coi = True + preprint.conflict_of_interest_statement = '' + preprint.save() + + plain_view.as_view()(request, guid=preprint._id) + preprint.reload() + + assert preprint.has_coi + assert preprint.conflict_of_interest_statement == '' From 23f0a832b54a74699edf70432e9cdd278549e284 Mon Sep 17 00:00:00 2001 From: Ihor Sokhan Date: Thu, 12 Mar 2026 10:00:34 +0200 Subject: [PATCH 14/34] fixed permission in a template --- admin/templates/preprints/fix_coi.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admin/templates/preprints/fix_coi.html b/admin/templates/preprints/fix_coi.html index 6ee06e66130..1324c4b53c5 100644 --- a/admin/templates/preprints/fix_coi.html +++ b/admin/templates/preprints/fix_coi.html @@ -1,4 +1,4 @@ -{% if perms.osf.change_node %} +{% if perms.osf.change_preprint %} Fix COI From 28410ef9450cdccc1ebba2640d1bf74bb2516a20 Mon Sep 17 00:00:00 2001 From: futa-ikeda <51409893+futa-ikeda@users.noreply.github.com> Date: Wed, 8 Apr 2026 10:46:49 -0400 Subject: [PATCH 15/34] [ENG-10063] orcid integration (#11686) ## Ticket [ENG-10063](https://openscience.atlassian.net/browse/ENG-10063) --- admin/management/urls.py | 4 +++- admin/management/views.py | 9 +++++++ admin/templates/management/commands.html | 13 ++++++++++ api/base/schemas/social-schema.json | 4 ---- api/users/views.py | 7 ++++-- api_tests/users/views/test_user_detail.py | 7 ------ api_tests/users/views/test_user_exceptions.py | 1 - .../users/views/test_user_external_login.py | 24 ++++++++++++++++--- framework/auth/views.py | 8 ++++--- osf/external/chronos/chronos.py | 1 - .../commands/remove_orcid_from_user_social.py | 24 +++++++++++++++++++ osf/models/user.py | 3 +-- osf_tests/test_elastic_search.py | 10 -------- osf_tests/test_search_views.py | 2 -- tests/identifiers/test_crossref.py | 14 ----------- website/static/js/profile.js | 7 ------ website/templates/include/profile/social.mako | 9 ------- .../project/modal_add_contributor.mako | 5 ---- website/templates/search.mako | 5 ---- 19 files changed, 81 insertions(+), 76 deletions(-) create mode 100644 osf/management/commands/remove_orcid_from_user_social.py diff --git a/admin/management/urls.py b/admin/management/urls.py index d583deb2ce0..c046b3bed18 100644 --- a/admin/management/urls.py +++ b/admin/management/urls.py @@ -19,5 +19,7 @@ re_path(r'^empty_metadata_dataarchive_registration_bulk_resync', views.EmptyMetadataDataarchiveRegistrationBulkResync.as_view(), name='empty-metadata-dataarchive-registration-bulk-resync'), re_path(r'^sync_notification_templates', views.SyncNotificationTemplates.as_view(), - name='sync_notification_templates') + name='sync_notification_templates'), + re_path(r'^remove_orcid_from_user_social', views.RemoveOrcidFromUserSocial.as_view(), + name='remove_orcid_from_user_social') ] diff --git a/admin/management/views.py b/admin/management/views.py index 36f3d893f24..f2052822f37 100644 --- a/admin/management/views.py +++ b/admin/management/views.py @@ -12,6 +12,7 @@ from osf.management.commands.fetch_cedar_metadata_templates import ingest_cedar_metadata_templates from osf.management.commands.sync_doi_metadata import sync_doi_metadata, sync_doi_empty_metadata_dataarchive_registrations from osf.management.commands.populate_notification_types import populate_notification_types +from osf.management.commands.remove_orcid_from_user_social import remove_orcid_from_user_social from scripts.find_spammy_content import manage_spammy_content from django.urls import reverse from django.shortcuts import redirect @@ -181,3 +182,11 @@ def post(self, request): populate_notification_types() messages.success(request, 'Notification templates have been successfully synced.') return redirect(reverse('management:commands')) + + +class RemoveOrcidFromUserSocial(ManagementCommandPermissionView): + + def post(self, request): + remove_orcid_from_user_social() + messages.success(request, 'Orcid from user social have been successfully removed.') + return redirect(reverse('management:commands')) diff --git a/admin/templates/management/commands.html b/admin/templates/management/commands.html index dd90affd5ff..edf242abfdd 100644 --- a/admin/templates/management/commands.html +++ b/admin/templates/management/commands.html @@ -165,6 +165,19 @@

    Sync Notification Templates

    +
    +

    Remove existing orcid info from user social

    +

    + Use this management command to remove existing orcid info from user social. +

    +
    + {% csrf_token %} + +
    +
    {% endblock %} diff --git a/api/base/schemas/social-schema.json b/api/base/schemas/social-schema.json index 97b9360698d..84dfcae000b 100644 --- a/api/base/schemas/social-schema.json +++ b/api/base/schemas/social-schema.json @@ -67,10 +67,6 @@ "academiaInstitution": { "description": "The academiaInstitution for the given user", "type": "string" - }, - "orcid": { - "description": "The orcid for the given user", - "type": "string" } }, "additionalProperties": false diff --git a/api/users/views.py b/api/users/views.py index b920798be86..b887ee7bdd6 100644 --- a/api/users/views.py +++ b/api/users/views.py @@ -755,7 +755,11 @@ def post(self, request, *args, **kwargs): # 1. update user oauth, with pending status external_identity[external_id_provider][external_id] = 'LINK' if external_id_provider in user.external_identity: - user.external_identity[external_id_provider].update(external_identity[external_id_provider]) + # v1 looks to be used because of /confirm/external/ usage for auth but add orcid external identity rewrite updates for v2 as well + if external_id_provider == settings.EXTERNAL_IDENTITY_PROFILE.get('OrcidProfile'): + user.external_identity[external_id_provider] = external_identity[external_id_provider] + else: + user.external_identity[external_id_provider].update(external_identity[external_id_provider]) else: user.external_identity.update(external_identity) if not user.accepted_terms_of_service and accepted_terms_of_service: @@ -1153,7 +1157,6 @@ class ConfirmEmailView(generics.CreateAPIView): def _process_external_identity(self, user, external_identity, service_url): """Handle all external_identity logic, including task enqueueing and url updates.""" - provider = next(iter(external_identity)) if provider not in user.external_identity: raise ValidationError('External-ID provider mismatch.') diff --git a/api_tests/users/views/test_user_detail.py b/api_tests/users/views/test_user_detail.py index 188a3593ee1..70bd2cfe810 100644 --- a/api_tests/users/views/test_user_detail.py +++ b/api_tests/users/views/test_user_detail.py @@ -453,7 +453,6 @@ def user_one(self): twitter='userOneTwitter', linkedIn='userOneLinkedIn', impactStory='userOneImpactStory', - orcid='userOneOrcid', researcherId='userOneResearcherId' ) ) @@ -508,7 +507,6 @@ def data_new_user_one(self, user_one): 'twitter': ['http://twitter.com/newtwitter'], 'linkedIn': ['https://www.linkedin.com/newLinkedIn'], 'impactStory': 'https://impactstory.org/newImpactStory', - 'orcid': 'http://orcid.org/newOrcid', 'researcherId': 'http://researcherid.com/rid/newResearcherId', }}, }} @@ -916,7 +914,6 @@ def test_partial_patch_user_logged_in(self, app, user_one, url_user_one): assert user_one.social['twitter'] in social['twitter'] assert user_one.social['linkedIn'] in social['linkedIn'] assert user_one.social['impactStory'] in social['impactStory'] - assert user_one.social['orcid'] in social['orcid'] assert user_one.social['researcherId'] in social['researcherId'] assert user_one.fullname == 'new_fullname' assert user_one.suffix == 'The Millionth' @@ -933,7 +930,6 @@ def test_patch_all_social_fields(self, app, user_one, url_user_one, mock_spam_he 'academiaProfileID': 'okokokok', 'ssrn': 'aaaa', 'impactStory': 'why not', - 'orcid': 'ork-id', 'researchGate': 'Why are there so many of these', 'researcherId': 'ok-lastone', 'academiaInstitution': 'Center for Open Science' @@ -1005,7 +1001,6 @@ def test_partial_patch_user_logged_in_no_social_fields( assert user_one.social['twitter'] in social['twitter'] assert user_one.social['linkedIn'] in social['linkedIn'] assert user_one.social['impactStory'] in social['impactStory'] - assert user_one.social['orcid'] in social['orcid'] assert user_one.social['researcherId'] in social['researcherId'] assert user_one.fullname == 'new_fullname' assert user_one.suffix == 'The Millionth' @@ -1056,7 +1051,6 @@ def test_put_user_logged_in(self, app, user_one, data_new_user_one, url_user_one assert 'newtwitter' in social['twitter'][0] assert 'newLinkedIn' in social['linkedIn'][0] assert 'newImpactStory' in social['impactStory'] - assert 'newOrcid' in social['orcid'] assert 'newResearcherId' in social['researcherId'] user_one.reload() assert user_one.fullname == data_new_user_one['data']['attributes']['full_name'] @@ -1069,7 +1063,6 @@ def test_put_user_logged_in(self, app, user_one, data_new_user_one, url_user_one assert 'newtwitter' in social['twitter'][0] assert 'newLinkedIn' in social['linkedIn'][0] assert 'newImpactStory' in social['impactStory'] - assert 'newOrcid' in social['orcid'] assert 'newResearcherId' in social['researcherId'] def test_update_user_sanitizes_html_properly( diff --git a/api_tests/users/views/test_user_exceptions.py b/api_tests/users/views/test_user_exceptions.py index 58b3e7c8a61..57bf35608ab 100644 --- a/api_tests/users/views/test_user_exceptions.py +++ b/api_tests/users/views/test_user_exceptions.py @@ -22,7 +22,6 @@ def user(self): twitter='userOneTwitter', linkedIn='userOneLinkedIn', impactStory='userOneImpactStory', - orcid='userOneOrcid', researcherId='userOneResearcherId' ) ) diff --git a/api_tests/users/views/test_user_external_login.py b/api_tests/users/views/test_user_external_login.py index 6f436f5d422..fca05aadbc8 100644 --- a/api_tests/users/views/test_user_external_login.py +++ b/api_tests/users/views/test_user_external_login.py @@ -48,7 +48,7 @@ def csrf_token(self): @pytest.fixture() def session_data(self): session = SessionStore() - session['auth_user_external_id_provider'] = 'orcid' + session['auth_user_external_id_provider'] = 'ORCID' session['auth_user_external_id'] = '1234-1234-1234-1234' session['auth_user_fullname'] = 'external login' session['auth_user_external_first_login'] = True @@ -62,7 +62,7 @@ def test_external_login(self, app, payload, url, session_data, csrf_token): with capture_notifications(): res = app.post_json_api(url, payload, headers={'X-CSRFToken': csrf_token}) assert res.status_code == 200 - assert res.json == {'external_id_provider': 'orcid', 'auth_user_fullname': 'external login'} + assert res.json == {'external_id_provider': 'ORCID', 'auth_user_fullname': 'external login'} assert not OSFUser.objects.get(username='freddie@mercury.com').is_confirmed def test_invalid_payload(self, app, url, session_data, csrf_token): @@ -84,6 +84,24 @@ def test_existing_user(self, app, payload, url, user_one, session_data, csrf_tok with capture_notifications(): res = app.post_json_api(url, payload, headers={'X-CSRFToken': csrf_token}) assert res.status_code == 200 - assert res.json == {'external_id_provider': 'orcid', 'auth_user_fullname': 'external login'} + assert res.json == {'external_id_provider': 'ORCID', 'auth_user_fullname': 'external login'} user_one.reload() assert user_one.username in user_one.unconfirmed_emails + + def test_existing_user_orcid_overwrites(self, app, payload, url, user_one, session_data, csrf_token): + user_one.external_identity = { + 'ORCID': { + '0000-0000-0000-0000': 'LINK', + } + } + user_one.save() + app.set_cookie(CSRF_COOKIE_NAME, csrf_token) + app.set_cookie(settings.COOKIE_NAME, str(session_data)) + assert user_one.external_identity['ORCID'] == {'0000-0000-0000-0000': 'LINK'} + assert '0000-0000-0000-0000' in user_one.external_identity['ORCID'] + payload['data']['attributes']['email'] = user_one.username + with capture_notifications(): + res = app.post_json_api(url, payload, headers={'X-CSRFToken': csrf_token}) + assert res.status_code == 200 + user_one.reload() + assert user_one.external_identity['ORCID'] == {'1234-1234-1234-1234': 'LINK'} diff --git a/framework/auth/views.py b/framework/auth/views.py index f7450c42a47..2adeb00b3b6 100644 --- a/framework/auth/views.py +++ b/framework/auth/views.py @@ -644,7 +644,6 @@ def external_login_confirm_email_get(auth, uid, token): user.date_last_logged_in = timezone.now() user.external_identity[provider][provider_id] = 'VERIFIED' - user.social[provider.lower()] = provider_id del user.email_verifications[token] user.verification_key = generate_verification_key() user.save() @@ -1094,7 +1093,11 @@ def external_login_email_post(): # 1. update user oauth, with pending status external_identity[external_id_provider][external_id] = 'LINK' if external_id_provider in user.external_identity: - user.external_identity[external_id_provider].update(external_identity[external_id_provider]) + # v1 looks to be used because of /confirm/external/ usage for auth but add orcid external identity rewrite updates for v2 as well + if external_id_provider == settings.EXTERNAL_IDENTITY_PROFILE.get('OrcidProfile'): + user.external_identity[external_id_provider] = external_identity[external_id_provider] + else: + user.external_identity[external_id_provider].update(external_identity[external_id_provider]) else: user.external_identity.update(external_identity) if not user.accepted_terms_of_service and form.accepted_terms_of_service.data: @@ -1129,7 +1132,6 @@ def external_login_email_post(): campaign=None, accepted_terms_of_service=accepted_terms_of_service ) - # TODO: [#OSF-6934] update social fields, verified social fields cannot be modified user.save() # 3. send confirmation email send_confirm_email_async( diff --git a/osf/external/chronos/chronos.py b/osf/external/chronos/chronos.py index 03835770615..fe53db1fbf9 100644 --- a/osf/external/chronos/chronos.py +++ b/osf/external/chronos/chronos.py @@ -103,7 +103,6 @@ def serialize_user(cls, user): 'GIVEN_NAME': user.given_name, 'FULLNAME': user.fullname, 'MIDDLE_NAME': user.middle_names, - 'ORCID_ID': user.social.get('orcid', None), 'PARTNER_USER_ID': user._id, 'SURNAME': user.family_name, } diff --git a/osf/management/commands/remove_orcid_from_user_social.py b/osf/management/commands/remove_orcid_from_user_social.py new file mode 100644 index 00000000000..68f887e8583 --- /dev/null +++ b/osf/management/commands/remove_orcid_from_user_social.py @@ -0,0 +1,24 @@ +from django.core.management.base import BaseCommand +from django.db.models.expressions import RawSQL +from osf.models import OSFUser +from datetime import datetime +import logging + +logger = logging.getLogger(__name__) + + +def remove_orcid_from_user_social(): + start = datetime.now() + orcid_records = OSFUser.objects.filter(social__has_key='orcid') + logger.info(f'extracted orcid records count {orcid_records.count()}') + total_deleted_records = 0 + while OSFUser.objects.filter(social__has_key='orcid').exists(): + total_deleted_records += OSFUser.objects.filter( + id__in=OSFUser.objects.filter(social__has_key='orcid')[:10_000].values_list('id', flat=True) + ).update(social=RawSQL("""social #- '{orcid}'""", [])) + logger.info(f'deleted orcid records count {total_deleted_records} in {datetime.now() - start}') + + +class Command(BaseCommand): + def handle(self, *args, **kwargs): + remove_orcid_from_user_social() diff --git a/osf/models/user.py b/osf/models/user.py index 6b19a5e6f8d..0cf1d19428a 100644 --- a/osf/models/user.py +++ b/osf/models/user.py @@ -153,6 +153,7 @@ class OSFUser(DirtyFieldsMixin, GuidMixin, BaseModel, AbstractBaseUser, Permissi 'schools', 'social', 'allow_indexing', + 'external_identity', } # Overrides DirtyFieldsMixin, Foreign Keys checked by '_id' rather than typical name. @@ -163,7 +164,6 @@ class OSFUser(DirtyFieldsMixin, GuidMixin, BaseModel, AbstractBaseUser, Permissi # search update for all nodes to which the user is a contributor. SOCIAL_FIELDS = { - 'orcid': 'http://orcid.org/{}', 'github': 'http://github.com/{}', 'scholar': 'http://scholar.google.com/citations?user={}', 'twitter': 'http://twitter.com/{}', @@ -346,7 +346,6 @@ class OSFUser(DirtyFieldsMixin, GuidMixin, BaseModel, AbstractBaseUser, Permissi # 'twitter': , # 'github': , # 'linkedIn': , - # 'orcid': , # 'researcherID': , # 'impactStory': , # 'scholar': , diff --git a/osf_tests/test_elastic_search.py b/osf_tests/test_elastic_search.py index b6c20e5af4a..72866cfc031 100644 --- a/osf_tests/test_elastic_search.py +++ b/osf_tests/test_elastic_search.py @@ -1099,16 +1099,6 @@ def test_search_partial_special_character(self): contribs = search.search_contributor(self.name4.split(' ')[0][:-1]) assert len(contribs['users']) == 0 - def test_search_profile(self): - orcid = '123456' - user = factories.UserFactory() - user.social['orcid'] = orcid - user.save() - contribs = search.search_contributor(orcid) - assert len(contribs['users']) == 1 - assert len(contribs['users'][0]['social']) == 1 - assert contribs['users'][0]['social']['orcid'] == user.social_links['orcid'] - @pytest.mark.enable_search @pytest.mark.enable_enqueue_task diff --git a/osf_tests/test_search_views.py b/osf_tests/test_search_views.py index 858decb9d1a..08c4c9a8a5e 100644 --- a/osf_tests/test_search_views.py +++ b/osf_tests/test_search_views.py @@ -174,7 +174,6 @@ def test_search_views(self): user_two.social = { 'profileWebsites': [f'http://me.com/{user_two.given_name}'], - 'orcid': user_two.given_name, 'linkedIn': user_two.given_name, 'scholar': user_two.given_name, 'impactStory': user_two.given_name, @@ -207,7 +206,6 @@ def test_search_views(self): assert 'ssrn' not in res.json['results'][0]['social'] assert res.json['results'][0]['social']['profileWebsites'][0] == f'http://me.com/{user_two.given_name}' assert res.json['results'][0]['social']['impactStory'] == f'https://impactstory.org/u/{user_two.given_name}' - assert res.json['results'][0]['social']['orcid'] == f'http://orcid.org/{user_two.given_name}' assert res.json['results'][0]['social']['baiduScholar'] == f'http://xueshu.baidu.com/scholarID/{user_two.given_name}' assert res.json['results'][0]['social']['linkedIn'] == f'https://www.linkedin.com/{user_two.given_name}' assert res.json['results'][0]['social']['scholar'] == f'http://scholar.google.com/citations?user={user_two.given_name}' diff --git a/tests/identifiers/test_crossref.py b/tests/identifiers/test_crossref.py index e05284f303e..87c81822e2c 100644 --- a/tests/identifiers/test_crossref.py +++ b/tests/identifiers/test_crossref.py @@ -294,20 +294,6 @@ def test_metadata_contributor_orcid(self, crossref_client, preprint): assert contributors.find('.//{%s}ORCID' % crossref.CROSSREF_NAMESPACE).text == f'https://orcid.org/{ORCID}' assert contributors.find('.//{%s}ORCID' % crossref.CROSSREF_NAMESPACE).attrib == {'authenticated': 'true'} - # unverified (only in profile) - contributor.external_identity = {} - contributor.social = { - 'orcid': ORCID - } - contributor.save() - - crossref_xml = crossref_client.build_metadata(preprint) - root = lxml.etree.fromstring(crossref_xml) - contributors = root.find('.//{%s}contributors' % crossref.CROSSREF_NAMESPACE) - - # Do not send unverified ORCID to crossref - assert contributors.find('.//{%s}ORCID' % crossref.CROSSREF_NAMESPACE) is None - def test_metadata_none_license_update(self, crossref_client, preprint): crossref_xml = crossref_client.build_metadata(preprint) root = lxml.etree.fromstring(crossref_xml) diff --git a/website/static/js/profile.js b/website/static/js/profile.js index 635d7e2ff44..3e706431bca 100644 --- a/website/static/js/profile.js +++ b/website/static/js/profile.js @@ -43,7 +43,6 @@ function urlRegex() { } var socialRules = { - orcid: /orcid\.org\/([-\d]+)/i, researcherId: /researcherid\.com\/rid\/([-\w]+)/i, scholar: /scholar\.google\.com\/citations\?user=(\w+)/i, twitter: /twitter\.com\/(\w+)/i, @@ -601,10 +600,6 @@ var SocialViewModel = function(urls, modes, preventUnsaved) { return true; }); - self.orcid = extendLink( - ko.observable().extend({trimmed: true, cleanup: cleanByRule(socialRules.orcid)}), - self, 'orcid', 'http://orcid.org/' - ); self.researcherId = extendLink( ko.observable().extend({trimmed: true, cleanup: cleanByRule(socialRules.researcherId)}), self, 'researcherId', 'http://researcherId.com/rid/' @@ -653,7 +648,6 @@ var SocialViewModel = function(urls, modes, preventUnsaved) { self.trackedProperties = [ self.profileWebsites, - self.orcid, self.researcherId, self.twitter, self.scholar, @@ -675,7 +669,6 @@ var SocialViewModel = function(urls, modes, preventUnsaved) { self.values = ko.computed(function() { return [ - {label: 'ORCID', text: self.orcid(), value: self.orcid.url()}, {label: 'ResearcherID', text: self.researcherId(), value: self.researcherId.url()}, {label: 'Twitter', text: self.twitter(), value: self.twitter.url()}, {label: 'GitHub', text: self.github(), value: self.github.url()}, diff --git a/website/templates/include/profile/social.mako b/website/templates/include/profile/social.mako index 752c77aba61..caca01de2fb 100644 --- a/website/templates/include/profile/social.mako +++ b/website/templates/include/profile/social.mako @@ -35,14 +35,6 @@ -
    - -
    - http://orcid.org/ - -
    -
    -
    @@ -190,7 +182,6 @@ {% endblock %} diff --git a/admin/templates/institutions/list.html b/admin/templates/institutions/list.html index f990c778d25..47e6d09233f 100644 --- a/admin/templates/institutions/list.html +++ b/admin/templates/institutions/list.html @@ -20,6 +20,7 @@

    List of Institutions

    Name Description Status + SSO Availability @@ -37,6 +38,7 @@

    List of Institutions

    {% else %} DEACTIVATED {% endif %} + {{ institution.sso_availability }} {% endfor %} diff --git a/admin_tests/institutions/test_views.py b/admin_tests/institutions/test_views.py index 13cb1456ab9..6326d3f608e 100644 --- a/admin_tests/institutions/test_views.py +++ b/admin_tests/institutions/test_views.py @@ -139,11 +139,24 @@ def test_institution_form(self): 'name': 'New Name', 'logo_name': 'awesome_logo.png', 'domains': 'http://kris.biz/, http://www.little.biz/', - '_id': 'newawesomeprov' + '_id': 'newawesomeprov', + 'sso_availability': 'Unavailable', } form = InstitutionForm(data=new_data) assert form.is_valid() + def test_institution_form_invalid(self): + new_data = { + 'name': 'New Name', + 'logo_name': 'awesome_logo.png', + 'domains': 'http://kris.biz/, http://www.little.biz/', + '_id': 'newawesomeprov', + 'sso_availability': 'Public', + } + form = InstitutionForm(data=new_data) + assert not form.is_valid() + assert {'__all__': ['SSO availability must be set to "Unavailable" when no delegation protocol is configured.']} == form.errors + class TestInstitutionExport(AdminTestCase): def setUp(self): @@ -214,7 +227,8 @@ def test_monthly_reporter_called_on_create(self, mock_monthly_reporter_do): 'email_domains': FakeList('domain_name', n=1), 'orcid_record_verified_source': '', 'delegation_protocol': '', - 'institutional_request_access_enabled': False + 'institutional_request_access_enabled': False, + 'sso_availability': 'Unavailable', } form = InstitutionForm(data=data) assert form.is_valid() diff --git a/api/institutions/serializers.py b/api/institutions/serializers.py index 6f4bc4f9e15..fc1e48cefdf 100644 --- a/api/institutions/serializers.py +++ b/api/institutions/serializers.py @@ -29,10 +29,12 @@ class InstitutionSerializer(JSONAPISerializer): 'id', 'name', 'auth_url', + 'sso_availability', ]) name = ser.CharField(read_only=True) id = ser.CharField(read_only=True, source='_id') + sso_availability = ser.CharField(read_only=True) description = ser.CharField(read_only=True) auth_url = ser.CharField(read_only=True) iri = ser.CharField(read_only=True, source='identifier_domain') diff --git a/api/institutions/views.py b/api/institutions/views.py index a3c0f93d0c8..d653f5b4e77 100644 --- a/api/institutions/views.py +++ b/api/institutions/views.py @@ -73,6 +73,9 @@ class InstitutionList(JSONAPIBaseView, generics.ListAPIView, ListFilterMixin): base_permissions.TokenHasScope, ) + # Adding sso_availability to MULTIPLE_VALUES_FIELDS to allow filtering institutions by multiple sso_availability values, e.g. ?filter[sso_availability]=[Unavailable,Hidden] + MULTIPLE_VALUES_FIELDS = ListFilterMixin.MULTIPLE_VALUES_FIELDS + ['sso_availability'] + required_read_scopes = [CoreScopes.INSTITUTION_READ] required_write_scopes = [CoreScopes.NULL] model_class = Institution @@ -85,7 +88,9 @@ class InstitutionList(JSONAPIBaseView, generics.ListAPIView, ListFilterMixin): ordering = ('name',) def get_default_queryset(self): - return Institution.objects.filter(_id__isnull=False, is_deleted=False) + if 'filter[sso_availability]' in self.request.query_params: + return Institution.objects.filter(_id__isnull=False, is_deleted=False) + return Institution.objects.get_non_hidden_institutions().filter(_id__isnull=False, is_deleted=False) # overrides ListAPIView def get_queryset(self): diff --git a/api_tests/institutions/views/test_institution_list.py b/api_tests/institutions/views/test_institution_list.py index 74cb0b6bc8f..60e63056b7a 100644 --- a/api_tests/institutions/views/test_institution_list.py +++ b/api_tests/institutions/views/test_institution_list.py @@ -15,6 +15,10 @@ def institution_one(self): def institution_two(self): return InstitutionFactory() + @pytest.fixture() + def institution_three(self): + return InstitutionFactory() + @pytest.fixture() def url_institution(self): return f'/{API_BASE}institutions/' @@ -47,3 +51,53 @@ def test_does_not_return_deleted_institution( assert len(res.json['data']) == 1 assert institution_one._id not in ids assert institution_two._id in ids + + def test_sso_availability_filter( + self, app, institution_one, institution_two, institution_three, url_institution + ): + institution_one.sso_availability = 'Unavailable' + institution_one.save() + + institution_two.sso_availability = 'Public' + institution_two.save() + + institution_three.sso_availability = 'Hidden' + institution_three.save() + + res = app.get(f'{url_institution}?filter[sso_availability]=[Unavailable]') + assert res.status_code == 200 + + ids = [each['id'] for each in res.json['data']] + assert len(res.json['data']) == 1 + assert institution_one._id in ids + assert institution_two._id not in ids + + res = app.get(f'{url_institution}?filter[sso_availability]=[Unavailable,Hidden]') + assert res.status_code == 200 + + ids = [each['id'] for each in res.json['data']] + assert len(res.json['data']) == 2 + assert institution_one._id in ids + assert institution_three._id in ids + assert institution_two._id not in ids + + def test_default_filter_excludes_institutions_with_sso_availability_hidden( + self, app, institution_one, institution_two, institution_three, url_institution + ): + institution_one.sso_availability = 'Unavailable' + institution_one.save() + + institution_two.sso_availability = 'Public' + institution_two.save() + + institution_three.sso_availability = 'Hidden' + institution_three.save() + + res = app.get(url_institution) + assert res.status_code == 200 + + ids = [each['id'] for each in res.json['data']] + assert len(res.json['data']) == 2 + assert institution_one._id in ids + assert institution_two._id in ids + assert institution_three._id not in ids diff --git a/framework/auth/utils.py b/framework/auth/utils.py index 520c9489e1b..53c7df21a70 100644 --- a/framework/auth/utils.py +++ b/framework/auth/utils.py @@ -11,6 +11,7 @@ from framework import sentry from website import settings +from website.util import web_url_for logger = logging.getLogger(__name__) @@ -169,3 +170,16 @@ def generate_csl_given_name(given_name, middle_names='', suffix=''): if suffix: given = f'{given}, {suffix}' return given + +def get_default_osf_login_url(): + """Return the default OSF login URL. + """ + next_url = web_url_for(view_name='index', _absolute=True, _angular_route=True) + return web_url_for(view_name='auth_login', _absolute=True, next=next_url) + + +def get_default_osf_logout_url(): + """Return the default OSF logout URL. + """ + next_url = web_url_for(view_name='index', _absolute=True, _angular_route=True) + return web_url_for(view_name='auth_logout', _absolute=True, next=next_url) diff --git a/framework/auth/views.py b/framework/auth/views.py index 2adeb00b3b6..debbedb0d55 100644 --- a/framework/auth/views.py +++ b/framework/auth/views.py @@ -84,7 +84,7 @@ def _reset_password_get(auth, uid=None, token=None, institutional=False): raise HTTPError(http_status.HTTP_400_BAD_REQUEST, data=error_data) # override routes.py login_url to redirect to my-projects - service_url = web_url_for('my_projects', _absolute=True) + service_url = web_url_for('dashboard', _absolute=True, _angular_route=True) return { 'uid': user_obj._id, @@ -142,7 +142,7 @@ def reset_password_post(uid=None, token=None): status.push_status_message('Password reset', kind='success', trust=False) # redirect to CAS and authenticate the user automatically with one-time verification key. return redirect(cas.get_login_url( - web_url_for('user_account', _absolute=True), + web_url_for('user_account', _absolute=True, _angular_route=True), username=user_obj.username, verification_key=user_obj.verification_key )) @@ -176,7 +176,7 @@ def forgot_password_get(auth): #overriding the routes.py sign in url to redirect to the my-projects after login context = {} - context['login_url'] = web_url_for('my_projects', _absolute=True) + context['login_url'] = web_url_for('dashboard', _absolute=True, _angular_route=True) return context @@ -324,7 +324,7 @@ def login_and_register_handler(auth, login=True, campaign=None, next_url=None, l # unlike other campaigns, institution login serves as an alternative for authentication if campaign == 'institution': if next_url is None: - next_url = web_url_for('my_projects', _absolute=True) + next_url = web_url_for('dashboard', _absolute=True, _angular_route=True) data['status_code'] = http_status.HTTP_302_FOUND if auth.logged_in: data['next_url'] = next_url @@ -391,7 +391,7 @@ def login_and_register_handler(auth, login=True, campaign=None, next_url=None, l # `/login/` or `/register/` without any parameter if auth.logged_in: data['status_code'] = http_status.HTTP_302_FOUND - data['next_url'] = web_url_for('my_projects', _absolute=True) + data['next_url'] = web_url_for('dashboard', _absolute=True, _angular_route=True) return data @@ -614,7 +614,7 @@ def external_login_confirm_email_get(auth, uid, token): return redirect(campaign_url) if new: status.push_status_message(language.WELCOME_MESSAGE, kind='default', jumbotron=True, trust=True, id='welcome_message') - return redirect(web_url_for('my_projects')) + return redirect(web_url_for('dashboard', _absolute=True, _angular_route=True)) # token is invalid if token not in user.email_verifications: @@ -712,10 +712,10 @@ def confirm_email_get(token, auth=None, **kwargs): status.push_status_message(language.WELCOME_MESSAGE, kind='default', jumbotron=True, trust=True, id='welcome_message') if token in auth.user.email_verifications: status.push_status_message(language.CONFIRM_ALTERNATE_EMAIL_ERROR, kind='danger', trust=True, id='alternate_email_error') - return redirect(web_url_for('my_projects')) + return redirect(web_url_for('dashboard', _absolute=True, _angular_route=True)) status.push_status_message(language.MERGE_COMPLETE, kind='success', trust=False) - return redirect(web_url_for('user_account')) + return redirect(web_url_for('user_account', _absolute=True, _angular_route=True)) try: user.confirm_email(token, merge=is_merge) @@ -1053,8 +1053,7 @@ def external_login_email_post(): fullname = session.get('auth_user_fullname', None) or form.name.data service_url = session.get('service_url', None) - # TODO: @cslzchen use user tags instead of destination - destination = 'my_projects' + destination = 'dashboard' for campaign in campaigns.get_campaigns(): if campaign != 'institution': # Handle different url encoding schemes between `furl` and `urlparse/urllib`. @@ -1202,6 +1201,10 @@ def validate_next_url(next_url): :return: True if valid, False otherwise """ + # allow redirection to angular locally + if settings.LOCAL_MODE and next_url.startswith(settings.LOCAL_ANGULAR_DOMAIN): + return True + # disable external domain using `//`: the browser allows `//` as a shortcut for non-protocol specific requests # like http:// or https:// depending on the use of SSL on the page already. if next_url.startswith('//'): diff --git a/framework/sessions/__init__.py b/framework/sessions/__init__.py index 5891cb6d69f..bc11b690b12 100644 --- a/framework/sessions/__init__.py +++ b/framework/sessions/__init__.py @@ -15,7 +15,6 @@ from osf.utils.fields import ensure_str from osf.exceptions import InvalidCookieOrSessionError from website import settings -from website.util import web_url_for SessionStore = import_module(django_conf_settings.SESSION_ENGINE).SessionStore @@ -165,7 +164,7 @@ def create_session(response, data=None): def before_request(): # TODO: Fix circular import from framework.auth.core import get_user - from framework.auth import cas + from framework.auth import cas, utils UserSessionMap = apps.get_model('osf.UserSessionMap') # Request Type 1: Service ticket validation during CAS login. @@ -216,7 +215,8 @@ def before_request(): try: user_session = flask_get_session_from_cookie(cookie) except InvalidCookieOrSessionError: - response = redirect(web_url_for('auth_login')) + # If invalid session/cookie happens, perform a full logout to clear both CAS and OSF Sessions + response = redirect(utils.get_default_osf_logout_url()) response.delete_cookie(settings.COOKIE_NAME, domain=settings.OSF_COOKIE_DOMAIN) return response # Case 1: anonymous session that is used for first time external (e.g. ORCiD) login only diff --git a/osf/management/commands/backfill_sso_availability.py b/osf/management/commands/backfill_sso_availability.py new file mode 100644 index 00000000000..d86e9362ff6 --- /dev/null +++ b/osf/management/commands/backfill_sso_availability.py @@ -0,0 +1,79 @@ +from django.core.management.base import BaseCommand +from django.db import transaction +from django.db.models import Q + +from osf.models.institution import Institution, SSOAvailability, IntegrationType + + +class Command(BaseCommand): + help = 'Backfill sso_availability using fast DB-level updates' + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help='Show how many rows would be updated without applying changes', + ) + + def handle(self, *args, **options): + dry_run = options['dry_run'] + + # Build querysets + qs_no_protocol = Institution.objects.filter( + delegation_protocol=IntegrationType.NONE.value + ).exclude( + sso_availability=SSOAvailability.UNAVAILABLE.value + ) + + qs_inactive_with_protocol = Institution.objects.filter( + ~Q(delegation_protocol=IntegrationType.NONE.value), + deactivated__isnull=False + ).exclude( + sso_availability=SSOAvailability.HIDDEN.value + ) + + qs_active_with_protocol = Institution.objects.filter( + ~Q(delegation_protocol=IntegrationType.NONE.value), + deactivated__isnull=True + ).exclude( + sso_availability=SSOAvailability.PUBLIC.value + ) + + count_no_protocol = qs_no_protocol.count() + count_inactive = qs_inactive_with_protocol.count() + count_active = qs_active_with_protocol.count() + + total = count_no_protocol + count_inactive + count_active + + self.stdout.write('Planned updates:') + self.stdout.write(f" No protocol → UNAVAILABLE: {count_no_protocol}") + self.stdout.write(f" Inactive + protocol → HIDDEN: {count_inactive}") + self.stdout.write(f" Active + protocol → PUBLIC: {count_active}") + self.stdout.write(f" TOTAL: {total}") + + if dry_run: + self.stdout.write(self.style.WARNING('Dry run, no changes applied.')) + return + + with transaction.atomic(): + updated_no_protocol = qs_no_protocol.update( + sso_availability=SSOAvailability.UNAVAILABLE.value + ) + + updated_inactive = qs_inactive_with_protocol.update( + sso_availability=SSOAvailability.HIDDEN.value + ) + + updated_active = qs_active_with_protocol.update( + sso_availability=SSOAvailability.PUBLIC.value + ) + + self.stdout.write( + self.style.SUCCESS( + 'Done:\n' + f" UNAVAILABLE: {updated_no_protocol}\n" + f" HIDDEN: {updated_inactive}\n" + f" PUBLIC: {updated_active}\n" + f" TOTAL: {updated_no_protocol + updated_inactive + updated_active}" + ) + ) diff --git a/osf/migrations/0038_institution_sso_availability.py b/osf/migrations/0038_institution_sso_availability.py new file mode 100644 index 00000000000..c4be5de4001 --- /dev/null +++ b/osf/migrations/0038_institution_sso_availability.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.15 on 2026-03-13 11:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0037_notification_refactor_post_release'), + ] + + operations = [ + migrations.AddField( + model_name='institution', + name='sso_availability', + field=models.CharField(choices=[('Public', 'PUBLIC'), ('Unavailable', 'UNAVAILABLE'), ('Hidden', 'HIDDEN')], default='Hidden', max_length=15), + ), + ] diff --git a/osf/migrations/0039_merge_20260427_1359.py b/osf/migrations/0039_merge_20260427_1359.py new file mode 100644 index 00000000000..51979d93ca9 --- /dev/null +++ b/osf/migrations/0039_merge_20260427_1359.py @@ -0,0 +1,14 @@ +# Generated by Django 4.2.15 on 2026-04-27 13:59 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0038_abstractnode_date_last_indexed_and_more'), + ('osf', '0038_institution_sso_availability'), + ] + + operations = [ + ] diff --git a/osf/models/institution.py b/osf/models/institution.py index 3671e7bef1f..87cb12eed05 100644 --- a/osf/models/institution.py +++ b/osf/models/institution.py @@ -24,6 +24,7 @@ from .validators import validate_email from osf.utils.fields import NonNaiveDateTimeField, LowercaseEmailField from website import settings as website_settings +from urllib.parse import quote logger = logging.getLogger(__name__) @@ -46,6 +47,13 @@ class SsoFilterCriteriaAction(Enum): CONTAINS = 'contains' # Type 2: SSO releases a multi-value attribute, of which one value matches IN = 'in' # Type 3: SSO releases a single-value attribute that have multiple valid values +class SSOAvailability(Enum): + """Defines 3 SSO availability states for institutions. + """ + PUBLIC = 'Public' # Active, has a delegation protocol and SSO setup has been verified + UNAVAILABLE = 'Unavailable' # Does not have a delegation protocol + HIDDEN = 'Hidden' # 1) Inactive and has a delegation protocol, or 2) active, has a delegation protocol and SSO setup is in-progress + class InstitutionManager(models.Manager): @@ -55,6 +63,9 @@ def get_queryset(self): def get_all_institutions(self): return super().get_queryset() + def get_non_hidden_institutions(self): + return super().get_queryset().filter(deactivated__isnull=True, sso_availability__in=[SSOAvailability.PUBLIC.value, SSOAvailability.UNAVAILABLE.value]) + class Institution(DirtyFieldsMixin, Loggable, ObjectIDMixin, BaseModel, GuardianMixin): objects = InstitutionManager() @@ -79,6 +90,13 @@ class Institution(DirtyFieldsMixin, Loggable, ObjectIDMixin, BaseModel, Guardian default='' ) + # Institution SSO availability + sso_availability = models.CharField( + choices=[(choice.value, choice.name) for choice in SSOAvailability], + max_length=15, + default=SSOAvailability.HIDDEN.value + ) + # Default Storage Region storage_regions = models.ManyToManyField( 'addons_osfstorage.Region', @@ -194,6 +212,21 @@ def banner_path(self): except InstitutionAssetFile.DoesNotExist: return '/static/img/institutions/banners/placeholder-banner.png' + @property + def cas_login_url(self): + if self.delegation_protocol == IntegrationType.NONE.value: + return None + # Note: admin app can't use `web_url_for()` due to out of context + next_url_param = quote(website_settings.DOMAIN, safe='') + service_url_param = quote(f'{website_settings.DOMAIN}login?next={next_url_param}', safe='') + institution_id_param = quote(self._id, safe='') + return ( + f'{website_settings.CAS_SERVER_URL}/login' + f'?campaign=institution' + f'&institutionId={institution_id_param}' + f'&service={service_url_param}' + ) + def update_search(self): from website.search.search import update_institution from website.search.exceptions import SearchUnavailableError @@ -237,6 +270,11 @@ def deactivate(self): """ if not self.deactivated: self.deactivated = timezone.now() + if not self.delegation_protocol: + self.sso_availability = SSOAvailability.UNAVAILABLE.value + else: + self.sso_availability = SSOAvailability.HIDDEN.value + self.save() # Django mangers aren't used when querying on related models. Thus, we can query # affiliated users and send notification emails after the institution has been deactivated. @@ -251,6 +289,10 @@ def reactivate(self): """ if self.deactivated: self.deactivated = None + if not self.delegation_protocol: + self.sso_availability = SSOAvailability.UNAVAILABLE.value + else: + self.sso_availability = SSOAvailability.HIDDEN.value self.save() else: message = f'Action rejected - reactivating an active institution [{self._id}].' diff --git a/osf_tests/factories.py b/osf_tests/factories.py index 0b357aed1aa..0d5e5ad0c32 100644 --- a/osf_tests/factories.py +++ b/osf_tests/factories.py @@ -28,6 +28,7 @@ from osf import models from osf.models.sanctions import Sanction from osf.models.storage import PROVIDER_ASSET_NAME_CHOICES +from osf.models.institution import SSOAvailability from osf.utils.names import impute_names_model from osf.utils.workflows import ( DefaultStates, @@ -258,6 +259,7 @@ class InstitutionFactory(DjangoModelFactory): orcid_record_verified_source = '' delegation_protocol = '' institutional_request_access_enabled = False + sso_availability = SSOAvailability.PUBLIC.value class Meta: model = models.Institution diff --git a/osf_tests/test_institution.py b/osf_tests/test_institution.py index 039b0ce04dd..867723cf291 100644 --- a/osf_tests/test_institution.py +++ b/osf_tests/test_institution.py @@ -128,6 +128,20 @@ def test_deactivated_institution_in_all_institutions(self): institution.save() assert institution in Institution.objects.get_all_institutions() + def test_deactivate_sso_institution(self): + institution = InstitutionFactory() + institution.delegation_protocol = 'saml-shib' + institution.save() + with mock.patch.object( + institution, + '_send_deactivation_email', + return_value=None + ) as mock__send_deactivation_email: + institution.deactivate() + assert institution.deactivated is not None + assert mock__send_deactivation_email.called + assert institution.sso_availability == 'Hidden' + def test_deactivate_institution(self): institution = InstitutionFactory() with mock.patch.object( @@ -138,6 +152,16 @@ def test_deactivate_institution(self): institution.deactivate() assert institution.deactivated is not None assert mock__send_deactivation_email.called + assert institution.sso_availability == 'Unavailable' + + def test_reactivate_sso_institution(self): + institution = InstitutionFactory() + institution.delegation_protocol = 'saml-shib' + institution.deactivated = timezone.now() + institution.save() + institution.reactivate() + assert institution.deactivated is None + assert institution.sso_availability == 'Hidden' def test_reactivate_institution(self): institution = InstitutionFactory() @@ -145,6 +169,7 @@ def test_reactivate_institution(self): institution.save() institution.reactivate() assert institution.deactivated is None + assert institution.sso_availability == 'Unavailable' def test_send_deactivation_email_call_count(self): institution = InstitutionFactory() diff --git a/scripts/populate_institutions.py b/scripts/populate_institutions.py index f46f7c67113..64bf59bd2b9 100644 --- a/scripts/populate_institutions.py +++ b/scripts/populate_institutions.py @@ -12,13 +12,13 @@ from website import settings from website.app import init_app -from osf.models import Institution +from osf.models.institution import Institution, SSOAvailability, IntegrationType from website.search.search import update_institution logger = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO) -ENVS = ['prod', 'stage', 'stage2', 'stage3', 'test', 'local'] +ENVS = ['prod', 'stage', 'stage2', 'stage3', 'test', 'local', 'auto_generated'] # TODO: Store only the Entity IDs in OSF DB and move the URL building process to CAS SHIBBOLETH_SP_LOGIN = f'{settings.CAS_SERVER_URL}/Shibboleth.sso/Login?entityID={{}}' @@ -37,7 +37,10 @@ def encode_uri_component(val): def update_or_create(inst_data): - inst = Institution.load(inst_data['_id']) + try: + inst = Institution.objects.get_all_institutions().get(_id=inst_data['_id']) + except Institution.DoesNotExist: + inst = None if inst: for key, val in inst_data.items(): setattr(inst, key, val) @@ -53,6 +56,53 @@ def update_or_create(inst_data): return inst, True +PROTOCOL_MAP = { + IntegrationType.SAML_SHIBBOLETH.value: 'SAML', + IntegrationType.CAS_PAC4J.value: 'CAS', + IntegrationType.OAUTH_PAC4J.value: 'OAuth', + IntegrationType.AFFILIATION_VIA_ORCID.value: 'ORCiD', + IntegrationType.NONE.value: 'None', +} + + +DEACTIVATED_STATES = [ + None, + '2026-01-01T00:00:00+00:00', +] + + +def get_valid_sso_states(protocol, deactivated): + is_active = deactivated is None + if not protocol: + return [SSOAvailability.UNAVAILABLE.value] + if not is_active: + return [SSOAvailability.HIDDEN.value] + return [SSOAvailability.PUBLIC.value, SSOAvailability.HIDDEN.value] + + +def generate_test_institutions(): + institutions = [] + + for protocol in PROTOCOL_MAP.keys(): + for deactivated in DEACTIVATED_STATES: + for availability in get_valid_sso_states(protocol, deactivated): + _id = f"{PROTOCOL_MAP[protocol]}_{availability}_{'a' if deactivated is None else 'i'}".lower() + institutions.append({ + '_id': _id, + 'name': f'Test Institution [{PROTOCOL_MAP[protocol] if protocol else "None"} {availability} {"Inactive" if deactivated else "Active"}]', + 'description': f'Description for {PROTOCOL_MAP[protocol] if protocol else "None"} {availability} {"Inactive" if deactivated else "Active"}', + 'login_url': SHIBBOLETH_SP_LOGIN.format(encode_uri_component(f'{_id}-entity-id')) if protocol == IntegrationType.SAML_SHIBBOLETH.value else None, + 'logout_url': SHIBBOLETH_SP_LOGOUT.format(encode_uri_component(f'{settings.DOMAIN}{_id}')) if protocol == IntegrationType.SAML_SHIBBOLETH.value else None, + 'domains': [], + 'email_domains': [f'{_id}.osf.io'] if not protocol else [], + 'delegation_protocol': protocol, + 'sso_availability': availability, + 'deactivated': deactivated, + }) + + return institutions + + def main(default_args=False): if default_args: @@ -67,7 +117,12 @@ def main(default_args=False): if not server_env or server_env not in ENVS: logger.error(f'A valid environment must be specified: {ENVS}') sys.exit(1) - institutions = INSTITUTIONS[server_env] + + if server_env == 'auto_generated': + logger.info('Generating institutions with all combinations of protocol, availability, and deactivated states for testing purposes.') + institutions = generate_test_institutions() + else: + institutions = INSTITUTIONS[server_env] if not update_all and not update_ids: logger.error('Nothing to update or create. Please either specify a list of institutions ' @@ -101,6 +156,7 @@ def main(default_args=False): 'domains': [], 'email_domains': ['a2jlab.org'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'albion', @@ -113,6 +169,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'asu', @@ -125,6 +182,7 @@ def main(default_args=False): 'domains': ['osf.asu.edu'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'brown', @@ -135,6 +193,7 @@ def main(default_args=False): 'domains': ['osf.brown.edu'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'bt', @@ -145,6 +204,7 @@ def main(default_args=False): 'domains': ['osf.boystownhospital.org'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'bu', @@ -155,6 +215,7 @@ def main(default_args=False): 'domains': ['osf.bu.edu'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'busara', @@ -165,6 +226,7 @@ def main(default_args=False): 'domains': [], 'email_domains': ['busaracenter.org'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'callutheran', @@ -175,6 +237,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'capolicylab', @@ -185,6 +248,7 @@ def main(default_args=False): 'domains': [], 'email_domains': ['capolicylab.org'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'cfa', @@ -195,6 +259,7 @@ def main(default_args=False): 'domains': [], 'email_domains': ['cfa.harvard.edu'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'clrn', @@ -205,6 +270,7 @@ def main(default_args=False): 'domains': [], 'email_domains': ['characterlab.org'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'cmu', @@ -219,6 +285,7 @@ def main(default_args=False): 'domains': ['osf.library.cmu.edu'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'colorado', @@ -229,6 +296,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'cord', @@ -239,6 +307,7 @@ def main(default_args=False): 'domains': ['osf.cord.edu'], 'email_domains': [], 'delegation_protocol': 'cas-pac4j', + 'sso_availability': 'Public', }, { '_id': 'cornell', @@ -249,6 +318,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'cos', @@ -259,6 +329,7 @@ def main(default_args=False): 'domains': ['osf.cos.io'], 'email_domains': ['cos.io'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'csic', @@ -269,6 +340,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'cwru', @@ -279,6 +351,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'duke', @@ -289,6 +362,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'ecu', @@ -299,6 +373,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'esip', @@ -309,6 +384,7 @@ def main(default_args=False): 'domains': [], 'email_domains': ['esipfed.org'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'eur', @@ -324,6 +400,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'ferris', @@ -334,6 +411,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'fsu', @@ -344,6 +422,7 @@ def main(default_args=False): 'domains': ['osf.fsu.edu'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'gatech', @@ -354,6 +433,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'gmu', @@ -364,6 +444,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'gwu', @@ -374,6 +455,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'harvard', @@ -384,6 +466,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'ibhri', @@ -394,6 +477,7 @@ def main(default_args=False): 'domains': ['osf.ibhri.org'], 'email_domains': ['ibhri.org'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'icarehb', @@ -404,6 +488,7 @@ def main(default_args=False): 'domains': [], 'email_domains': ['icarehb.com'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'icer', @@ -414,6 +499,7 @@ def main(default_args=False): 'domains': ['osf.icer-review.org'], 'email_domains': ['icer-review.org'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'igdore', @@ -427,6 +513,7 @@ def main(default_args=False): 'domains': [], 'email_domains': ['igdore.org'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'iit', @@ -437,6 +524,7 @@ def main(default_args=False): 'domains': ['osf.iit.edu'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'itb', @@ -447,6 +535,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'jhu', @@ -457,6 +546,7 @@ def main(default_args=False): 'domains': ['osf.data.jhu.edu'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'jmu', @@ -467,6 +557,7 @@ def main(default_args=False): 'domains': ['osf.jmu.edu'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'jpal', @@ -477,6 +568,7 @@ def main(default_args=False): 'domains': ['osf.povertyactionlab.org'], 'email_domains': ['povertyactionlab.org'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'kuleuven', @@ -487,6 +579,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'ljaf', @@ -497,6 +590,7 @@ def main(default_args=False): 'domains': [], 'email_domains': ['arnoldfoundation.org'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'mit', @@ -507,6 +601,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'mq', @@ -517,6 +612,7 @@ def main(default_args=False): 'domains': ['osf.mq.edu.au'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'nationalmaglab', @@ -527,6 +623,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'nesta', @@ -537,6 +634,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'nd', @@ -547,6 +645,7 @@ def main(default_args=False): 'domains': ['osf.nd.edu'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'nyu', @@ -557,6 +656,7 @@ def main(default_args=False): 'domains': ['osf.nyu.edu'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'okstate', @@ -567,6 +667,7 @@ def main(default_args=False): 'domains': ['osf.library.okstate.edu'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'ou', @@ -577,6 +678,7 @@ def main(default_args=False): 'domains': ['osf.ou.edu'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'oxford', @@ -587,6 +689,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'via-orcid', + 'sso_availability': 'Public', 'orcid_record_verified_source': 'ORCID Integration at the University of Oxford', }, { @@ -598,6 +701,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'purdue', @@ -608,6 +712,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'sc', @@ -620,6 +725,7 @@ def main(default_args=False): 'domains': ['osf.sc.edu'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'temple', @@ -630,6 +736,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'thepolicylab', @@ -640,6 +747,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'thelabatdc', @@ -650,6 +758,7 @@ def main(default_args=False): 'domains': [], 'email_domains': ['dc.gov'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'theworks', @@ -660,6 +769,7 @@ def main(default_args=False): 'domains': [], 'email_domains': ['theworks.info'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'tufts', @@ -670,6 +780,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'ua', @@ -680,6 +791,7 @@ def main(default_args=False): 'domains': ['osf.arizona.edu'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'ubc', @@ -690,6 +802,7 @@ def main(default_args=False): 'domains': ['osf.openscience.ubc.ca'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'uc', @@ -700,6 +813,7 @@ def main(default_args=False): 'domains': ['osf.uc.edu'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'ucla', @@ -710,6 +824,7 @@ def main(default_args=False): 'domains': ['osf.ucla.edu'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'ucsd', @@ -720,6 +835,7 @@ def main(default_args=False): 'domains': ['osf.ucsd.edu'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'ucr', @@ -730,6 +846,7 @@ def main(default_args=False): 'domains': ['osf.ucr.edu'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'uct', @@ -740,6 +857,7 @@ def main(default_args=False): 'domains': ['osf.uct.ac.za'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'ugent', @@ -750,6 +868,7 @@ def main(default_args=False): 'domains': ['osf.ugent.be'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'ugoe', @@ -760,6 +879,7 @@ def main(default_args=False): 'domains': ['osf.uni-goettingen.de'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'umb', @@ -770,6 +890,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'umd', @@ -780,6 +901,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'unc', @@ -790,6 +912,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'universityofkent', @@ -800,6 +923,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'uol', @@ -810,6 +934,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'uom', @@ -820,6 +945,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'usc', @@ -830,6 +956,7 @@ def main(default_args=False): 'domains': ['osf.usc.edu'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'ush', @@ -840,6 +967,7 @@ def main(default_args=False): 'domains': [], 'email_domains': ['uvers.ac.id'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'utdallas', @@ -850,6 +978,7 @@ def main(default_args=False): 'domains': ['osf.utdallas.edu'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'uva', @@ -860,6 +989,7 @@ def main(default_args=False): 'domains': ['osf.virginia.edu'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'uw', @@ -870,6 +1000,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'uwstout', @@ -880,6 +1011,7 @@ def main(default_args=False): 'domains': ['open.uwstout.edu'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'vcu', @@ -890,6 +1022,7 @@ def main(default_args=False): 'domains': ['osf.research.vcu.edu'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'vt', @@ -900,6 +1033,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'vua', @@ -910,6 +1044,7 @@ def main(default_args=False): 'domains': ['osf.vu.nl'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'wustl', @@ -920,6 +1055,7 @@ def main(default_args=False): 'domains': ['osf.wustl.edu'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, ], 'stage': [ @@ -932,6 +1068,7 @@ def main(default_args=False): 'domains': ['staging-osf.cos.io'], 'email_domains': ['cos.io'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'nd', @@ -942,6 +1079,7 @@ def main(default_args=False): 'domains': ['staging-osf-nd.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'google', @@ -952,6 +1090,7 @@ def main(default_args=False): 'domains': [], 'email_domains': ['gmail.com'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'yahoo', @@ -961,6 +1100,7 @@ def main(default_args=False): 'domains': [], 'email_domains': ['yahoo.com'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'oxford', @@ -971,6 +1111,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'via-orcid', + 'sso_availability': 'Public', 'orcid_record_verified_source': 'ORCID Integration at the University of Oxford', }, { @@ -983,6 +1124,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'via-orcid', + 'sso_availability': 'Public', 'orcid_record_verified_source': 'OSF Integration', }, ], @@ -996,6 +1138,7 @@ def main(default_args=False): 'domains': ['staging2-osf.cos.io'], 'email_domains': ['cos.io'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, ], 'stage3': [ @@ -1008,6 +1151,7 @@ def main(default_args=False): 'domains': ['staging3-osf.cos.io'], 'email_domains': ['cos.io'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, ], 'test': [ @@ -1020,6 +1164,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'a2jlab', @@ -1030,6 +1175,7 @@ def main(default_args=False): 'domains': [], 'email_domains': ['a2jlab.org'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'albion', @@ -1042,6 +1188,7 @@ def main(default_args=False): 'domains': ['test-osf-ablbion.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'asu', @@ -1054,6 +1201,7 @@ def main(default_args=False): 'domains': ['test-osf-asu.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'brown', @@ -1064,6 +1212,7 @@ def main(default_args=False): 'domains': ['test-osf-brown.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'bt', @@ -1074,6 +1223,7 @@ def main(default_args=False): 'domains': ['test-osf-bt.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'bu', @@ -1084,6 +1234,7 @@ def main(default_args=False): 'domains': ['test-osf-bu.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'busara', @@ -1094,6 +1245,7 @@ def main(default_args=False): 'domains': [], 'email_domains': ['busaracenter.org'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'callutheran', @@ -1104,6 +1256,7 @@ def main(default_args=False): 'domains': ['test-osf-callutheran.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'callutheran2', @@ -1114,6 +1267,7 @@ def main(default_args=False): 'domains': ['test-osf-callutheran2.cos.io'], 'email_domains': [], 'delegation_protocol': 'cas-pac4j', + 'sso_availability': 'Public', }, { '_id': 'capolicylab', @@ -1124,6 +1278,7 @@ def main(default_args=False): 'domains': [], 'email_domains': ['capolicylab.org'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'cfa', @@ -1134,6 +1289,7 @@ def main(default_args=False): 'domains': [], 'email_domains': ['cfa.harvard.edu'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'clrn', @@ -1144,6 +1300,7 @@ def main(default_args=False): 'domains': [], 'email_domains': ['characterlab.org'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'cmu', @@ -1158,6 +1315,7 @@ def main(default_args=False): 'domains': ['test-osf-cmu.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'colorado', @@ -1168,6 +1326,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'cornell', @@ -1178,6 +1337,7 @@ def main(default_args=False): 'domains': ['test-osf-cornell.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'cord', @@ -1188,6 +1348,7 @@ def main(default_args=False): 'domains': ['test-osf-cord.cos.io'], 'email_domains': [], 'delegation_protocol': 'cas-pac4j', + 'sso_availability': 'Public', }, { '_id': 'cos', @@ -1198,6 +1359,7 @@ def main(default_args=False): 'domains': ['test-osf.cos.io'], 'email_domains': ['cos.io'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'csic', @@ -1208,6 +1370,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'cwru', @@ -1218,6 +1381,7 @@ def main(default_args=False): 'domains': ['test-osf-cwru.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'duke', @@ -1228,6 +1392,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'ecu', @@ -1238,6 +1403,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'esip', @@ -1248,6 +1414,7 @@ def main(default_args=False): 'domains': [], 'email_domains': ['esipfed.org'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'eur', @@ -1263,6 +1430,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'ferris', @@ -1273,6 +1441,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'fsu', @@ -1283,6 +1452,7 @@ def main(default_args=False): 'domains': ['test-osf-fsu.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'gatech', @@ -1293,6 +1463,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'gmu', @@ -1303,6 +1474,7 @@ def main(default_args=False): 'domains': ['test-osf-gmu.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'gwu', @@ -1313,6 +1485,7 @@ def main(default_args=False): 'domains': ['test-osf-gwu.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'harvard', @@ -1323,6 +1496,7 @@ def main(default_args=False): 'domains': ['test-osf-harvard.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'ibhri', @@ -1333,6 +1507,7 @@ def main(default_args=False): 'domains': ['test-osf-ibhri.cos.io'], 'email_domains': ['ibhri.org'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'icarehb', @@ -1343,6 +1518,7 @@ def main(default_args=False): 'domains': ['test-osf-icarehb.cos.io'], 'email_domains': ['icarehb.com'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'icer', @@ -1353,6 +1529,7 @@ def main(default_args=False): 'domains': ['test-osf-icer.cos.io'], 'email_domains': ['icer-review.org'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'igdore', @@ -1366,6 +1543,7 @@ def main(default_args=False): 'domains': ['test-osf-icer.igdore.io'], 'email_domains': ['igdore.org'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'iit', @@ -1376,6 +1554,7 @@ def main(default_args=False): 'domains': ['test-osf-iit.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'itb', @@ -1386,6 +1565,7 @@ def main(default_args=False): 'domains': ['test-osf-itb.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'jhu', @@ -1396,6 +1576,7 @@ def main(default_args=False): 'domains': ['test-osf-jhu.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'jmu', @@ -1406,6 +1587,7 @@ def main(default_args=False): 'domains': ['test-osf-jmu.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'jpal', @@ -1416,6 +1598,7 @@ def main(default_args=False): 'domains': ['test-osf-jpal.cos.io'], 'email_domains': ['povertyactionlab.org'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'kuleuven', @@ -1426,6 +1609,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'ljaf', @@ -1436,6 +1620,7 @@ def main(default_args=False): 'domains': [], 'email_domains': ['arnoldfoundation.org'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'mit', @@ -1446,6 +1631,7 @@ def main(default_args=False): 'domains': ['test-osf-mit.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'mq', @@ -1456,6 +1642,7 @@ def main(default_args=False): 'domains': ['test-osf-mq.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'nationalmaglab', @@ -1466,6 +1653,7 @@ def main(default_args=False): 'domains': ['test-osf-nationalmaglab.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'nesta', @@ -1476,6 +1664,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'nd', @@ -1486,6 +1675,7 @@ def main(default_args=False): 'domains': ['test-osf-nd.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'nyu', @@ -1496,6 +1686,7 @@ def main(default_args=False): 'domains': ['test-osf-nyu.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'okstate', @@ -1506,6 +1697,7 @@ def main(default_args=False): 'domains': ['test-osf-library-okstate.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'ou', @@ -1516,6 +1708,7 @@ def main(default_args=False): 'domains': ['test-osf-ou.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'oxford', @@ -1526,6 +1719,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'via-orcid', + 'sso_availability': 'Public', 'orcid_record_verified_source': 'ORCID Integration at the University of Oxford', }, { @@ -1537,6 +1731,7 @@ def main(default_args=False): 'domains': ['test-osf-pu.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'purdue', @@ -1547,6 +1742,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'sc', @@ -1559,6 +1755,7 @@ def main(default_args=False): 'domains': ['test-osf-sc.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'temple', @@ -1569,6 +1766,7 @@ def main(default_args=False): 'domains': ['test-osf-temple.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'thepolicylab', @@ -1579,6 +1777,7 @@ def main(default_args=False): 'domains': ['test-osf-thepolicylab.cos.io'], 'email_domains': ['policylab.io'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'thelabatdc', @@ -1589,6 +1788,7 @@ def main(default_args=False): 'domains': [], 'email_domains': ['dc.gov'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'theworks', @@ -1599,6 +1799,7 @@ def main(default_args=False): 'domains': [], 'email_domains': ['theworks.info'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'tufts', @@ -1609,6 +1810,7 @@ def main(default_args=False): 'domains': ['test-osf-tufts.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'ua', @@ -1619,6 +1821,7 @@ def main(default_args=False): 'domains': ['test-osf-ua.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'ubc', @@ -1629,6 +1832,7 @@ def main(default_args=False): 'domains': ['test-osf-ubc.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'uc', @@ -1639,6 +1843,7 @@ def main(default_args=False): 'domains': ['test-osf-uc.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'ucla', @@ -1649,6 +1854,7 @@ def main(default_args=False): 'domains': ['test-osf-ucla.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'ucsd', @@ -1659,6 +1865,7 @@ def main(default_args=False): 'domains': ['test-osf-ucsd.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'ucr', @@ -1669,6 +1876,7 @@ def main(default_args=False): 'domains': ['test-osf-ucr.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'uct', @@ -1679,6 +1887,7 @@ def main(default_args=False): 'domains': ['test-osf-uct.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'umb', @@ -1689,6 +1898,7 @@ def main(default_args=False): 'domains': ['test-osf-umb.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'umd', @@ -1699,6 +1909,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'ugent', @@ -1709,6 +1920,7 @@ def main(default_args=False): 'domains': ['test-osf-ugent.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'ugoe', @@ -1719,6 +1931,7 @@ def main(default_args=False): 'domains': ['test-osf-ugoe.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'uit', @@ -1731,6 +1944,7 @@ def main(default_args=False): 'domains': ['test-osf-uit.cos.io'], 'email_domains': ['uit.no'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'unc', @@ -1741,6 +1955,7 @@ def main(default_args=False): 'domains': ['test-osf-unc.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'universityofkent', @@ -1751,6 +1966,7 @@ def main(default_args=False): 'domains': ['test-osf-universityofkent.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'uol', @@ -1761,6 +1977,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'uom', @@ -1771,6 +1988,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'usc', @@ -1781,6 +1999,7 @@ def main(default_args=False): 'domains': ['test-osf-usc.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'ush', @@ -1791,6 +2010,7 @@ def main(default_args=False): 'domains': ['test-osf-ush.cos.io'], 'email_domains': ['uvers.ac.id'], 'delegation_protocol': '', + 'sso_availability': 'Unavailable', }, { '_id': 'utdallas', @@ -1801,6 +2021,7 @@ def main(default_args=False): 'domains': ['test-osf-utdallas.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'uva', @@ -1811,6 +2032,7 @@ def main(default_args=False): 'domains': ['test-osf-virginia.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'uw', @@ -1821,6 +2043,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'uwstout', @@ -1831,6 +2054,7 @@ def main(default_args=False): 'domains': ['test-osf-uwstout.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'vcu', @@ -1841,6 +2065,7 @@ def main(default_args=False): 'domains': ['test-osf-research-vcu.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'vt', @@ -1851,6 +2076,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'vua', @@ -1861,6 +2087,7 @@ def main(default_args=False): 'domains': ['test-osf-vua.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'wustl', @@ -1871,6 +2098,7 @@ def main(default_args=False): 'domains': ['test-osf-wustl.cos.io'], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, ], 'local': [ @@ -1884,6 +2112,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'cas-pac4j', + 'sso_availability': 'Public', }, { '_id': 'osftype1', @@ -1895,6 +2124,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'via-orcid', + 'sso_availability': 'Public', 'orcid_record_verified_source': 'OSF Integration', }, { @@ -1907,6 +2137,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', 'orcid_record_verified_source': '', }, { @@ -1920,6 +2151,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'osftype4', @@ -1932,6 +2164,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'osftype5', @@ -1944,6 +2177,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', 'orcid_record_verified_source': '', }, { @@ -1957,6 +2191,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'osftype7', @@ -1969,6 +2204,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'osftype8', @@ -1981,6 +2217,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, { '_id': 'osftype9', @@ -1993,6 +2230,7 @@ def main(default_args=False): 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'sso_availability': 'Public', }, ], } diff --git a/tests/test_auth.py b/tests/test_auth.py index 487759603c1..240a0cb317a 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -96,7 +96,7 @@ def test_confirm_email(self): res = self.app.resolve_redirect(res) assert res.status_code == 302 - assert '/my-projects/' == urlparse(res.location).path + assert '/dashboard/' == urlparse(res.location).path # assert len(get_session()['status']) == 1 def test_get_user_by_id(self): diff --git a/tests/test_auth_basic_auth.py b/tests/test_auth_basic_auth.py index e1b9d6e5b8d..86b1d5fbec1 100644 --- a/tests/test_auth_basic_auth.py +++ b/tests/test_auth_basic_auth.py @@ -99,4 +99,4 @@ def test_expired_cookie(self): self.app.set_cookie(settings.COOKIE_NAME, str(cookie)) res = self.app.get(self.reachable_url) assert res.status_code == 302 - assert '/login/' == res.location + assert 'http://localhost:5000/logout/?next=http://localhost:4200/' == res.location diff --git a/tests/test_auth_views.py b/tests/test_auth_views.py index 7b24e286c15..965501d2e03 100644 --- a/tests/test_auth_views.py +++ b/tests/test_auth_views.py @@ -551,32 +551,32 @@ def setUp(self): self.no_auth = Auth() self.user_auth = AuthUserFactory() self.auth = Auth(user=self.user_auth) - self.next_url = web_url_for('my_projects', _absolute=True) + self.next_url = web_url_for('dashboard', _absolute=True, _angular_route=True) self.invalid_campaign = 'invalid_campaign' def test_osf_login_with_auth(self): # login: user with auth data = login_and_register_handler(self.auth) assert data.get('status_code') == http_status.HTTP_302_FOUND - assert data.get('next_url') == web_url_for('my_projects', _absolute=True) + assert data.get('next_url') == web_url_for('dashboard', _absolute=True, _angular_route=True) def test_osf_login_without_auth(self): # login: user without auth data = login_and_register_handler(self.no_auth) assert data.get('status_code') == http_status.HTTP_302_FOUND - assert data.get('next_url') == web_url_for('my_projects', _absolute=True) + assert data.get('next_url') == web_url_for('dashboard', _absolute=True, _angular_route=True) def test_osf_register_with_auth(self): # register: user with auth data = login_and_register_handler(self.auth, login=False) assert data.get('status_code') == http_status.HTTP_302_FOUND - assert data.get('next_url') == web_url_for('my_projects', _absolute=True) + assert data.get('next_url') == web_url_for('dashboard', _absolute=True, _angular_route=True) def test_osf_register_without_auth(self): # register: user without auth data = login_and_register_handler(self.no_auth, login=False) assert data.get('status_code') == http_status.HTTP_200_OK - assert data.get('next_url') == web_url_for('my_projects', _absolute=True) + assert data.get('next_url') == web_url_for('dashboard', _absolute=True, _angular_route=True) def test_next_url_login_with_auth(self): # next_url login: user with auth @@ -584,6 +584,17 @@ def test_next_url_login_with_auth(self): assert data.get('status_code') == http_status.HTTP_302_FOUND assert data.get('next_url') == self.next_url + def test_next_url_angular_login_with_auth(self): + data = login_and_register_handler(self.auth, next_url=settings.LOCAL_ANGULAR_DOMAIN) + assert data.get('status_code') == http_status.HTTP_302_FOUND + assert data.get('next_url') == settings.LOCAL_ANGULAR_DOMAIN + + def test_next_url_angular_login_without_auth(self): + request.url = web_url_for('auth_login', next=settings.LOCAL_ANGULAR_DOMAIN, _absolute=True) + data = login_and_register_handler(self.no_auth, next_url=settings.LOCAL_ANGULAR_DOMAIN) + assert data.get('status_code') == http_status.HTTP_302_FOUND + assert data.get('next_url') == cas.get_login_url(request.url) + def test_next_url_login_without_auth(self): # login: user without auth request.url = web_url_for('auth_login', next=self.next_url, _absolute=True) @@ -610,14 +621,16 @@ def test_institution_login_with_auth(self): # institution login: user with auth data = login_and_register_handler(self.auth, campaign='institution') assert data.get('status_code') == http_status.HTTP_302_FOUND - assert data.get('next_url') == web_url_for('my_projects', _absolute=True) + assert data.get('next_url') == web_url_for('dashboard', _absolute=True, _angular_route=True) def test_institution_login_without_auth(self): # institution login: user without auth data = login_and_register_handler(self.no_auth, campaign='institution') assert data.get('status_code') == http_status.HTTP_302_FOUND - assert data.get('next_url') == cas.get_login_url(web_url_for('my_projects', _absolute=True), - campaign='institution') + assert data.get('next_url') == cas.get_login_url( + web_url_for('dashboard', _absolute=True, _angular_route=True), + campaign='institution' + ) def test_institution_login_next_url_with_auth(self): # institution login: user with auth and next url @@ -635,13 +648,16 @@ def test_institution_register_with_auth(self): # institution register: user with auth data = login_and_register_handler(self.auth, login=False, campaign='institution') assert data.get('status_code') == http_status.HTTP_302_FOUND - assert data.get('next_url') == web_url_for('my_projects', _absolute=True) + assert data.get('next_url') == web_url_for('dashboard', _absolute=True, _angular_route=True) def test_institution_register_without_auth(self): # institution register: user without auth data = login_and_register_handler(self.no_auth, login=False, campaign='institution') assert data.get('status_code') == http_status.HTTP_302_FOUND - assert data.get('next_url') == cas.get_login_url(web_url_for('my_projects', _absolute=True), campaign='institution') + assert data.get('next_url') == cas.get_login_url( + web_url_for('dashboard', _absolute=True, _angular_route=True), + campaign='institution' + ) def test_campaign_login_with_auth(self): for campaign in get_campaigns(): @@ -827,6 +843,20 @@ def test_logout_with_no_parameter(self): assert resp.status_code == http_status.HTTP_302_FOUND assert cas.get_logout_url(self.goodbye_url) == resp.headers['Location'] + def test_logout_with_angular_next_url_logged_in(self): + angular_url = 'http://localhost:4200/' + logout_url = web_url_for('auth_logout', _absolute=True, next=angular_url) + resp = self.app.get(logout_url, auth=self.auth_user.auth) + assert resp.status_code == http_status.HTTP_302_FOUND + assert cas.get_logout_url(logout_url) == resp.headers['Location'] + + def test_logout_with_angular_next_url_logged_out(self): + angular_url = 'http://localhost:4200/' + logout_url = web_url_for('auth_logout', _absolute=True, next=angular_url) + resp = self.app.get(logout_url, auth=None) + assert resp.status_code == http_status.HTTP_302_FOUND + assert angular_url == resp.headers['Location'] + class TestResetPassword(OsfTestCase): diff --git a/tests/test_campaigns.py b/tests/test_campaigns.py index 221ce03f8cf..49838c6244e 100644 --- a/tests/test_campaigns.py +++ b/tests/test_campaigns.py @@ -238,7 +238,7 @@ def setUp(self): super().setUp() self.url_login = web_url_for('auth_login', campaign='institution') self.url_register = web_url_for('auth_register', campaign='institution') - self.service_url = web_url_for('my_projects', _absolute=True) + self.service_url = web_url_for('dashboard', _absolute=True, _angular_route=True) # go to CAS institution login page if not logged in def test_institution_not_logged_in(self): diff --git a/tests/test_webtests.py b/tests/test_webtests.py index 64c5669f3e9..037aca0a137 100644 --- a/tests/test_webtests.py +++ b/tests/test_webtests.py @@ -79,35 +79,30 @@ def test_can_see_profile_url(self): res = self.app.get(self.user.url, follow_redirects=True) assert self.user.url in res.text - # `GET /login/` without parameters is redirected to `/my-projects/` page which has `@must_be_logged_in` decorator - # if user is not logged in, she/he is further redirected to CAS login page + # `GET /login/` (legacy BE endpoint) without parameters is redirected to `/dashboard/` (angular FE endpoint). + # It's impossible to test external redirects in tests, and it is angular's job to redirect correctly to CAS login. def test_is_redirected_to_cas_if_not_logged_in_at_login_page(self): - res = self.app.resolve_redirect(self.app.get('/login/')) + res = self.app.get('/login/') assert res.status_code == 302 - location = res.headers.get('Location') - assert 'login?service=' in location + assert 'dashboard' in res.headers.get('Location') - def test_is_redirected_to_myprojects_if_already_logged_in_at_login_page(self): + def test_is_redirected_to_dashboard_if_already_logged_in_at_login_page(self): res = self.app.get('/login/', auth=self.user.auth) assert res.status_code == 302 - assert 'my-projects' in res.headers.get('Location') + assert 'dashboard' in res.headers.get('Location') def test_register_page(self): res = self.app.get('/register/') assert res.status_code == 200 - def test_is_redirected_to_myprojects_if_already_logged_in_at_register_page(self): + def test_is_redirected_to_dashboard_if_already_logged_in_at_register_page(self): res = self.app.get('/register/', auth=self.user.auth) assert res.status_code == 302 - assert 'my-projects' in res.headers.get('Location') + assert 'dashboard' in res.headers.get('Location') def test_sees_projects_in_her_dashboard(self): - # the user already has a project - project = ProjectFactory(creator=self.user) - project.add_contributor(self.user) - project.save() - res = self.app.get('/my-projects/', auth=self.user.auth) - assert 'Projects' in res.text # Projects heading + # Deprecated test, dashboard and my-projects are angular pages + pass def test_does_not_see_osffiles_in_user_addon_settings(self): res = self.app.get('/settings/addons/', auth=self.auth, follow_redirects=True) @@ -123,10 +118,8 @@ def test_sees_osffiles_in_project_addon_settings(self): assert 'OSF Storage' in res.text def test_sees_correct_title_on_dashboard(self): - # User goes to dashboard - res = self.app.get('/my-projects/', auth=self.auth, follow_redirects=True) - title = res.html.title.string - assert 'OSF | My Projects' == title + # Deprecated test, dashboard and my-projects are angular pages + pass def test_can_see_make_public_button_if_admin(self): # User is a contributor on a project diff --git a/website/routes.py b/website/routes.py index 226f03fb1f1..af14a916183 100644 --- a/website/routes.py +++ b/website/routes.py @@ -230,11 +230,10 @@ def sitemap_file(path): def goodbye(): # Redirect to dashboard if logged in - redirect_url = util.web_url_for('auth_login') if _get_current_user(): - return redirect(redirect_url) + return redirect(util.web_url_for('dashboard', _absolute=True, _angular_route=True)) else: - return redirect(redirect_url + '?goodbye=true') + return redirect(util.web_url_for('index', _absolute=True, _angular_route=True)) def make_url_map(app): """Set up all the routes for the OSF app. @@ -294,12 +293,8 @@ def make_url_map(app): process_rules(app, [ Rule('/', 'get', website_views.index, notemplate), - Rule( - '/dashboard/', - 'get', - website_views.dashboard, - notemplate - ), + Rule('/dashboard/', 'get', website_views.dashboard, notemplate), + Rule('/my-projects/', 'get', website_views.my_projects, notemplate), Rule( '/metadata//', @@ -307,12 +302,6 @@ def make_url_map(app): website_views.metadata_download, notemplate ), - Rule( - '/my-projects/', - 'get', - website_views.my_projects, - OsfWebRenderer('my_projects.mako', trust=False) - ), Rule( '/reproducibility/', diff --git a/website/settings/defaults.py b/website/settings/defaults.py index fbe9b939ae1..c2ad5bff701 100644 --- a/website/settings/defaults.py +++ b/website/settings/defaults.py @@ -84,12 +84,14 @@ def parent_dir(path): DEV_MODE = False DEBUG_MODE = False SECURE_MODE = not DEBUG_MODE # Set secure cookie +LOCAL_MODE = False # handles angular and web with different domains in local env PROTOCOL = 'https://' if SECURE_MODE else 'http://' DOMAIN = PROTOCOL + 'localhost:5000/' INTERNAL_DOMAIN = DOMAIN API_DOMAIN = PROTOCOL + 'localhost:8000/' RESET_PASSWORD_URL = PROTOCOL + 'localhost:5000/resetpassword/' # TODO set angular reset password url +LOCAL_ANGULAR_DOMAIN = PROTOCOL + 'localhost:4200/' # Only used when LOCAL_MODE is True PREPRINT_PROVIDER_DOMAINS = { 'enabled': False, diff --git a/website/settings/local-ci.py b/website/settings/local-ci.py index a4d250a9792..eec0e7070e2 100644 --- a/website/settings/local-ci.py +++ b/website/settings/local-ci.py @@ -12,10 +12,12 @@ DEV_MODE = True DEBUG_MODE = True # Sets app to debug mode, turns off template caching, etc. SECURE_MODE = not DEBUG_MODE # Disable osf secure cookie +LOCAL_MODE = True # handles angular and web with different domains in local env PROTOCOL = 'https://' if SECURE_MODE else 'http://' DOMAIN = PROTOCOL + 'localhost:5000/' API_DOMAIN = PROTOCOL + 'localhost:8000/' +LOCAL_ANGULAR_DOMAIN = PROTOCOL + 'localhost:4200/' # Only used when LOCAL_MODE is True ENABLE_INSTITUTIONS = True PREPRINT_PROVIDER_DOMAINS = { diff --git a/website/settings/local-dist.py b/website/settings/local-dist.py index 01d26b420c2..fbd7f4a451e 100644 --- a/website/settings/local-dist.py +++ b/website/settings/local-dist.py @@ -11,6 +11,7 @@ DEV_MODE = True DEBUG_MODE = True # Sets app to debug mode, turns off template caching, etc. SECURE_MODE = not DEBUG_MODE # Disable osf cookie secure +LOCAL_MODE = True # handles angular and web with different domains in local env # NOTE: Internal Domains/URLs have been added to facilitate docker development environments # when localhost inside a container != localhost on the client machine/docker host. @@ -19,7 +20,7 @@ DOMAIN = PROTOCOL + 'localhost:5000/' INTERNAL_DOMAIN = DOMAIN API_DOMAIN = PROTOCOL + 'localhost:8000/' -LOCAL_ANGULAR_DOMAIN = PROTOCOL + 'localhost:4200/' +LOCAL_ANGULAR_DOMAIN = PROTOCOL + 'localhost:4200/' # Only used when LOCAL_MODE is True #WATERBUTLER_URL = 'http://localhost:7777' #WATERBUTLER_INTERNAL_URL = WATERBUTLER_URL diff --git a/website/util/__init__.py b/website/util/__init__.py index f74182300cf..70ae6644e90 100644 --- a/website/util/__init__.py +++ b/website/util/__init__.py @@ -88,7 +88,7 @@ def api_v2_url(path_str, return x # Move to api utils? -def web_url_for(view_name, _absolute=False, _internal=False, _guid=False, *args, **kwargs): +def web_url_for(view_name, _absolute=False, _internal=False, _guid=False, _angular_route=False, *args, **kwargs): """Reverse URL lookup for web routes (those that use the OsfWebRenderer). Takes the same arguments as Flask's url_for, with the addition of `_absolute`, which will make an absolute URL with the correct HTTP scheme @@ -102,6 +102,11 @@ def web_url_for(view_name, _absolute=False, _internal=False, _guid=False, *args, # We do NOT use the url_for's _external kwarg because app.config['SERVER_NAME'] alters # behavior in an unknown way (currently breaks tests). /sloria /jspies domain = website_settings.INTERNAL_DOMAIN if _internal else website_settings.DOMAIN + if website_settings.LOCAL_MODE and _angular_route: + # We use `web_url_for()` to build URLs that actually goes to the angular + # container. It is not a problem for servers since web and angular shares + # the same domain. However, we need to handle this differently locally. + domain = website_settings.LOCAL_ANGULAR_DOMAIN return urljoin(domain, url) return url diff --git a/website/views.py b/website/views.py index 1a4bf4942da..f9601fb0441 100644 --- a/website/views.py +++ b/website/views.py @@ -8,8 +8,10 @@ from django.apps import apps from flask import request, Response +from framework import sentry from framework.auth import Auth -from framework.auth.decorators import must_be_logged_in, is_contributor_or_public_resource +from framework.auth.utils import get_default_osf_logout_url +from framework.auth.decorators import is_contributor_or_public_resource from framework.auth.forms import SignInForm, ForgotPasswordForm from framework.exceptions import HTTPError from framework.flask import redirect # VOL-aware redirect @@ -28,7 +30,6 @@ from osf.utils import permissions from osf.metadata.tools import pls_gather_metadata_file -from api.waffle.utils import storage_i18n_flag_active logger = logging.getLogger(__name__) @@ -132,21 +133,6 @@ def find_bookmark_collection(user): return Collection.objects.get(creator=user, deleted__isnull=True, is_bookmark_collection=True) -@must_be_logged_in -def my_projects(auth): - user = auth.user - - region_list = get_storage_region_list(user) - - bookmark_collection = find_bookmark_collection(user) - my_projects_id = bookmark_collection._id - return {'addons_enabled': user.get_addon_names(), - 'dashboard_id': my_projects_id, - 'storage_regions': region_list, - 'storage_flag_is_active': storage_i18n_flag_active(), - } - - def validate_page_num(page, pages): if page < 0 or (pages and page >= pages): raise HTTPError(http_status.HTTP_400_BAD_REQUEST, data=dict( @@ -165,11 +151,30 @@ def paginate(items, total, page, size): def index(): - return redirect('/my-projects/') + """This route is handled by Angular now and web flow should not reach it at all. + There is alo no direct call of this view other than via `website/routes`. However, + we kept this view to use `web_url_for()` to build correct URL to go to Angular. + """ + sentry.log_message('View "index" should not have been directly called or reached') + return redirect(get_default_osf_logout_url()) def dashboard(): - return redirect('/my-projects/') + """ This route is handled by Angular now and web flow should not reach it at all. + There is alo no direct call of this view other than via `website/routes`. However, + we kept this view to use `web_url_for()` to build correct URL to go to Angular. + """ + sentry.log_message('View "dashboard" should not have been directly called or reached') + return redirect(get_default_osf_logout_url()) + + +def my_projects(): + """ This route is handled by Angular now and web flow should not reach it at all. + There is alo no direct call of this view other than via `website/routes`. However, + we kept this view to use `web_url_for()` to build correct URL to go to Angular. + """ + sentry.log_message('View "my_projects" should not have been directly called or reached') + return redirect(get_default_osf_logout_url()) def reproducibility(): From a6d722c57c6b3aea66ee9ab90e3f73d75eb89f74 Mon Sep 17 00:00:00 2001 From: Longze Chen Date: Thu, 7 May 2026 16:06:50 -0400 Subject: [PATCH 34/34] Update changelog and bump version --- CHANGELOG | 5 +++++ package.json | 2 +- pyproject.toml | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 0a630b7871f..08306263ab1 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -2,6 +2,11 @@ We follow the CalVer (https://calver.org/) versioning scheme: YY.MINOR.MICRO. +26.9.0 (2026-05-07) +=================== + +- OSF4I In-progress SSO Project - BE Piece + 26.8.2 (2026-05-06) =================== diff --git a/package.json b/package.json index 9581c715d1a..48a65b623ec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "OSF", - "version": "26.8.2", + "version": "26.9.0", "description": "Facilitating Open Science", "repository": "https://github.com/CenterForOpenScience/osf.io", "author": "Center for Open Science", diff --git a/pyproject.toml b/pyproject.toml index 4a776719fac..556eaff7a36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "osf-io" -version = "26.7.0" +version = "26.9.0" description = "The code for [https://osf.io](https://osf.io)." authors = ["Your Name "] license = "Apache License 2.0"