From 8b91d23d1b18cb6b28431f8352a33523dedf0b10 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Thu, 23 Apr 2026 18:16:19 +0300 Subject: [PATCH 01/13] return 503 for API if maintanence_mode is set to ture --- api/base/middleware.py | 21 ++++++++++++++ api/base/settings/defaults.py | 1 + osf/migrations/0039_maintenancemode.py | 38 ++++++++++++++++++++++++++ osf/models/__init__.py | 2 +- osf/models/maintenance_state.py | 13 +++++++++ 5 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 osf/migrations/0039_maintenancemode.py diff --git a/api/base/middleware.py b/api/base/middleware.py index bec771aba61..ef7b899181b 100644 --- a/api/base/middleware.py +++ b/api/base/middleware.py @@ -5,6 +5,7 @@ from importlib import import_module from django.conf import settings +from django.http import JsonResponse from django.contrib.sessions.middleware import SessionMiddleware from django.utils.deprecation import MiddlewareMixin from sentry_sdk import init @@ -24,6 +25,7 @@ from .api_globals import api_globals from api.base import settings as api_settings from api.base.authentication.drf import drf_get_session_from_cookie +from osf.models import MaintenanceMode SessionStore = import_module(settings.SESSION_ENGINE).SessionStore @@ -132,3 +134,22 @@ def process_request(self, request): request.session = drf_get_session_from_cookie(cookie) else: request.session = SessionStore() + + +class MaintenanceModeMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if request.path.endswith(('/v2', '/v2/')): + return self.get_response(request) + if MaintenanceMode.is_under_maintenance(): + return JsonResponse( + { + 'meta': { + 'maintenance_mode': True, + 'status_page': 'status', + }, + }, status=503, + ) + return self.get_response(request) diff --git a/api/base/settings/defaults.py b/api/base/settings/defaults.py index 52d30b40f9a..1626ada872e 100644 --- a/api/base/settings/defaults.py +++ b/api/base/settings/defaults.py @@ -232,6 +232,7 @@ 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'api.base.middleware.UnsignCookieSessionMiddleware', + 'api.base.middleware.MaintenanceModeMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', diff --git a/osf/migrations/0039_maintenancemode.py b/osf/migrations/0039_maintenancemode.py new file mode 100644 index 00000000000..c29f466dfa4 --- /dev/null +++ b/osf/migrations/0039_maintenancemode.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2.26 on 2026-04-23 14:25 + +from django.db import migrations, models + + +def create_initial_record(apps, schema_editor): + MaintenanceMode = apps.get_model('osf', 'MaintenanceMode') + MaintenanceMode.objects.get_or_create( + pk=1, + defaults={'maintenance_mode': False} + ) + + +def reverse_initial_record(apps, schema_editor): + # the reverse 'reverse_initial_record' does nothing + # because the table will be removed + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0038_abstractnode_date_last_indexed_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='MaintenanceMode', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('maintenance_mode', models.BooleanField(default=False)), + ], + ), + migrations.RunPython( + create_initial_record, + reverse_code=reverse_initial_record + ), + ] diff --git a/osf/models/__init__.py b/osf/models/__init__.py index 7f334a357cc..918ca9aa009 100644 --- a/osf/models/__init__.py +++ b/osf/models/__init__.py @@ -52,7 +52,7 @@ from .institution_affiliation import InstitutionAffiliation from .institution_storage_region import InstitutionStorageRegion from .licenses import NodeLicense, NodeLicenseRecord -from .maintenance_state import MaintenanceState +from .maintenance_state import MaintenanceState, MaintenanceMode from .metadata import GuidMetadataRecord from .metaschema import ( FileMetadataSchema, diff --git a/osf/models/maintenance_state.py b/osf/models/maintenance_state.py index ce8a5ce1786..134979b5f86 100644 --- a/osf/models/maintenance_state.py +++ b/osf/models/maintenance_state.py @@ -14,3 +14,16 @@ class MaintenanceState(models.Model): start = NonNaiveDateTimeField() end = NonNaiveDateTimeField() message = models.TextField(blank=True) + + +class MaintenanceMode(models.Model): + maintenance_mode = models.BooleanField(default=False) + + def save(self, *args, **kwargs): + self.pk = 1 + super().save(*args, **kwargs) + + @classmethod + def is_under_maintenance(cls): + obj, _ = cls.objects.get_or_create(pk=1) + return obj.maintenance_mode From 6e3360e69c794bb0606337767383f651a47a0015 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Fri, 24 Apr 2026 16:01:33 +0300 Subject: [PATCH 02/13] set maintenance mode via admin --- admin/maintenance/views.py | 14 ++++++++------ admin/templates/maintenance/display.html | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/admin/maintenance/views.py b/admin/maintenance/views.py index 05e7a8372c9..7ca7a53cdef 100644 --- a/admin/maintenance/views.py +++ b/admin/maintenance/views.py @@ -1,7 +1,7 @@ import pytz import datetime -from osf.models import MaintenanceState +from osf.models import MaintenanceState, MaintenanceMode import website.maintenance as maintenance from admin.maintenance.forms import MaintenanceForm @@ -36,15 +36,17 @@ def get_context_data(self, **kwargs): maintenance = MaintenanceState.objects.first() kwargs['form'] = MaintenanceForm() kwargs['current_alert'] = model_to_dict(maintenance) if maintenance else None + kwargs['maintenance_mode'] = MaintenanceMode.is_under_maintenance() return super().get_context_data(**kwargs) def post(self, request, *args, **kwargs): data = request.POST - - start = convert_eastern_to_utc(data['start']).isoformat() if data.get('start') else None - end = convert_eastern_to_utc(data['end']).isoformat() if data.get('end') else None - - maintenance.set_maintenance(data.get('message', ''), data['level'], start, end) + if maintenance_mode := data.get('maintenance_mode'): + MaintenanceMode(maintenance_mode=False if maintenance_mode == 'True' else True).save() + else: + start = convert_eastern_to_utc(data['start']).isoformat() if data.get('start') else None + end = convert_eastern_to_utc(data['end']).isoformat() if data.get('end') else None + maintenance.set_maintenance(data.get('message', ''), data['level'], start, end) return redirect('maintenance:display') diff --git a/admin/templates/maintenance/display.html b/admin/templates/maintenance/display.html index b29028fcbba..6064cf6f131 100644 --- a/admin/templates/maintenance/display.html +++ b/admin/templates/maintenance/display.html @@ -66,6 +66,21 @@

Put up an alert:

+ + +
+
+
+ {% csrf_token %} + + {% if maintenance_mode %} + + {% else %} + + {% endif %} +
+
+
{% endif %} {% endblock content %} From 66558654ad1d75db5463ba8a1ac7f6099b24dd48 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Fri, 24 Apr 2026 20:51:10 +0300 Subject: [PATCH 03/13] resolve CR comments add unittests --- admin_tests/maintenance/test_views.py | 58 ++++++++++++++++++++++++++- api/base/middleware.py | 4 +- osf_tests/test_middleware.py | 28 +++++++++++++ 3 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 osf_tests/test_middleware.py diff --git a/admin_tests/maintenance/test_views.py b/admin_tests/maintenance/test_views.py index abeaa6af677..f42ac73c0a1 100644 --- a/admin_tests/maintenance/test_views.py +++ b/admin_tests/maintenance/test_views.py @@ -8,7 +8,7 @@ from django.core.exceptions import PermissionDenied import website.maintenance as maintenance -from osf.models import MaintenanceState +from osf.models import MaintenanceState, MaintenanceMode from osf_tests.factories import AuthUserFactory from admin_tests.utilities import setup_view @@ -105,3 +105,59 @@ def test_correct_view_permissions(self, req, user, plain_view): res = plain_view.as_view()(req) assert res.status_code == 200 + + +@pytest.mark.urls('admin.base.urls') +class TestMaintenanceMode: + + @pytest.fixture() + def user(self): + user = AuthUserFactory() + view_permission = Permission.objects.get(codename='change_maintenancestate') + user.user_permissions.add(view_permission) + user.save() + return user + + @pytest.fixture() + def plain_view(self): + return views.MaintenanceDisplay + + @pytest.fixture() + def view(self, user, plain_view): + req = RequestFactory().get('/fake_path') + req.user = user + view = plain_view() + setup_view(view, req) + return view + + def test_get_context_data_includes_maintenance_mode(self, view): + MaintenanceMode(maintenance_mode=True).save() + context = view.get_context_data() + assert context['maintenance_mode'] is True + MaintenanceMode(maintenance_mode=False).save() + context = view.get_context_data() + assert context['maintenance_mode'] is False + + def test_post_toggles_maintenance_mode_on(self, user, plain_view): + MaintenanceMode(maintenance_mode=False).save() + req = RequestFactory().post('/fake_path', data={'maintenance_mode': 'False'}) + req.user = user + view = plain_view() + setup_view(view, req) + response = view.post(req) + # It should redirect back to the display page + assert response.status_code == 302 + # The database state should now be True + assert MaintenanceMode.is_under_maintenance() is True + + def test_post_toggles_maintenance_mode_off(self, user, plain_view): + MaintenanceMode(maintenance_mode=True).save() + req = RequestFactory().post('/fake_path', data={'maintenance_mode': 'True'}) + req.user = user + view = plain_view() + setup_view(view, req) + response = view.post(req) + # It should redirect back to the display page + assert response.status_code == 302 + # The database state should now be False + assert MaintenanceMode.is_under_maintenance() is False diff --git a/api/base/middleware.py b/api/base/middleware.py index ef7b899181b..b8202105954 100644 --- a/api/base/middleware.py +++ b/api/base/middleware.py @@ -141,14 +141,14 @@ def __init__(self, get_response): self.get_response = get_response def __call__(self, request): - if request.path.endswith(('/v2', '/v2/')): + if request.path.endswith('/v2'): return self.get_response(request) if MaintenanceMode.is_under_maintenance(): return JsonResponse( { 'meta': { 'maintenance_mode': True, - 'status_page': 'status', + 'status_page': 'https://status.cos.io', }, }, status=503, ) diff --git a/osf_tests/test_middleware.py b/osf_tests/test_middleware.py new file mode 100644 index 00000000000..1f9f4c0575d --- /dev/null +++ b/osf_tests/test_middleware.py @@ -0,0 +1,28 @@ +import json +import pytest +from unittest import mock + +class TestMaintenanceModeMiddlewareIntegration: + + MAINTENANCE_MOCK_PATH = 'api.base.middleware.MaintenanceMode.is_under_maintenance' + + @mock.patch(MAINTENANCE_MOCK_PATH, return_value=True) + def test_bypasses_v2_root_if_maintenance_mode_on(self, mock_maintenance, client): + response = client.get('/v2') + assert response.status_code != 503 + mock_maintenance.assert_not_called() + + @pytest.mark.parametrize('method', ['post', 'patch', 'put', 'delete']) + @mock.patch(MAINTENANCE_MOCK_PATH, return_value=True) + def test_middleware_blocks_different_requests_if_maintenance_mode_on(self, mock_maintenance, client, method): + client_method = getattr(client, method) + response = client_method('/v2/nodes/', data={}, content_type='application/json') + assert response.status_code == 503 + data = json.loads(response.content) + assert data['meta']['maintenance_mode'] is True + assert data['meta']['status_page'] == 'https://status.cos.io' + + @mock.patch(MAINTENANCE_MOCK_PATH, return_value=False) + def test_passes_through_when_maintenance_mode_off(self, mock_maintenance, client): + response = client.get('/v2/nodes/') + assert response.status_code != 503 From d49a26e697e1647ca516e438747f9c44a8dd07ef Mon Sep 17 00:00:00 2001 From: mkovalua Date: Mon, 27 Apr 2026 15:19:20 +0300 Subject: [PATCH 04/13] resolve CR comments update unittests --- api/base/middleware.py | 2 - osf_tests/test_middleware.py | 98 ++++++++++++++++++++++++++++++------ 2 files changed, 82 insertions(+), 18 deletions(-) diff --git a/api/base/middleware.py b/api/base/middleware.py index b8202105954..239d64194cb 100644 --- a/api/base/middleware.py +++ b/api/base/middleware.py @@ -141,8 +141,6 @@ def __init__(self, get_response): self.get_response = get_response def __call__(self, request): - if request.path.endswith('/v2'): - return self.get_response(request) if MaintenanceMode.is_under_maintenance(): return JsonResponse( { diff --git a/osf_tests/test_middleware.py b/osf_tests/test_middleware.py index 1f9f4c0575d..2dde726c178 100644 --- a/osf_tests/test_middleware.py +++ b/osf_tests/test_middleware.py @@ -1,28 +1,94 @@ -import json import pytest from unittest import mock +from tests.base import ApiTestCase +from osf.utils import permissions +from osf_tests.factories import ( + AuthUserFactory, + CollectionProviderFactory, + ProjectFactory, +) -class TestMaintenanceModeMiddlewareIntegration: +@pytest.fixture() +def provider(): + provider = CollectionProviderFactory() + provider.update_group_permissions() + return provider + + +@pytest.fixture() +def admin(provider): + user = AuthUserFactory() + provider.get_group(permissions.ADMIN).user_set.add(user) + return user + + +@pytest.fixture() +def node(admin): + return ProjectFactory(creator=admin) + + +class TestMaintenanceModeMiddlewareIntegration(ApiTestCase): MAINTENANCE_MOCK_PATH = 'api.base.middleware.MaintenanceMode.is_under_maintenance' + def setUp(self): + super().setUp() + self.provider = CollectionProviderFactory() + self.provider.update_group_permissions() + self.admin = AuthUserFactory() + self.provider.get_group(permissions.ADMIN).user_set.add(self.admin) + self.node = ProjectFactory(creator=self.admin) + + @mock.patch(MAINTENANCE_MOCK_PATH, return_value=True) + def test_middleware_blocks_post_when_maintenance_mode_on(self, mock_maintenance): + url = f'/v2/nodes/{self.node._id}/' + response = self.app.post_json(url, {}, expect_errors=True) + assert response.status_code == 503 + assert response.json['meta']['maintenance_mode'] is True + assert response.json['meta']['status_page'] == 'https://status.cos.io' + @mock.patch(MAINTENANCE_MOCK_PATH, return_value=True) - def test_bypasses_v2_root_if_maintenance_mode_on(self, mock_maintenance, client): - response = client.get('/v2') - assert response.status_code != 503 - mock_maintenance.assert_not_called() + def test_middleware_blocks_patch_when_maintenance_mode_on(self, mock_maintenance): + url = f'/v2/nodes/{self.node._id}/' + response = self.app.patch_json(url, {}, expect_errors=True) + assert response.status_code == 503 + assert response.json['meta']['maintenance_mode'] is True - @pytest.mark.parametrize('method', ['post', 'patch', 'put', 'delete']) @mock.patch(MAINTENANCE_MOCK_PATH, return_value=True) - def test_middleware_blocks_different_requests_if_maintenance_mode_on(self, mock_maintenance, client, method): - client_method = getattr(client, method) - response = client_method('/v2/nodes/', data={}, content_type='application/json') + def test_middleware_blocks_delete_when_maintenance_mode_on(self, mock_maintenance): + url = f'/v2/nodes/{self.node._id}/' + response = self.app.delete(url, expect_errors=True) + assert response.status_code == 503 - data = json.loads(response.content) - assert data['meta']['maintenance_mode'] is True - assert data['meta']['status_page'] == 'https://status.cos.io' + assert response.json['meta']['maintenance_mode'] is True + + @mock.patch(MAINTENANCE_MOCK_PATH, return_value=False) + def test_go_to_post_view_when_maintenance_mode_off(self, mock_maintenance): + url = '/v2/nodes/' + payload = { + 'data': { + 'type': 'nodes', + 'attributes': {'title': 'New Node', 'category': 'project'} + } + } + response = self.app.post_json(url, payload, auth=self.admin.auth) + assert response.status_code == 201 + + @mock.patch(MAINTENANCE_MOCK_PATH, return_value=False) + def test_go_to_post_view_if_maintenance_mode_off(self, mock_maintenance): + url = f'/v2/nodes/{self.node._id}/' + payload = { + 'data': { + 'id': self.node._id, + 'type': 'nodes', + 'attributes': {'title': 'Updated Title'} + } + } + response = self.app.patch_json(url, payload, auth=self.admin.auth) + assert response.status_code == 200 @mock.patch(MAINTENANCE_MOCK_PATH, return_value=False) - def test_passes_through_when_maintenance_mode_off(self, mock_maintenance, client): - response = client.get('/v2/nodes/') - assert response.status_code != 503 + def test_go_to_delete_view_if_maintenance_mode_off(self, mock_maintenance): + url = f'/v2/nodes/{self.node._id}/' + response = self.app.delete(url, auth=self.admin.auth) + assert response.status_code == 204 From 86784a3314871101b3ea0f590d7127bcd7836795 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Mon, 27 Apr 2026 16:21:10 +0300 Subject: [PATCH 05/13] update tests --- osf_tests/test_middleware.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/osf_tests/test_middleware.py b/osf_tests/test_middleware.py index 2dde726c178..32c16cabd41 100644 --- a/osf_tests/test_middleware.py +++ b/osf_tests/test_middleware.py @@ -40,7 +40,7 @@ def setUp(self): self.node = ProjectFactory(creator=self.admin) @mock.patch(MAINTENANCE_MOCK_PATH, return_value=True) - def test_middleware_blocks_post_when_maintenance_mode_on(self, mock_maintenance): + def test_middleware_blocks_post_if_maintenance_mode_on(self, mock_maintenance): url = f'/v2/nodes/{self.node._id}/' response = self.app.post_json(url, {}, expect_errors=True) assert response.status_code == 503 @@ -48,22 +48,33 @@ def test_middleware_blocks_post_when_maintenance_mode_on(self, mock_maintenance) assert response.json['meta']['status_page'] == 'https://status.cos.io' @mock.patch(MAINTENANCE_MOCK_PATH, return_value=True) - def test_middleware_blocks_patch_when_maintenance_mode_on(self, mock_maintenance): + def test_middleware_blocks_patch_if_maintenance_mode_on(self, mock_maintenance): url = f'/v2/nodes/{self.node._id}/' - response = self.app.patch_json(url, {}, expect_errors=True) + original_title = self.node.title + payload = { + 'data': { + 'id': self.node._id, + 'type': 'nodes', + 'attributes': {'title': 'Updated Title'} + } + } + response = self.app.patch_json(url, payload, expect_errors=True) assert response.status_code == 503 assert response.json['meta']['maintenance_mode'] is True + self.node.reload() + assert self.node.title == original_title @mock.patch(MAINTENANCE_MOCK_PATH, return_value=True) - def test_middleware_blocks_delete_when_maintenance_mode_on(self, mock_maintenance): + def test_middleware_blocks_delete_if_maintenance_mode_on(self, mock_maintenance): url = f'/v2/nodes/{self.node._id}/' response = self.app.delete(url, expect_errors=True) - assert response.status_code == 503 assert response.json['meta']['maintenance_mode'] is True + self.node.reload() + assert self.node.is_deleted is False @mock.patch(MAINTENANCE_MOCK_PATH, return_value=False) - def test_go_to_post_view_when_maintenance_mode_off(self, mock_maintenance): + def test_go_to_post_view_if_maintenance_mode_off(self, mock_maintenance): url = '/v2/nodes/' payload = { 'data': { @@ -75,7 +86,7 @@ def test_go_to_post_view_when_maintenance_mode_off(self, mock_maintenance): assert response.status_code == 201 @mock.patch(MAINTENANCE_MOCK_PATH, return_value=False) - def test_go_to_post_view_if_maintenance_mode_off(self, mock_maintenance): + def test_go_to_patch_view_if_maintenance_mode_off(self, mock_maintenance): url = f'/v2/nodes/{self.node._id}/' payload = { 'data': { From e3ab66a4bbb3f579d06d77bce0afa97fe580e7c9 Mon Sep 17 00:00:00 2001 From: Ostap-Zherebetskyi Date: Fri, 1 May 2026 18:40:20 +0300 Subject: [PATCH 06/13] [ENG-10774] Provide extra options to populate newly created templates and selective existing templates (#11698) * Add options to sync notification templates: restore one or all * Fix formatting of template name input in management commands --- admin/management/views.py | 12 ++- admin/templates/management/commands.html | 10 ++ .../commands/populate_notification_types.py | 94 ++++++++++++------- 3 files changed, 80 insertions(+), 36 deletions(-) diff --git a/admin/management/views.py b/admin/management/views.py index f2052822f37..4ec96a4ea3a 100644 --- a/admin/management/views.py +++ b/admin/management/views.py @@ -179,7 +179,17 @@ def post(self, request): class SyncNotificationTemplates(ManagementCommandPermissionView): def post(self, request): - populate_notification_types() + run_type = request.POST.get('run_type') + if run_type == 'restore_one': + template_name = request.POST.get('template_name') + if not template_name: + messages.error(request, 'A template name must be specified when restoring one template. Check your inputs and try again') + return redirect(reverse('management:commands')) + populate_notification_types(restore_one=template_name) + elif run_type == 'restore_all': + populate_notification_types(restore_all=True) + else: + populate_notification_types() messages.success(request, 'Notification templates have been successfully synced.') return redirect(reverse('management:commands')) diff --git a/admin/templates/management/commands.html b/admin/templates/management/commands.html index edf242abfdd..87ff147f919 100644 --- a/admin/templates/management/commands.html +++ b/admin/templates/management/commands.html @@ -160,6 +160,16 @@

Sync Notification Templates

{% csrf_token %} + + +
+ +
+ diff --git a/osf/management/commands/populate_notification_types.py b/osf/management/commands/populate_notification_types.py index 302c1069a17..5f0145f08a9 100644 --- a/osf/management/commands/populate_notification_types.py +++ b/osf/management/commands/populate_notification_types.py @@ -1,6 +1,5 @@ import sys import yaml -from django.apps import apps from waffle import switch_is_active from osf import features @@ -19,7 +18,7 @@ 'email_transactional': 'instantly', } -def populate_notification_types(*args, **kwargs): +def populate_notification_types(*args, restore_one=None, restore_all=False, **kwargs): if kwargs.get('sender'): # exists when called as a post_migrate signal if not switch_is_active(features.POPULATE_NOTIFICATION_TYPES): if 'pytest' not in sys.modules: @@ -28,64 +27,89 @@ def populate_notification_types(*args, **kwargs): logger.info('Populating notification types...') from django.contrib.contenttypes.models import ContentType from osf.models.notification_type import NotificationType + try: with open(settings.NOTIFICATION_TYPES_YAML) as stream: notification_types = yaml.safe_load(stream) - for notification_type in notification_types['notification_types']: - notification_type.pop('__docs__', None) - notification_type.pop('tests', None) - object_content_type_model_name = notification_type.pop('object_content_type_model_name') + + notification_types_dict = { + nt['name']: nt for nt in notification_types['notification_types'] + } + + all_names = set(notification_types_dict.keys()) + existing_names = set( + NotificationType.objects.values_list('name', flat=True) + ) + + if restore_one: + if restore_one not in notification_types_dict: + raise ValueError(f'Notification type "{restore_one}" not found in YAML') + names_to_process = {restore_one} + + elif restore_all: + names_to_process = all_names + + else: + names_to_process = all_names - existing_names + + logger.info(f'Processing {len(names_to_process)} notification types') + + for name in names_to_process: + raw_nt = notification_types_dict[name].copy() + + raw_nt.pop('__docs__', None) + raw_nt.pop('tests', None) + + object_content_type_model_name = raw_nt.pop('object_content_type_model_name') if object_content_type_model_name == 'desk': content_type = None - elif object_content_type_model_name == 'osfuser': - OSFUser = apps.get_model('osf', 'OSFUser') - content_type = ContentType.objects.get_for_model(OSFUser) - elif object_content_type_model_name == 'preprint': - Preprint = apps.get_model('osf', 'Preprint') - content_type = ContentType.objects.get_for_model(Preprint) - elif object_content_type_model_name == 'collectionsubmission': - CollectionSubmission = apps.get_model('osf', 'CollectionSubmission') - content_type = ContentType.objects.get_for_model(CollectionSubmission) - elif object_content_type_model_name == 'abstractprovider': - AbstractProvider = apps.get_model('osf', 'abstractprovider') - content_type = ContentType.objects.get_for_model(AbstractProvider) - elif object_content_type_model_name == 'osfuser': - OSFUser = apps.get_model('osf', 'OSFUser') - content_type = ContentType.objects.get_for_model(OSFUser) - elif object_content_type_model_name == 'draftregistration': - DraftRegistration = apps.get_model('osf', 'DraftRegistration') - content_type = ContentType.objects.get_for_model(DraftRegistration) else: try: - content_type = ContentType.objects.get( - app_label='osf', - model=object_content_type_model_name - ) + content_type = ContentType.objects.get_by_natural_key(app_label='osf', model=object_content_type_model_name) except ContentType.DoesNotExist: raise ValueError(f'No content type for osf.{object_content_type_model_name}') - template_path = notification_type.pop('template') + template_path = raw_nt.pop('template') + template = None + if template_path: with open(template_path) as stream: template = stream.read() nt, _ = NotificationType.objects.update_or_create( - name=notification_type['name'], - defaults=notification_type, + name=name, + defaults=raw_nt, ) + nt.object_content_type = content_type - if not nt.template or settings.DEV_MODE: + if template: nt.template = template + nt.save() + except ProgrammingError: logger.info('Notification types failed potential side effect of reverse migration') logger.info('Finished populating notification types.') - class Command(BaseCommand): - help = 'Population notification types.' + help = 'Populate notification types.' + + def add_arguments(self, parser): + parser.add_argument( + '--restore-all', + action='store_true', + help='Restore all templates from files' + ) + parser.add_argument( + '--restore', + type=str, + help='Restore specific template by name' + ) def handle(self, *args, **options): with transaction.atomic(): - populate_notification_types(args, options) + populate_notification_types( + restore_all=options['restore_all'], + restore_one=options['restore'] + ) From b985424ceb3a31bf789eb9b77f087acd0fabb586 Mon Sep 17 00:00:00 2001 From: Ostap-Zherebetskyi Date: Fri, 1 May 2026 18:41:37 +0300 Subject: [PATCH 07/13] [ENG-10775] Move notification management from admin/admin to badmin and improve template preview (#11715) * Refactor notifications admin interface: add views, templates, and URLs for notifications management * Refactor notification rendering: enhance error handling and add mock data support in preview * Add JSON parsing error --- admin/base/urls.py | 1 + admin/notifications/forms.py | 8 + admin/notifications/urls.py | 14 + admin/notifications/views.py | 327 +++++++++++++++++- admin/templates/base.html | 22 ++ .../notifications/email_tasks_list.html | 34 ++ .../notification_subscriptions_list.html | 37 ++ .../notification_type_detail.html | 109 ++++++ .../notification_type_preview.html | 30 ++ .../notification_types_list.html | 37 ++ .../notifications/notifications_list.html | 37 ++ osf/email/__init__.py | 6 +- osf/models/notification_type.py | 1 + 13 files changed, 660 insertions(+), 3 deletions(-) create mode 100644 admin/notifications/forms.py create mode 100644 admin/notifications/urls.py create mode 100644 admin/templates/notifications/email_tasks_list.html create mode 100644 admin/templates/notifications/notification_subscriptions_list.html create mode 100644 admin/templates/notifications/notification_type_detail.html create mode 100644 admin/templates/notifications/notification_type_preview.html create mode 100644 admin/templates/notifications/notification_types_list.html create mode 100644 admin/templates/notifications/notifications_list.html diff --git a/admin/base/urls.py b/admin/base/urls.py index 9ff5e03a03e..1eb840bc362 100644 --- a/admin/base/urls.py +++ b/admin/base/urls.py @@ -38,6 +38,7 @@ 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')), + re_path(r'^notifications/', include('admin.notifications.urls', namespace='notifications')), ]), ), ] diff --git a/admin/notifications/forms.py b/admin/notifications/forms.py new file mode 100644 index 00000000000..946754415bb --- /dev/null +++ b/admin/notifications/forms.py @@ -0,0 +1,8 @@ +from django import forms +from osf.models import NotificationType + + +class NotificationTypeForm(forms.ModelForm): + class Meta: + model = NotificationType + fields = '__all__' diff --git a/admin/notifications/urls.py b/admin/notifications/urls.py new file mode 100644 index 00000000000..236059a577e --- /dev/null +++ b/admin/notifications/urls.py @@ -0,0 +1,14 @@ +from django.urls import re_path +from . import views + +app_name = 'admin' + +urlpatterns = [ + re_path(r'$', views.NotificationsList.as_view(), name='list'), + re_path(r'types/$', views.NotificationTypeList.as_view(), name='types_list'), + re_path(r'type_display/(?P\d+)/$', views.NotificationTypeDisplay.as_view(), name='type_display'), + re_path(r'type_detail/(?P\d+)/$', views.NotificationTypeDetail.as_view(), name='type_detail'), + re_path(r'types_preview/(?P\d+)/$', views.NotificationTypePreview.as_view(), name='types_preview'), + re_path(r'subscriptions/$', views.NotificationSubscriptionsList.as_view(), name='subscriptions_list'), + re_path(r'email_tasks/$', views.EmailTasksList.as_view(), name='email_tasks_list'), +] diff --git a/admin/notifications/views.py b/admin/notifications/views.py index 6719ac90a8a..e1c55c05f47 100644 --- a/admin/notifications/views.py +++ b/admin/notifications/views.py @@ -1,4 +1,329 @@ -from osf.models.notification_subscription import NotificationSubscription +from django.urls import reverse_lazy +from django.db.models import Q +from osf.models import NotificationSubscription, NotificationType, Notification, EmailTask +from django.views.generic import ListView, DetailView, UpdateView +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.forms.models import model_to_dict +from .forms import NotificationTypeForm +from osf.email import _render_email_html +import json +from collections import defaultdict +from mako.lexer import Lexer +from mako.parsetree import ControlLine +import re def delete_selected_notifications(selected_ids): NotificationSubscription.objects.filter(id__in=selected_ids).delete() + +TEMPLATE_IDENTIFIER_BLACKLIST = { + 'if', 'else', 'and', 'or', 'not', 'in', + 'True', 'False', 'len', 'str', 'int', 'float', 'list', 'dict', 'set', 'tuple', +} + +def resolve_identifiers(identifier_structure): + structure = defaultdict(dict) + if hasattr(identifier_structure, 'nodes') and identifier_structure.nodes: + for node in identifier_structure.nodes: + if isinstance(node, ControlLine) and node.keyword == 'for': + match = re.match(r'for (\w+) in (.+):', node.text) + if match: + iterator, source = match.groups() + structure[node.text] = { + 'type': 'loop', + 'iterator': iterator, + 'source': source, + 'children': resolve_identifiers(node) + } + elif hasattr(node, 'text'): + field_match = re.match(r"(\w+)\['(.+)'\]", node.text) + if field_match: + source, field = field_match.groups() + structure[node.text] = { + 'type': 'field', + 'source': source, + 'field': field + } + return structure + +def generate_mock_json(structure, list_name=None): + item = {} + result = {} + for key, value in structure.items(): + # simple field + if isinstance(value, dict) and value.get('type') == 'field': + field_name = value['field'] + item[field_name] = f"mock_{field_name}" + + # nested loop + elif isinstance(value, dict) and value.get('type') == 'loop': + nested_source = value['source'] + nested_match = re.match(r"\w+\['(.+)'\]", nested_source) + if nested_match: + nested_field = nested_match.group(1) + item[nested_field] = [1, 2, 3, 4] + + # top-level loop wrapper + elif key.startswith('for '): + match = re.match(r'for (\w+) in (.+):', key) + if match: + _, source = match.groups() + # Extract final field name + field_match = re.search(r"(\w+)\['(.+?)'\]$", source) + if field_match: + field_name = field_match.group(1) + list_name = field_match.group(2) + return {field_name: generate_mock_json(value, list_name)} + else: + list_name = source + return generate_mock_json(value, list_name) + if list_name: + result[list_name] = [item, item, item] + + return result + + +def build_safe_context(template: str) -> dict: + templatenode = Lexer(text=template).parse() + identifiers_location = [] + for node in templatenode.get_children(): + if hasattr(node, 'nodes'): + identifiers_location.extend(node.nodes) + + if not identifiers_location: + identifiers_location = templatenode.get_children() + identifier_structure = defaultdict() + for control_structure in identifiers_location: + if isinstance(control_structure, ControlLine): + identifier_structure[control_structure.text] = resolve_identifiers(control_structure) + + identifiers = [x.undeclared_identifiers() for x in identifiers_location if hasattr(x, 'undeclared_identifiers')] + flatten_identifiers = set() + for indentifier_set in identifiers: + flatten_identifiers.update(indentifier_set) + mock_json = generate_mock_json(identifier_structure) + context = {identifier: f'mock_{identifier}' for identifier in flatten_identifiers if identifier not in TEMPLATE_IDENTIFIER_BLACKLIST} + context.update(mock_json) + return context + +class NotificationsList(PermissionRequiredMixin, ListView): + paginate_by = 25 + template_name = 'notifications/notifications_list.html' + ordering = 'id' + permission_required = 'osf.view_notification' + raise_exception = True + model = Notification + + def get_queryset(self): + qs = Notification.objects.all().order_by(self.ordering) + q = self.request.GET.get('q') + if q: + qs = qs.filter( + Q(subscription__notification_type__name__icontains=q) | + Q(subscription__user__username__icontains=q) | + Q(subscription__message_frequency__icontains=q) + ) + return qs + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + q = self.request.GET.get('q', '') + context['q'] = q + # append search param to pagination links + if q: + context['extra_query_params'] = f"&q={q}" + else: + context['extra_query_params'] = '' + + context['notifications'] = context['object_list'] + context['page'] = context['page_obj'] + return context + +class NotificationSubscriptionsList(PermissionRequiredMixin, ListView): + paginate_by = 25 + template_name = 'notifications/notification_subscriptions_list.html' + ordering = 'id' + permission_required = 'osf.view_notificationsubscription' + raise_exception = True + model = NotificationSubscription + + def get_queryset(self): + qs = NotificationSubscription.objects.all().order_by(self.ordering) + q = self.request.GET.get('q') + if q: + qs = qs.filter( + Q(notification_type__name__icontains=q) | + Q(user__username__icontains=q) | + Q(message_frequency__icontains=q) + ) + return qs + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + q = self.request.GET.get('q', '') + context['q'] = q + # append search param to pagination links + if q: + context['extra_query_params'] = f"&q={q}" + else: + context['extra_query_params'] = '' + context['subscriptions'] = context['object_list'] + context['page'] = context['page_obj'] + return context + +class EmailTasksList(PermissionRequiredMixin, ListView): + paginate_by = 25 + template_name = 'notifications/email_tasks_list.html' + ordering = 'task_id' + permission_required = 'osf.view_emailtask' + raise_exception = True + model = EmailTask + + def get_queryset(self): + qs = EmailTask.objects.all().order_by(self.ordering) + q = self.request.GET.get('q') + if q: + qs = qs.filter( + Q(task_id=q) | + Q(user__username__icontains=q) | + Q(status=q) + ) + return qs + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + q = self.request.GET.get('q', '') + context['q'] = q + # append search param to pagination links + if q: + context['extra_query_params'] = f"&q={q}" + else: + context['extra_query_params'] = '' + context['email_tasks'] = context['object_list'] + context['page'] = context['page_obj'] + return context + +class NotificationTypeList(PermissionRequiredMixin, ListView): + paginate_by = 25 + template_name = 'notifications/notification_types_list.html' + ordering = 'name' + permission_required = 'osf.view_notificationtype' + raise_exception = True + model = NotificationType + + def get_queryset(self): + qs = NotificationType.objects.all().order_by(self.ordering) + q = self.request.GET.get('q') + if q: + qs = qs.filter( + Q(name__icontains=q) | + Q(subject__icontains=q) | + Q(notification_interval_choices__icontains=q) + ) + return qs + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + q = self.request.GET.get('q', '') + context['q'] = q + # append search param to pagination links + if q: + context['extra_query_params'] = f"&q={q}" + else: + context['extra_query_params'] = '' + + context['notification_types'] = context['object_list'] + context['page'] = context['page_obj'] + return context + +class NotificationTypeDisplay(PermissionRequiredMixin, DetailView): + model = NotificationType + template_name = 'notifications/notification_type_detail.html' + permission_required = 'osf.view_notificationtype' + raise_exception = True + + def get_object(self, queryset=None): + return NotificationType.objects.get(id=self.kwargs.get('pk')) + + def get_context_data(self, *args, **kwargs): + notification_type = self.get_object() + notification_type_dict = model_to_dict(notification_type) + fields = notification_type_dict.copy() + kwargs.setdefault('page_number', self.request.GET.get('page', '1')) + notification_type_dict['is_digest_type'] = notification_type.is_digest_type + kwargs['notification_type'] = notification_type_dict + kwargs['template'] = notification_type_dict.pop('template', None) + kwargs['change_form'] = NotificationTypeForm(initial=fields) + + return kwargs + +class NotificationTypePreview(PermissionRequiredMixin, DetailView): + model = NotificationType + template_name = 'notifications/notification_type_preview.html' + permission_required = 'osf.view_notificationtype' + raise_exception = True + + def get_object(self, queryset=None): + return NotificationType.objects.get(id=self.kwargs.get('pk')) + + def get_context_data(self, *args, **kwargs): + notification_type = self.get_object() + raw_context = self.request.GET.get('context') + if raw_context: + try: + if notification_type.is_digest_type: + safe_context = {'notifications': [json.loads(raw_context)]} + else: + safe_context = json.loads(raw_context) + + return_context = json.loads(raw_context) + except json.JSONDecodeError as e: + kwargs['rendered_template'] = f"Error parsing JSON: {str(e)}" + kwargs['context'] = raw_context + return kwargs + else: + if notification_type.is_digest_type: + inner_context = build_safe_context(notification_type.template) + inner_template = _render_email_html(notification_type, ctx=inner_context, return_original_error=True) + safe_context = {'notifications': [inner_template]} + return_context = inner_context + else: + safe_context = build_safe_context(notification_type.template) + return_context = safe_context + + if notification_type.is_digest_type: + # Use user_digest template as a wrapper for digest notification preview. + template_obj = NotificationType.objects.get(name='user_digest') + else: + template_obj = notification_type + try: + kwargs['rendered_template'] = _render_email_html(template_obj, ctx=safe_context, return_original_error=True) + except Exception as e: + kwargs['rendered_template'] = f"Error rendering template: {str(e)}" + + kwargs['context'] = json.dumps(return_context, indent=4) + + return kwargs + +class NotificationTypeDetail(PermissionRequiredMixin, DetailView): + model = NotificationType + template_name = 'notifications/notification_type_detail.html' + permission_required = 'osf.view_notificationtype' + raise_exception = True + + def get(self, request, *args, **kwargs): + view = NotificationTypeDetail.as_view() + return view(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + view = NotificationTypeChangeForm.as_view() + return view(request, *args, **kwargs) + +class NotificationTypeChangeForm(PermissionRequiredMixin, UpdateView): + template_name = 'institutions/detail.html' + permission_required = 'osf.change_notificationtype' + raise_exception = True + model = NotificationType + form_class = NotificationTypeForm + + def get_success_url(self, *args, **kwargs): + return reverse_lazy('notifications:type_display', kwargs={'pk': self.kwargs.get('pk')}) diff --git a/admin/templates/base.html b/admin/templates/base.html index 31a89b74037..5f645ffe267 100644 --- a/admin/templates/base.html +++ b/admin/templates/base.html @@ -289,6 +289,28 @@ {% endif %} {% endif %} + {% if perms.osf.view_notification or perms.osf.view_notificationtype or perms.osf.view_notificationsubscription %} +
  • + Notifications +
  • +
    + +
    + {% endif %} {% if perms.osf.view_metrics %}
  • Metrics
  • {% endif %} diff --git a/admin/templates/notifications/email_tasks_list.html b/admin/templates/notifications/email_tasks_list.html new file mode 100644 index 00000000000..cf4345f8907 --- /dev/null +++ b/admin/templates/notifications/email_tasks_list.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% load render_bundle from webpack_loader %} +{% load static %} +{% block title %} + List of Email Tasks +{% endblock title %} +{% block content %} +

    List of Email Tasks

    + + {% include "util/pagination.html" with items=page status=status %} + + + + + + + + + + + + + + {% for email_task in email_tasks %} + + + + + + {% endfor %} + +
    Task IDUserStatus
    {{ email_task.task_id }}{{ email_task.user }}{{ email_task.status }}
    + +{% endblock content %} diff --git a/admin/templates/notifications/notification_subscriptions_list.html b/admin/templates/notifications/notification_subscriptions_list.html new file mode 100644 index 00000000000..7ed0febd384 --- /dev/null +++ b/admin/templates/notifications/notification_subscriptions_list.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% load render_bundle from webpack_loader %} +{% load static %} +{% block title %} + List of Notification Subscriptions +{% endblock title %} +{% block content %} +

    List of Notification Subscriptions

    + + {% include "util/pagination.html" with items=page status=status %} +
    + + +
    + + + + + + + + + + + {% for subscription in subscriptions %} + + + + + + + + {% endfor %} + +
    Notification Type NameUserMessage FrequencySubscribed Object
    {{ subscription.notification_type.name }}{{ subscription.user }}{{ subscription.message_frequency }}{{ subscription.subscribed_object }}
    + +{% endblock content %} diff --git a/admin/templates/notifications/notification_type_detail.html b/admin/templates/notifications/notification_type_detail.html new file mode 100644 index 00000000000..1a571099762 --- /dev/null +++ b/admin/templates/notifications/notification_type_detail.html @@ -0,0 +1,109 @@ +{% extends "base.html" %} +{% load static %} +{% load render_bundle from webpack_loader %} +{% block title %} + Notification Type +{% endblock title %} +{% block content %} +
    +
    +
    + +
    +
    +
    +
    +

    {{ notification_type.name }}

    +
    +
    +
    +
    + +
    +
    +
    +
    + + {% for field, value in notification_type.items %} + + + + + {% endfor %} + + + + + +
    {{ field }}{{ value | safe }}
    template
    {{ template }}
    +
    + +
    +
    +
    +
    +
    + +{% endblock content %} + +{% block bottom_js %} + +{% endblock %} diff --git a/admin/templates/notifications/notification_type_preview.html b/admin/templates/notifications/notification_type_preview.html new file mode 100644 index 00000000000..e9aefbe3284 --- /dev/null +++ b/admin/templates/notifications/notification_type_preview.html @@ -0,0 +1,30 @@ +

    Notification Template Preview

    +

    Rendered Template

    + + + +
    + {{ rendered_template|safe }} +
    +

    Mock Data

    +
    + Edit the mock data and click "Render Preview" to see how changes affect the rendered template.
    + Note: The mock data should be a JSON object matching the expected context for the template. Please ensure that the JSON is properly formatted if error occurs during rendering. +
    +
    + +
    diff --git a/admin/templates/notifications/notification_types_list.html b/admin/templates/notifications/notification_types_list.html new file mode 100644 index 00000000000..655e8ec1ab2 --- /dev/null +++ b/admin/templates/notifications/notification_types_list.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% load render_bundle from webpack_loader %} +{% load static %} +{% block title %} + List of Notification Types +{% endblock title %} +{% block content %} +

    List of Notification Types

    + + {% include "util/pagination.html" with items=page status=status %} +
    + + +
    + + + + + + + + + + + {% for notification_type in notification_types %} + + + + + + + + {% endfor %} + +
    NameSubjectInterval ChoicesIs Digest
    {{ notification_type.name }}{{ notification_type.subject }}{{ notification_type.notification_interval_choices }}{{ notification_type.is_digest_type }}
    + +{% endblock content %} diff --git a/admin/templates/notifications/notifications_list.html b/admin/templates/notifications/notifications_list.html new file mode 100644 index 00000000000..76e747ca5e1 --- /dev/null +++ b/admin/templates/notifications/notifications_list.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% load render_bundle from webpack_loader %} +{% load static %} +{% block title %} + List of Notifications +{% endblock title %} +{% block content %} +

    List of Notifications

    + + {% include "util/pagination.html" with items=page status=status %} +
    + + +
    + + + + + + + + + + + {% for notification in notifications %} + + + + + + + + {% endfor %} + +
    Notification Type NameUserSentIs fake sent
    {{ notification.subscription.notification_type.name }}{{ notification.subscription.user }}{{ notification.sent }}{{ notification.fake_sent }}
    + +{% endblock content %} diff --git a/osf/email/__init__.py b/osf/email/__init__.py index bb22bbcd637..1cf39af809b 100644 --- a/osf/email/__init__.py +++ b/osf/email/__init__.py @@ -149,7 +149,7 @@ def _read_lookup_uri(uri: str) -> str: 'domain': settings.DOMAIN, } -def _render_email_html(notification_type, ctx: dict) -> str: +def _render_email_html(notification_type, ctx: dict, return_original_error: bool = False) -> str: template_text = notification_type.template if not template_text: return '' @@ -172,7 +172,9 @@ def _render_email_html(notification_type, ctx: dict) -> str: strict_undefined=True, ).render(**(ctx or {})) - except Exception: + except Exception as e: + if return_original_error: + raise e logging.exception( f'Mako render failed. type {notification_type.name} provided_keys=%s inline_uri=%s base_uri=%s lookup_dirs=%s', sorted((ctx or {}).keys()), uri, NOTIFY_BASE_URI, LOOKUP_DIRS, diff --git a/osf/models/notification_type.py b/osf/models/notification_type.py index e0afdab7aea..f8162a08bce 100644 --- a/osf/models/notification_type.py +++ b/osf/models/notification_type.py @@ -160,6 +160,7 @@ def is_digest_type(self): NotificationTypeEnum.ADDON_FILE_COPIED.value, NotificationTypeEnum.ADDON_FILE_MOVED.value, NotificationTypeEnum.ADDON_FILE_RENAMED.value, + NotificationTypeEnum.ADDON_FILE_REMOVED.value, NotificationTypeEnum.FILE_ADDED.value, NotificationTypeEnum.FILE_REMOVED.value, NotificationTypeEnum.FILE_UPDATED.value, From 0d4bd1d21414469edd8a837c28f3e6985f0f3126 Mon Sep 17 00:00:00 2001 From: Ostap-Zherebetskyi Date: Fri, 1 May 2026 18:43:25 +0300 Subject: [PATCH 08/13] [ENG-10943] Add the 'id' and 'event_name' attributes to the 'SubscriptionDetail' updates response (#11719) * Add the 'id' and 'event_name' attributes to the 'SubscriptionDetail' updates response * Add assertions for 'id' and 'event_name' in subscription detail response test --- api/subscriptions/views.py | 10 ++++++++++ .../subscriptions/views/test_subscriptions_detail.py | 2 ++ 2 files changed, 12 insertions(+) diff --git a/api/subscriptions/views.py b/api/subscriptions/views.py index 52be8669231..9fce7f4ab97 100644 --- a/api/subscriptions/views.py +++ b/api/subscriptions/views.py @@ -306,6 +306,9 @@ def update(self, request, *args, **kwargs): raise PermissionDenied for instance in qs: + instance.legacy_id = self.kwargs['subscription_id'] + instance.event_name = instance.notification_type.name + serializer = self.get_serializer(instance=instance, data=request.data, partial=True) serializer.is_valid(raise_exception=True) self.perform_update(serializer) @@ -326,6 +329,9 @@ def update(self, request, *args, **kwargs): raise PermissionDenied for instance in qs: + instance.legacy_id = self.kwargs['subscription_id'] + instance.event_name = instance.notification_type.name + serializer = self.get_serializer(instance=instance, data=request.data, partial=True) serializer.is_valid(raise_exception=True) self.perform_update(serializer) @@ -354,6 +360,9 @@ def update(self, request, *args, **kwargs): raise PermissionDenied for instance in qs: + instance.legacy_id = self.kwargs['subscription_id'] + instance.event_name = instance.notification_type.name + serializer = self.get_serializer(instance=instance, data=request.data, partial=True) serializer.is_valid(raise_exception=True) self.perform_update(serializer) @@ -361,6 +370,7 @@ def update(self, request, *args, **kwargs): else: instance.event_name = instance.notification_type.name # Set event_name for serializer to use + instance.legacy_id = instance.notification_type.name # Set legacy_id for serializer to use partial = kwargs.pop('partial', False) serializer = self.get_serializer(instance, data=request.data, partial=partial) diff --git a/api_tests/subscriptions/views/test_subscriptions_detail.py b/api_tests/subscriptions/views/test_subscriptions_detail.py index a7246bbbd19..f14eeec3cfa 100644 --- a/api_tests/subscriptions/views/test_subscriptions_detail.py +++ b/api_tests/subscriptions/views/test_subscriptions_detail.py @@ -342,3 +342,5 @@ def test_subscription_detail_patch( res = app.patch_json_api(url_user_global_file_updated, payload, auth=user.auth) assert res.status_code == 200 assert res.json['data']['attributes']['frequency'] == 'none' + assert res.json['data'].get('id') is not None + assert res.json['data']['attributes'].get('event_name') is not None From 237c66ad3676cf12a271175b12be398b0dedad42 Mon Sep 17 00:00:00 2001 From: Ostap-Zherebetskyi Date: Tue, 5 May 2026 16:37:14 +0300 Subject: [PATCH 09/13] [ENG-10943] Add the 'id' and 'event_name' attributes to the 'SubscriptionDetail' updates response (#11724) --- api/subscriptions/views.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/subscriptions/views.py b/api/subscriptions/views.py index 9fce7f4ab97..14a97d50489 100644 --- a/api/subscriptions/views.py +++ b/api/subscriptions/views.py @@ -307,7 +307,7 @@ def update(self, request, *args, **kwargs): for instance in qs: instance.legacy_id = self.kwargs['subscription_id'] - instance.event_name = instance.notification_type.name + instance.event_name = 'global_file_updated' serializer = self.get_serializer(instance=instance, data=request.data, partial=True) serializer.is_valid(raise_exception=True) @@ -330,7 +330,7 @@ def update(self, request, *args, **kwargs): for instance in qs: instance.legacy_id = self.kwargs['subscription_id'] - instance.event_name = instance.notification_type.name + instance.event_name = 'global_reviews' serializer = self.get_serializer(instance=instance, data=request.data, partial=True) serializer.is_valid(raise_exception=True) @@ -361,7 +361,7 @@ def update(self, request, *args, **kwargs): for instance in qs: instance.legacy_id = self.kwargs['subscription_id'] - instance.event_name = instance.notification_type.name + instance.event_name = 'file_updated' serializer = self.get_serializer(instance=instance, data=request.data, partial=True) serializer.is_valid(raise_exception=True) From cfdcc011f013918cfc073f6e8ac5a10895e03848 Mon Sep 17 00:00:00 2001 From: Ostap-Zherebetskyi Date: Thu, 7 May 2026 16:35:37 +0300 Subject: [PATCH 10/13] [ENG-11055] Fix conditional logic in pending registration template (#11729) --- website/templates/pending_registration_admin.html.mako | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/templates/pending_registration_admin.html.mako b/website/templates/pending_registration_admin.html.mako index ebf11fb1cd8..694a670c5e2 100644 --- a/website/templates/pending_registration_admin.html.mako +++ b/website/templates/pending_registration_admin.html.mako @@ -32,7 +32,7 @@ To cancel this registration: Click here.

    - % if not reviewable_provider__id != 'gfs': + % if reviewable_provider__id != 'gfs': Note: If any admin clicks their cancel link, the submission will be canceled immediately, and the pending registration will be reverted to draft state to revise and resubmit. This operation is irreversible. % else: From d35445e9625640786ba08fd2c0dc19c81bfe21df Mon Sep 17 00:00:00 2001 From: Ostap-Zherebetskyi Date: Sat, 16 May 2026 00:56:28 +0300 Subject: [PATCH 11/13] [ENG-10776] Log invalid user account status to Sentry with detailed user information (#11737) --- api/base/authentication/drf.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/api/base/authentication/drf.py b/api/base/authentication/drf.py index 9db2ca8faeb..9dd13ef15e6 100644 --- a/api/base/authentication/drf.py +++ b/api/base/authentication/drf.py @@ -21,6 +21,7 @@ from osf.models import OSFUser from osf.utils.fields import ensure_str from website import settings +from framework import sentry SessionStore = import_module(api_settings.SESSION_ENGINE).SessionStore @@ -92,6 +93,20 @@ def check_user(user): # For all other cases, the user status is invalid. Although such status can't be reached with # normal user-facing web application flow, it is still possible as a result of direct database # access, coding bugs, database corruption, etc. + extra_data = { + 'user_id': user.id, + 'user_guid': user._id, + 'is_active': user.is_active, + 'is_disabled': user.is_disabled, + 'is_merged': user.is_merged, + 'date_disabled': user.date_disabled, + 'is_confirmed': user.is_confirmed, + 'is_registered': user.is_registered, + 'can_login': user.has_usable_password() or ( + 'VERIFIED' in sum([list(each.values()) for each in user.external_identity.values()], []) + ), + } + sentry.log_message(f'Invalid user account status detected: user_id={user._id}', extra_data=extra_data) raise InvalidAccountError From 530fb372ae2f8d67a0025be71c3050b81f147774 Mon Sep 17 00:00:00 2001 From: Longze Chen Date: Fri, 22 May 2026 10:58:36 -0400 Subject: [PATCH 12/13] Redo migration for MaintenanceMode --- osf/migrations/0039_maintenancemode.py | 38 -------------------------- osf/migrations/0040_maintenancemode.py | 20 ++++++++++++++ 2 files changed, 20 insertions(+), 38 deletions(-) delete mode 100644 osf/migrations/0039_maintenancemode.py create mode 100644 osf/migrations/0040_maintenancemode.py diff --git a/osf/migrations/0039_maintenancemode.py b/osf/migrations/0039_maintenancemode.py deleted file mode 100644 index c29f466dfa4..00000000000 --- a/osf/migrations/0039_maintenancemode.py +++ /dev/null @@ -1,38 +0,0 @@ -# Generated by Django 4.2.26 on 2026-04-23 14:25 - -from django.db import migrations, models - - -def create_initial_record(apps, schema_editor): - MaintenanceMode = apps.get_model('osf', 'MaintenanceMode') - MaintenanceMode.objects.get_or_create( - pk=1, - defaults={'maintenance_mode': False} - ) - - -def reverse_initial_record(apps, schema_editor): - # the reverse 'reverse_initial_record' does nothing - # because the table will be removed - pass - - -class Migration(migrations.Migration): - - dependencies = [ - ('osf', '0038_abstractnode_date_last_indexed_and_more'), - ] - - operations = [ - migrations.CreateModel( - name='MaintenanceMode', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('maintenance_mode', models.BooleanField(default=False)), - ], - ), - migrations.RunPython( - create_initial_record, - reverse_code=reverse_initial_record - ), - ] diff --git a/osf/migrations/0040_maintenancemode.py b/osf/migrations/0040_maintenancemode.py new file mode 100644 index 00000000000..2afc6a599c2 --- /dev/null +++ b/osf/migrations/0040_maintenancemode.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.26 on 2026-05-22 14:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0039_merge_20260427_1359'), + ] + + operations = [ + migrations.CreateModel( + name='MaintenanceMode', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('maintenance_mode', models.BooleanField(default=False)), + ], + ), + ] From 502913ec113e27a4abf940fc27e3165dc76e4f96 Mon Sep 17 00:00:00 2001 From: Longze Chen Date: Fri, 22 May 2026 11:24:41 -0400 Subject: [PATCH 13/13] Add back manual modifications for osf 0040_maintenancemode --- osf/migrations/0040_maintenancemode.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/osf/migrations/0040_maintenancemode.py b/osf/migrations/0040_maintenancemode.py index 2afc6a599c2..9d7a410ae0a 100644 --- a/osf/migrations/0040_maintenancemode.py +++ b/osf/migrations/0040_maintenancemode.py @@ -1,8 +1,23 @@ # Generated by Django 4.2.26 on 2026-05-22 14:57 +# Manually added `create_initial_record()` from django.db import migrations, models +def create_initial_record(apps, schema_editor): + MaintenanceMode = apps.get_model('osf', 'MaintenanceMode') + MaintenanceMode.objects.get_or_create( + pk=1, + defaults={'maintenance_mode': False} + ) + + +def reverse_initial_record(apps, schema_editor): + # the reverse 'reverse_initial_record' does nothing + # because the table will be removed + pass + + class Migration(migrations.Migration): dependencies = [ @@ -17,4 +32,8 @@ class Migration(migrations.Migration): ('maintenance_mode', models.BooleanField(default=False)), ], ), + migrations.RunPython( + create_initial_record, + reverse_code=reverse_initial_record + ), ]