diff --git a/docs/manual/admin/upgrading/upgrading-reviewboard.rst b/docs/manual/admin/upgrading/upgrading-reviewboard.rst
index fedc8db32432b0b7a218ffef88038a5cc41e4229..800d55b30e2b4b3ce80536c8af8cbc202d53eda4 100644
--- a/docs/manual/admin/upgrading/upgrading-reviewboard.rst
+++ b/docs/manual/admin/upgrading/upgrading-reviewboard.rst
@@ -4,7 +4,8 @@ Upgrading Review Board
 
 
 Upgrading Review Board is pretty simple. It's essentially a three-step
-(optionally four-step) process.
+(optionally four-step) process. It is recommended that you enable
+read-only mode while performing the upgrade.
 
 1. Upgrade Review Board by running::
 
diff --git a/docs/manual/admin/upgrading/upgrading-sites.rst b/docs/manual/admin/upgrading/upgrading-sites.rst
index aef328893e9b0e9fe5f531a1e412c7cf54691b66..2738e5cfd58e9a4a94eef5a19df8692f22f89385 100644
--- a/docs/manual/admin/upgrading/upgrading-sites.rst
+++ b/docs/manual/admin/upgrading/upgrading-sites.rst
@@ -13,8 +13,8 @@ Upgrading serves several key tasks:
 
 If you don't perform an upgrade, your site may appear broken.
 
-Before upgrading, we highly recommend backing up your database, in case
-something goes wrong.
+Before upgrading, we highly recommend backing up your database and enabling
+read-only mode in case something goes wrong.
 
 To begin a site upgrade, run::
 
diff --git a/docs/manual/extending/extensions/class.rst b/docs/manual/extending/extensions/class.rst
index 967c1dcffd89f58b5a098287e82fbd081e7e2cc6..6686f3a90a42827f89ffc85a65bf3bed15bfc0b5 100644
--- a/docs/manual/extending/extensions/class.rst
+++ b/docs/manual/extending/extensions/class.rst
@@ -299,3 +299,24 @@ the extension's information. This will be a miniature version of Review
 Board's normal database viewer.
 
 This is covered in more detail in :ref:`extension-admin-site`.
+
+.. _extension-read-only-mode:
+
+Utilizing Read-Only Mode
+========================
+
+Review Board can be put into read-only mode by the site administrator, which
+disables API requests to the server and hides associated front-end features.
+If you would like your extension to be compliant or have specific behavior in
+read-only mode, :py:meth:`~reviewboard.admin.read_only.is_site_read_only_for`
+can be called with a :py:class:`User <django.contrib.auth.models.User>` to
+check if the User should be affected by the read-only mode.
+
+.. code-block:: python
+
+   from reviewboard.admin.read_only import is_site_read_only_for
+       ...
+
+
+   if is_site_read_only_for(user):
+       # Put code to run when in read-only mode here.
diff --git a/docs/manual/extending/extensions/js-extensions.rst b/docs/manual/extending/extensions/js-extensions.rst
index f97874f7d63fc4625554b90f90e7042b738fded3..5baf55ad947c29a754b02013716b76c9eb31d947 100644
--- a/docs/manual/extending/extensions/js-extensions.rst
+++ b/docs/manual/extending/extensions/js-extensions.rst
@@ -276,6 +276,28 @@ Backbone.js attribute accessors:
         }
     });
 
+.. _js-extensions-read-only-mode:
+
+Utilizing Read-Only Mode
+========================
+
+Reviewboard can be put into read-only mode by the site administrator, which
+disables API requests to the server and associated front-end features.
+When the site is in read-only mode, only changes made to models by superusers
+will be propagated to the server; changes made by all other users will be
+discarded.
+
+Whether a user is in read-only mode can be checked by looking up the
+``readOnly`` property in the :js:class:`RB.UserSession` instance.
+
+.. code-block:: javascript
+
+   RB.UserSession.instance.get('readOnly')
+
+
+   if is_site_read_only_for(user):
+       /* Put code to run when in read-only mode here. */
+
 
 .. _Backbone.js: http://backbonejs.org/
 .. _reviewboard-dev: https://groups.google.com/group/reviewboard-dev
diff --git a/reviewboard/accounts/tests.py b/reviewboard/accounts/tests.py
index 32c3d8f97148b0eb60aaa3735db159053ed576a8..977fe89eb66617514f2820329178d2e151606384 100644
--- a/reviewboard/accounts/tests.py
+++ b/reviewboard/accounts/tests.py
@@ -5,8 +5,10 @@ import re
 from django.contrib import messages
 from django.contrib.auth.models import User
 from django.core.exceptions import ValidationError
+from django.http import HttpResponse
 from django.test.client import RequestFactory
 from djblets.registries.errors import ItemLookupError, RegistrationError
+from djblets.siteconfig.models import SiteConfiguration
 from djblets.testing.decorators import add_fixtures
 from kgb import SpyAgency
 
@@ -16,6 +18,7 @@ from reviewboard.accounts.backends import (AuthBackend, auth_backends,
                                            register_auth_backend,
                                            StandardAuthBackend,
                                            unregister_auth_backend)
+from reviewboard.accounts.decorators import login_required
 from reviewboard.accounts.forms.pages import (AccountPageForm,
                                               ChangePasswordForm,
                                               ProfileForm)
@@ -548,3 +551,187 @@ class SandboxTests(SpyAgency, TestCase):
 
         form.save()
         self.assertTrue(SandboxAuthBackend.update_email.called)
+
+
+class LoginRequiredTest(TestCase):
+    """Test @login_required"""
+
+    fixtures = ['test_users']
+
+    @classmethod
+    def setUpClass(cls):
+        super(LoginRequiredTest, cls).setUpClass()
+
+        cls.factory = RequestFactory()
+        cls.request = cls.factory.get('test')
+
+    def setUp(self):
+        super(LoginRequiredTest, self).setUp()
+
+        self.siteconfig = SiteConfiguration.objects.get_current()
+
+    def tearDown(self):
+        super(LoginRequiredTest, self).tearDown()
+
+        defaults = self.siteconfig.get_defaults()
+
+        self.siteconfig.set('site_read_only', defaults.get('site_read_only'))
+        self.siteconfig.save()
+
+    def _mock_view_function(self, request):
+        """Mock view function to test with.
+
+        Args:
+            request (django.http.HttpRequest):
+                Test request.
+
+        Returns:
+            django.http.HttpResponse:
+            A fake response.
+        """
+        return HttpResponse(content='success', status=900)
+
+    def test_login_required_without_redirect_read_only_for_users(self):
+        """Testing @login_required passing only a view_func for regular
+        users
+        """
+        decorated_func = login_required(self._mock_view_function)
+
+        self.request.user = User(username='dummy', is_superuser=False)
+        resp = decorated_func(self.request)
+
+        self.assertEqual(resp.status_code, 900)
+        self.assertEqual(resp.content, 'success')
+
+    def test_login_required_without_redirect_read_only_for_superusers(self):
+        """Testing @login_required passing only a view_func for superusers"""
+        decorated_func = login_required(self._mock_view_function)
+
+        self.request.user = User(username='dummy', is_superuser=True)
+        resp = decorated_func(self.request)
+
+        self.assertEqual(resp.status_code, 900)
+        self.assertEqual(resp.content, 'success')
+
+    def test_login_required_with_view_func_while_read_only_for_users(self):
+        """Testing @login_required for regular users with redirect_read_only
+        set to true while site is in read-only mode
+        """
+        self.siteconfig.set('site_read_only', True)
+        self.siteconfig.save()
+
+        # Get decorated function then get response with regular user
+        decorated_func = login_required(
+            redirect_read_only=True)(self._mock_view_function)
+
+        self.request.user = User(username='dummy', is_superuser=False)
+        resp = decorated_func(self.request)
+
+        self._check_response_is_redirected(resp, True)
+
+    def test_login_required_with_view_func_while_read_only_for_superusers(self):
+        """Testing @login_required for superusers with redirect_read_only set to
+        true while site is in read-only mode
+        """
+        self.siteconfig.set('site_read_only', True)
+        self.siteconfig.save()
+
+        # Get decorated function then get response with superuser
+        decorated_func = login_required(
+            redirect_read_only=True)(self._mock_view_function)
+
+        self.request.user = User(username='dummy', is_superuser=True)
+        resp = decorated_func(self.request)
+
+        self._check_response_is_redirected(resp, False)
+
+    def test_login_required_with_view_func_while_not_read_only_for_users(self):
+        """Testing @login_required for regular users with redirect_read_only
+        set to true while site is not in read-only mode
+        """
+        self.siteconfig.set('site_read_only', False)
+        self.siteconfig.save()
+
+        # Get decorated function then get response with regular user
+        decorated_func = login_required(
+            redirect_read_only=True)(self._mock_view_function)
+
+        self.request.user = User(username='dummy', is_superuser=False)
+        resp = decorated_func(self.request)
+
+        self._check_response_is_redirected(resp, False)
+
+    def test_login_required_with_view_func_while_not_read_only_for_superusers(self):
+        """Testing @login_required for superusers with redirect_read_only set to
+        true while site is not in read-only mode
+        """
+        self.siteconfig.set('site_read_only', False)
+        self.siteconfig.save()
+
+        # Get decorated function then get response with superuser
+        decorated_func = login_required(
+            redirect_read_only=True)(self._mock_view_function)
+
+        self.request.user = User(username='dummy', is_superuser=True)
+        resp = decorated_func(self.request)
+
+        self._check_response_is_redirected(resp, False)
+
+    def test_my_account_redirects_when_read_only(self):
+        """Testing accessing My Account redirects when read-only"""
+        self.siteconfig.set('site_read_only', True)
+        self.siteconfig.save()
+
+        # Login as regular user and try to access My Account.
+        self.client.login(username='doc', password='doc')
+        resp = self.client.get('/account/preferences/')
+
+        self.assertEqual(resp.status_code, 302)
+        self.assertEqual(resp.url, 'http://testserver/503/')
+
+    def test_my_account_does_not_redirect_when_not_read_only(self):
+        """Testing accessing My Account does not redirect normally"""
+        self.siteconfig.set('site_read_only', False)
+        self.siteconfig.save()
+
+        # Login as regular user and try to access My Account
+        self.client.login(username='doc', password='doc')
+        resp = self.client.get('/account/preferences/')
+
+        self.assertEqual(resp.status_code, 200)
+        self.assertContains(resp, 'My Account')
+
+    def test_my_account_does_not_redirect_when_not_read_only_for_admin(self):
+        """Testing accessing My Account does not redirect when in read-only
+        for admins
+        """
+        self.siteconfig.set('site_read_only', True)
+        self.siteconfig.save()
+
+        # Login as regular user and try to access My Account
+        self.client.login(username='admin', password='admin')
+        resp = self.client.get('/account/preferences/')
+
+        self.assertEqual(resp.status_code, 200)
+        self.assertContains(resp, 'My Account')
+
+    def _check_response_is_redirected(self, resp, redirect_expected):
+        """Check response versus the expected value.
+
+        Args:
+            resp (django.http.HttpResponse):
+                Test response.
+
+            redirect_expected (bool):
+                Whether or not response is expected to be a response.
+
+        Returns:
+            bool:
+            Whether the response is a redirect or not.
+        """
+        if redirect_expected:
+            self.assertEqual(resp.status_code, 302)
+            self.assertEqual(resp.url, '/503/')
+        else:
+            self.assertEqual(resp.status_code, 900)
+            self.assertEqual(resp.content, 'success')
diff --git a/reviewboard/reviews/tests/test_views.py b/reviewboard/reviews/tests/test_views.py
index cf5d10770396aff7e794ac6691926ceb93efd05a..82dc435d85e8b72186601b807e5df98f646ffe4d 100644
--- a/reviewboard/reviews/tests/test_views.py
+++ b/reviewboard/reviews/tests/test_views.py
@@ -29,6 +29,14 @@ class ViewTests(TestCase):
         self.siteconfig.set('auth_require_sitewide_login', False)
         self.siteconfig.save()
 
+    def tearDown(self):
+        super(ViewTests, self).tearDown()
+
+        defaults = self.siteconfig.get_defaults()
+
+        self.siteconfig.set('site_read_only', defaults.get('site_read_only'))
+        self.siteconfig.save()
+
     def test_review_detail_redirect_no_slash(self):
         """Testing review_detail view redirecting with no trailing slash"""
         response = self.client.get('/r/1')
@@ -332,7 +340,7 @@ class ViewTests(TestCase):
         self.assertEqual(response.status_code, 302)
 
     def test_new_review_request(self):
-        """Testing new_review_request view"""
+        """Testing new_review_request view."""
         response = self.client.get('/r/new')
         self.assertEqual(response.status_code, 301)
 
@@ -344,6 +352,38 @@ class ViewTests(TestCase):
         response = self.client.get('/r/new/')
         self.assertEqual(response.status_code, 200)
 
+    def test_new_review_request_in_read_only_mode_for_users(self):
+        """Testing new_review_request view when in read-only mode for
+        regular users
+        """
+        # Turn on read-only mode.
+        self.siteconfig.set('site_read_only', True)
+        self.siteconfig.save()
+
+        # Ensure user is redirected when trying to create new review request
+        self.client.logout()
+        self.client.login(username='doc', password='doc')
+
+        resp = self.client.get('/r/new/')
+
+        self.assertEqual(resp.status_code, 302)
+
+    def test_new_review_request_in_read_only_mode_for_superusers(self):
+        """Testing new_review_request view when in read-only mode for
+        superusers
+        """
+        # Turn on read-only mode.
+        self.siteconfig.set('site_read_only', True)
+        self.siteconfig.save()
+
+        # Ensure admin can still access new while in read-only mode.
+        self.client.logout()
+        self.client.login(username='admin', password='admin')
+
+        resp = self.client.get('/r/new/')
+
+        self.assertEqual(resp.status_code, 200)
+
     # Bug 892
     def test_interdiff(self):
         """Testing the diff viewer with interdiffs"""
