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:
+
+
+
{% endif %}
{% endblock content %}
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 bec771aba61..239d64194cb 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,20 @@ 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 MaintenanceMode.is_under_maintenance():
+ return JsonResponse(
+ {
+ 'meta': {
+ 'maintenance_mode': True,
+ 'status_page': 'https://status.cos.io',
+ },
+ }, 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
diff --git a/osf_tests/test_middleware.py b/osf_tests/test_middleware.py
new file mode 100644
index 00000000000..32c16cabd41
--- /dev/null
+++ b/osf_tests/test_middleware.py
@@ -0,0 +1,105 @@
+import pytest
+from unittest import mock
+from tests.base import ApiTestCase
+from osf.utils import permissions
+from osf_tests.factories import (
+ AuthUserFactory,
+ CollectionProviderFactory,
+ ProjectFactory,
+)
+
+
+@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_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
+ 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_middleware_blocks_patch_if_maintenance_mode_on(self, mock_maintenance):
+ url = f'/v2/nodes/{self.node._id}/'
+ 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_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_if_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_patch_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_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