Skip to content

Commit d959d1c

Browse files
authored
Merge pull request #11706 from mkovalua/feature/ENG-10768
* https://openscience.atlassian.net/browse/ENG-10768 ## Purpose When OSF undergoes planned downtime for software releases, users who are unaware of the maintenance window have no way of knowing the site is intentionally offline — they simply experience a broken site. A backend maintenance mode is needed so that when enabled, the API halts normal request processing and signals to clients that the system is in maintenance. ## Changes return 503 for API if maintanence_mode is set to true set maintenance mode via admin
2 parents 3f36856 + 86784a3 commit d959d1c

9 files changed

Lines changed: 257 additions & 8 deletions

File tree

admin/maintenance/views.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import pytz
22
import datetime
33

4-
from osf.models import MaintenanceState
4+
from osf.models import MaintenanceState, MaintenanceMode
55
import website.maintenance as maintenance
66
from admin.maintenance.forms import MaintenanceForm
77

@@ -36,15 +36,17 @@ def get_context_data(self, **kwargs):
3636
maintenance = MaintenanceState.objects.first()
3737
kwargs['form'] = MaintenanceForm()
3838
kwargs['current_alert'] = model_to_dict(maintenance) if maintenance else None
39+
kwargs['maintenance_mode'] = MaintenanceMode.is_under_maintenance()
3940
return super().get_context_data(**kwargs)
4041

4142
def post(self, request, *args, **kwargs):
4243
data = request.POST
43-
44-
start = convert_eastern_to_utc(data['start']).isoformat() if data.get('start') else None
45-
end = convert_eastern_to_utc(data['end']).isoformat() if data.get('end') else None
46-
47-
maintenance.set_maintenance(data.get('message', ''), data['level'], start, end)
44+
if maintenance_mode := data.get('maintenance_mode'):
45+
MaintenanceMode(maintenance_mode=False if maintenance_mode == 'True' else True).save()
46+
else:
47+
start = convert_eastern_to_utc(data['start']).isoformat() if data.get('start') else None
48+
end = convert_eastern_to_utc(data['end']).isoformat() if data.get('end') else None
49+
maintenance.set_maintenance(data.get('message', ''), data['level'], start, end)
4850
return redirect('maintenance:display')
4951

5052

admin/templates/maintenance/display.html

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,21 @@ <h4>Put up an alert:</h4>
6666
</form>
6767
</div>
6868
</div>
69+
70+
71+
<div class="row">
72+
<div class="col-md-9" style="margin-top: 15px;">
73+
<form action="" method="post">
74+
{% csrf_token %}
75+
<input type="hidden" name="maintenance_mode" value={{maintenance_mode}}>
76+
{% if maintenance_mode %}
77+
<input class="btn btn-success" type="submit" value="Turn off Maintenance Mode" />
78+
{% else %}
79+
<input class="btn btn-danger" type="submit" value="Turn on Maintenance Mode" />
80+
{% endif %}
81+
</form>
82+
</div>
83+
</div>
6984
{% endif %}
7085

7186
{% endblock content %}

admin_tests/maintenance/test_views.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from django.core.exceptions import PermissionDenied
99

1010
import website.maintenance as maintenance
11-
from osf.models import MaintenanceState
11+
from osf.models import MaintenanceState, MaintenanceMode
1212
from osf_tests.factories import AuthUserFactory
1313

1414
from admin_tests.utilities import setup_view
@@ -105,3 +105,59 @@ def test_correct_view_permissions(self, req, user, plain_view):
105105

106106
res = plain_view.as_view()(req)
107107
assert res.status_code == 200
108+
109+
110+
@pytest.mark.urls('admin.base.urls')
111+
class TestMaintenanceMode:
112+
113+
@pytest.fixture()
114+
def user(self):
115+
user = AuthUserFactory()
116+
view_permission = Permission.objects.get(codename='change_maintenancestate')
117+
user.user_permissions.add(view_permission)
118+
user.save()
119+
return user
120+
121+
@pytest.fixture()
122+
def plain_view(self):
123+
return views.MaintenanceDisplay
124+
125+
@pytest.fixture()
126+
def view(self, user, plain_view):
127+
req = RequestFactory().get('/fake_path')
128+
req.user = user
129+
view = plain_view()
130+
setup_view(view, req)
131+
return view
132+
133+
def test_get_context_data_includes_maintenance_mode(self, view):
134+
MaintenanceMode(maintenance_mode=True).save()
135+
context = view.get_context_data()
136+
assert context['maintenance_mode'] is True
137+
MaintenanceMode(maintenance_mode=False).save()
138+
context = view.get_context_data()
139+
assert context['maintenance_mode'] is False
140+
141+
def test_post_toggles_maintenance_mode_on(self, user, plain_view):
142+
MaintenanceMode(maintenance_mode=False).save()
143+
req = RequestFactory().post('/fake_path', data={'maintenance_mode': 'False'})
144+
req.user = user
145+
view = plain_view()
146+
setup_view(view, req)
147+
response = view.post(req)
148+
# It should redirect back to the display page
149+
assert response.status_code == 302
150+
# The database state should now be True
151+
assert MaintenanceMode.is_under_maintenance() is True
152+
153+
def test_post_toggles_maintenance_mode_off(self, user, plain_view):
154+
MaintenanceMode(maintenance_mode=True).save()
155+
req = RequestFactory().post('/fake_path', data={'maintenance_mode': 'True'})
156+
req.user = user
157+
view = plain_view()
158+
setup_view(view, req)
159+
response = view.post(req)
160+
# It should redirect back to the display page
161+
assert response.status_code == 302
162+
# The database state should now be False
163+
assert MaintenanceMode.is_under_maintenance() is False

api/base/middleware.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from importlib import import_module
66

77
from django.conf import settings
8+
from django.http import JsonResponse
89
from django.contrib.sessions.middleware import SessionMiddleware
910
from django.utils.deprecation import MiddlewareMixin
1011
from sentry_sdk import init
@@ -24,6 +25,7 @@
2425
from .api_globals import api_globals
2526
from api.base import settings as api_settings
2627
from api.base.authentication.drf import drf_get_session_from_cookie
28+
from osf.models import MaintenanceMode
2729

2830
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
2931

@@ -132,3 +134,20 @@ def process_request(self, request):
132134
request.session = drf_get_session_from_cookie(cookie)
133135
else:
134136
request.session = SessionStore()
137+
138+
139+
class MaintenanceModeMiddleware:
140+
def __init__(self, get_response):
141+
self.get_response = get_response
142+
143+
def __call__(self, request):
144+
if MaintenanceMode.is_under_maintenance():
145+
return JsonResponse(
146+
{
147+
'meta': {
148+
'maintenance_mode': True,
149+
'status_page': 'https://status.cos.io',
150+
},
151+
}, status=503,
152+
)
153+
return self.get_response(request)

api/base/settings/defaults.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@
232232
'django.middleware.common.CommonMiddleware',
233233
'django.middleware.csrf.CsrfViewMiddleware',
234234
'api.base.middleware.UnsignCookieSessionMiddleware',
235+
'api.base.middleware.MaintenanceModeMiddleware',
235236
'django.contrib.auth.middleware.AuthenticationMiddleware',
236237
'django.contrib.messages.middleware.MessageMiddleware',
237238
'django.middleware.clickjacking.XFrameOptionsMiddleware',
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Generated by Django 4.2.26 on 2026-04-23 14:25
2+
3+
from django.db import migrations, models
4+
5+
6+
def create_initial_record(apps, schema_editor):
7+
MaintenanceMode = apps.get_model('osf', 'MaintenanceMode')
8+
MaintenanceMode.objects.get_or_create(
9+
pk=1,
10+
defaults={'maintenance_mode': False}
11+
)
12+
13+
14+
def reverse_initial_record(apps, schema_editor):
15+
# the reverse 'reverse_initial_record' does nothing
16+
# because the table will be removed
17+
pass
18+
19+
20+
class Migration(migrations.Migration):
21+
22+
dependencies = [
23+
('osf', '0038_abstractnode_date_last_indexed_and_more'),
24+
]
25+
26+
operations = [
27+
migrations.CreateModel(
28+
name='MaintenanceMode',
29+
fields=[
30+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
31+
('maintenance_mode', models.BooleanField(default=False)),
32+
],
33+
),
34+
migrations.RunPython(
35+
create_initial_record,
36+
reverse_code=reverse_initial_record
37+
),
38+
]

osf/models/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
from .institution_affiliation import InstitutionAffiliation
5353
from .institution_storage_region import InstitutionStorageRegion
5454
from .licenses import NodeLicense, NodeLicenseRecord
55-
from .maintenance_state import MaintenanceState
55+
from .maintenance_state import MaintenanceState, MaintenanceMode
5656
from .metadata import GuidMetadataRecord
5757
from .metaschema import (
5858
FileMetadataSchema,

osf/models/maintenance_state.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,16 @@ class MaintenanceState(models.Model):
1414
start = NonNaiveDateTimeField()
1515
end = NonNaiveDateTimeField()
1616
message = models.TextField(blank=True)
17+
18+
19+
class MaintenanceMode(models.Model):
20+
maintenance_mode = models.BooleanField(default=False)
21+
22+
def save(self, *args, **kwargs):
23+
self.pk = 1
24+
super().save(*args, **kwargs)
25+
26+
@classmethod
27+
def is_under_maintenance(cls):
28+
obj, _ = cls.objects.get_or_create(pk=1)
29+
return obj.maintenance_mode

osf_tests/test_middleware.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import pytest
2+
from unittest import mock
3+
from tests.base import ApiTestCase
4+
from osf.utils import permissions
5+
from osf_tests.factories import (
6+
AuthUserFactory,
7+
CollectionProviderFactory,
8+
ProjectFactory,
9+
)
10+
11+
12+
@pytest.fixture()
13+
def provider():
14+
provider = CollectionProviderFactory()
15+
provider.update_group_permissions()
16+
return provider
17+
18+
19+
@pytest.fixture()
20+
def admin(provider):
21+
user = AuthUserFactory()
22+
provider.get_group(permissions.ADMIN).user_set.add(user)
23+
return user
24+
25+
26+
@pytest.fixture()
27+
def node(admin):
28+
return ProjectFactory(creator=admin)
29+
30+
31+
class TestMaintenanceModeMiddlewareIntegration(ApiTestCase):
32+
MAINTENANCE_MOCK_PATH = 'api.base.middleware.MaintenanceMode.is_under_maintenance'
33+
34+
def setUp(self):
35+
super().setUp()
36+
self.provider = CollectionProviderFactory()
37+
self.provider.update_group_permissions()
38+
self.admin = AuthUserFactory()
39+
self.provider.get_group(permissions.ADMIN).user_set.add(self.admin)
40+
self.node = ProjectFactory(creator=self.admin)
41+
42+
@mock.patch(MAINTENANCE_MOCK_PATH, return_value=True)
43+
def test_middleware_blocks_post_if_maintenance_mode_on(self, mock_maintenance):
44+
url = f'/v2/nodes/{self.node._id}/'
45+
response = self.app.post_json(url, {}, expect_errors=True)
46+
assert response.status_code == 503
47+
assert response.json['meta']['maintenance_mode'] is True
48+
assert response.json['meta']['status_page'] == 'https://status.cos.io'
49+
50+
@mock.patch(MAINTENANCE_MOCK_PATH, return_value=True)
51+
def test_middleware_blocks_patch_if_maintenance_mode_on(self, mock_maintenance):
52+
url = f'/v2/nodes/{self.node._id}/'
53+
original_title = self.node.title
54+
payload = {
55+
'data': {
56+
'id': self.node._id,
57+
'type': 'nodes',
58+
'attributes': {'title': 'Updated Title'}
59+
}
60+
}
61+
response = self.app.patch_json(url, payload, expect_errors=True)
62+
assert response.status_code == 503
63+
assert response.json['meta']['maintenance_mode'] is True
64+
self.node.reload()
65+
assert self.node.title == original_title
66+
67+
@mock.patch(MAINTENANCE_MOCK_PATH, return_value=True)
68+
def test_middleware_blocks_delete_if_maintenance_mode_on(self, mock_maintenance):
69+
url = f'/v2/nodes/{self.node._id}/'
70+
response = self.app.delete(url, expect_errors=True)
71+
assert response.status_code == 503
72+
assert response.json['meta']['maintenance_mode'] is True
73+
self.node.reload()
74+
assert self.node.is_deleted is False
75+
76+
@mock.patch(MAINTENANCE_MOCK_PATH, return_value=False)
77+
def test_go_to_post_view_if_maintenance_mode_off(self, mock_maintenance):
78+
url = '/v2/nodes/'
79+
payload = {
80+
'data': {
81+
'type': 'nodes',
82+
'attributes': {'title': 'New Node', 'category': 'project'}
83+
}
84+
}
85+
response = self.app.post_json(url, payload, auth=self.admin.auth)
86+
assert response.status_code == 201
87+
88+
@mock.patch(MAINTENANCE_MOCK_PATH, return_value=False)
89+
def test_go_to_patch_view_if_maintenance_mode_off(self, mock_maintenance):
90+
url = f'/v2/nodes/{self.node._id}/'
91+
payload = {
92+
'data': {
93+
'id': self.node._id,
94+
'type': 'nodes',
95+
'attributes': {'title': 'Updated Title'}
96+
}
97+
}
98+
response = self.app.patch_json(url, payload, auth=self.admin.auth)
99+
assert response.status_code == 200
100+
101+
@mock.patch(MAINTENANCE_MOCK_PATH, return_value=False)
102+
def test_go_to_delete_view_if_maintenance_mode_off(self, mock_maintenance):
103+
url = f'/v2/nodes/{self.node._id}/'
104+
response = self.app.delete(url, auth=self.admin.auth)
105+
assert response.status_code == 204

0 commit comments

Comments
 (0)