diff --git a/reviewboard/accounts/views.py b/reviewboard/accounts/views.py
index 96c42a275243fa027401fd4a6988286758d9481f..a818870904a7a45258ee39cb6b6394302a55c714 100644
--- a/reviewboard/accounts/views.py
+++ b/reviewboard/accounts/views.py
@@ -2,7 +2,7 @@ from __future__ import unicode_literals
 
 from django.contrib.auth.decorators import login_required
 from django.core.urlresolvers import reverse
-from django.http import HttpResponseRedirect
+from django.http import HttpResponseRedirect, HttpResponse
 from django.utils.decorators import method_decorator
 from django.utils.functional import cached_property
 from django.utils.translation import ugettext_lazy as _
@@ -15,6 +15,7 @@ from djblets.util.decorators import augment_method_from
 from reviewboard.accounts.backends import get_enabled_auth_backends
 from reviewboard.accounts.forms.registration import RegistrationForm
 from reviewboard.accounts.pages import AccountPage
+from reviewboard.admin.read_only import is_site_read_only_for
 
 @csrf_protect
 def account_register(request, next_url='dashboard'):
@@ -81,3 +82,15 @@ class MyAccountView(ConfigPagesView):
     def ordered_user_local_sites(self):
         """Get the user's local sites, ordered by name."""
         return self.request.user.local_site.order_by('name')
+
+    def get(self, request, *args, **kwargs):
+        if is_site_read_only_for(request.user):
+            return HttpResponseRedirect(reverse('dashboard'))
+
+        return super(MyAccountView, self).get(request, *args, **kwargs)
+
+    def post(self, request, *args, **kwargs):
+        if is_site_read_only_for(request.user):
+            return HttpResponse(status=503)
+
+        return super(MyAccountView, self).post(request, *args, **kwargs)
diff --git a/reviewboard/admin/context_processors.py b/reviewboard/admin/context_processors.py
index 219e530614d2de03398d7e8ffed014791cf3e3ad..4e814a78909bb5f93daf810400915043ee3f7bd8 100644
--- a/reviewboard/admin/context_processors.py
+++ b/reviewboard/admin/context_processors.py
@@ -2,6 +2,7 @@ from __future__ import unicode_literals
 
 from reviewboard import (get_manual_url, get_package_version,
                          get_version_string, is_release, VERSION)
+from reviewboard.admin.read_only import is_site_read_only_for
 
 
 def version(request):
@@ -13,3 +14,10 @@ def version(request):
         'version_raw': VERSION,
         'RB_MANUAL_URL': get_manual_url(),
     }
+
+
+def read_only(request):
+    """Return a dictionary with read only mode information."""
+    return {
+        'is_read_only': is_site_read_only_for(request.user),
+    }
diff --git a/reviewboard/admin/read_only.py b/reviewboard/admin/read_only.py
new file mode 100644
index 0000000000000000000000000000000000000000..231018feed70d81fff03e216d23c71c90e3b5cf1
--- /dev/null
+++ b/reviewboard/admin/read_only.py
@@ -0,0 +1,25 @@
+"""Provide utility methods for read-only mode."""
+
+from djblets.siteconfig.models import SiteConfiguration
+
+
+def is_site_read_only_for(user):
+    """Check whether user should be affected by read-only mode.
+
+    Superusers are not affected by read-only mode. Otherwise, check siteconfig
+    for ``site_read_only``.
+
+    Args:
+        user (User):
+            The user that is to be checked.
+
+    Returns:
+        bool:
+        A boolean representing whether the site is read-only for a user.
+    """
+    if not hasattr(user, '_cache_site_is_read_only'):
+        siteconfig = SiteConfiguration.objects.get_current()
+        user._cache_site_is_read_only = (not user.is_superuser and
+                                         siteconfig.get('site_read_only'))
+
+    return user._cache_site_is_read_only
diff --git a/reviewboard/datagrids/grids.py b/reviewboard/datagrids/grids.py
index 0c708cc09ae3e3c0c5ddc6a2efb2028a06942f1f..de862a2a8467e6d96b8e13f2096990f6d2ea3340 100644
--- a/reviewboard/datagrids/grids.py
+++ b/reviewboard/datagrids/grids.py
@@ -14,6 +14,7 @@ from djblets.util.templatetags.djblets_utils import ageid
 
 from reviewboard.accounts.models import (LocalSiteProfile, Profile,
                                          ReviewRequestVisit)
+from reviewboard.admin.read_only import is_site_read_only_for
 from reviewboard.datagrids.columns import (BugsColumn,
                                            DateTimeSinceColumn,
                                            DiffSizeColumn,
@@ -111,6 +112,11 @@ class DataGrid(DataGridJSMixin, DjbletsDataGrid):
     to load for the page.
     """
 
+    def load_state(self, render_context, disable_save=False):
+        """Load the state of the datagrid without saving when read-only."""
+        read_only = is_site_read_only_for(self.request.user)
+        super(DataGrid, self).load_state(render_context, read_only)
+
 
 class AlphanumericDataGrid(DataGridJSMixin, DjbletsAlphanumericDataGrid):
     """Base class for an alphanumeric datagrid in Review Board.
@@ -119,6 +125,11 @@ class AlphanumericDataGrid(DataGridJSMixin, DjbletsAlphanumericDataGrid):
     to load for the page.
     """
 
+    def load_state(self, render_context, disable_save=False):
+        """Load the state of the datagrid without saving when read-only."""
+        read_only = is_site_read_only_for(self.request.user)
+        super(AlphanumericDataGrid, self).load_state(render_context, read_only)
+
 
 class ReviewRequestDataGrid(ShowClosedReviewRequestsMixin, DataGrid):
     """A datagrid that displays a list of review requests.
diff --git a/reviewboard/reviews/default_actions.py b/reviewboard/reviews/default_actions.py
index cda9ef49d67581e4776fc3349e7e550a70b083ba..b8d658127e9b6a75b94735c5b9cde25d2623abe2 100644
--- a/reviewboard/reviews/default_actions.py
+++ b/reviewboard/reviews/default_actions.py
@@ -2,6 +2,7 @@ from __future__ import unicode_literals
 
 from django.utils.translation import ugettext_lazy as _
 
+from reviewboard.admin.read_only import is_site_read_only_for
 from reviewboard.reviews.actions import (BaseReviewRequestAction,
                                          BaseReviewRequestMenuAction)
 from reviewboard.reviews.features import general_comments_feature
@@ -22,7 +23,8 @@ class CloseMenuAction(BaseReviewRequestMenuAction):
         return (review_request.status == ReviewRequest.PENDING_REVIEW and
                 (context['request'].user.pk == review_request.submitter_id or
                  (context['perms']['reviews']['can_change_status'] and
-                  review_request.public)))
+                  review_request.public)) and
+                not is_site_read_only_for(context['request'].user))
 
 
 class SubmitAction(BaseReviewRequestAction):
@@ -32,7 +34,8 @@ class SubmitAction(BaseReviewRequestAction):
     label = _('Submitted')
 
     def should_render(self, context):
-        return context['review_request'].public
+        return (context['review_request'].public and
+                not is_site_read_only_for(context['request'].user))
 
 
 class DiscardAction(BaseReviewRequestAction):
@@ -49,7 +52,8 @@ class DeleteAction(BaseReviewRequestAction):
     label = _('Delete Permanently')
 
     def should_render(self, context):
-        return context['perms']['reviews']['delete_reviewrequest']
+        return (context['perms']['reviews']['delete_reviewrequest'] and
+                not is_site_read_only_for(context['request'].user))
 
 
 class UpdateMenuAction(BaseReviewRequestMenuAction):
@@ -63,7 +67,8 @@ class UpdateMenuAction(BaseReviewRequestMenuAction):
 
         return (review_request.status == ReviewRequest.PENDING_REVIEW and
                 (context['request'].user.pk == review_request.submitter_id or
-                 context['perms']['reviews']['can_edit_reviewrequest']))
+                 context['perms']['reviews']['can_edit_reviewrequest']) and
+                not is_site_read_only_for(context['request'].user))
 
 
 class UploadDiffAction(BaseReviewRequestAction):
@@ -106,7 +111,8 @@ class UploadDiffAction(BaseReviewRequestAction):
         Returns:
             bool: Determines if this action should render.
         """
-        return context['review_request'].repository_id is not None
+        return (context['review_request'].repository_id is not None and
+                not is_site_read_only_for(context['request'].user))
 
 
 class UploadFileAction(BaseReviewRequestAction):
@@ -115,6 +121,9 @@ class UploadFileAction(BaseReviewRequestAction):
     action_id = 'upload-file-action'
     label = _('Add File')
 
+    def should_render(self, context):
+        return not is_site_read_only_for(context['request'].user)
+
 
 class DownloadDiffAction(BaseReviewRequestAction):
     """An action for downloading a diff from the review request."""
@@ -190,7 +199,8 @@ class EditReviewAction(BaseReviewRequestAction):
     label = _('Review')
 
     def should_render(self, context):
-        return context['request'].user.is_authenticated()
+        return (context['request'].user.is_authenticated() and
+                not is_site_read_only_for(context['request'].user))
 
 
 class AddGeneralCommentAction(BaseReviewRequestAction):
@@ -202,7 +212,8 @@ class AddGeneralCommentAction(BaseReviewRequestAction):
     def should_render(self, context):
         request = context['request']
         return (request.user.is_authenticated() and
-                general_comments_feature.is_enabled(request=request))
+                general_comments_feature.is_enabled(request=request) and
+                not is_site_read_only_for(request.user))
 
 
 class ShipItAction(BaseReviewRequestAction):
@@ -212,7 +223,8 @@ class ShipItAction(BaseReviewRequestAction):
     label = _('Ship It!')
 
     def should_render(self, context):
-        return context['request'].user.is_authenticated()
+        return (context['request'].user.is_authenticated() and
+                not is_site_read_only_for(context['request'].user))
 
 
 def get_default_actions():
diff --git a/reviewboard/reviews/models/base_comment.py b/reviewboard/reviews/models/base_comment.py
index 0e5866e89daa1ae81992fb4e297289433d5d3a9a..5e8a7f8fc6740f0421434741952918693222c767 100644
--- a/reviewboard/reviews/models/base_comment.py
+++ b/reviewboard/reviews/models/base_comment.py
@@ -9,6 +9,8 @@ from django.utils.translation import ugettext_lazy as _
 from djblets.db.fields import CounterField, JSONField
 from djblets.db.managers import ConcurrencyManager
 
+from reviewboard.admin.read_only import is_site_read_only_for
+
 
 @python_2_unicode_compatible
 class BaseComment(models.Model):
@@ -122,8 +124,9 @@ class BaseComment(models.Model):
         if not (user and user.is_authenticated()):
             return False
 
-        return (self.get_review_request().is_mutable_by(user) or
-                user == self.get_review().user)
+        return ((self.get_review_request().is_mutable_by(user) or
+                 user == self.get_review().user) and
+                not is_site_read_only_for(user))
 
     def save(self, **kwargs):
         from reviewboard.reviews.models.review_request import ReviewRequest
diff --git a/reviewboard/reviews/models/review_request.py b/reviewboard/reviews/models/review_request.py
index a1ed3adc86639db35f81ebe7c4c7805a8f8ab5a3..62d1d23d0d7cf85f7ca52c3d87f32de3d2d9378e 100644
--- a/reviewboard/reviews/models/review_request.py
+++ b/reviewboard/reviews/models/review_request.py
@@ -13,6 +13,7 @@ from djblets.cache.backend import make_cache_key
 from djblets.db.fields import CounterField, ModificationTimestampField
 from djblets.db.query import get_object_or_none
 
+from reviewboard.admin.read_only import is_site_read_only_for
 from reviewboard.attachments.models import (FileAttachment,
                                             FileAttachmentHistory)
 from reviewboard.changedescs.models import ChangeDescription
@@ -498,14 +499,16 @@ class ReviewRequest(BaseReviewRequestDetails):
 
     def is_mutable_by(self, user):
         """Returns whether the user can modify this review request."""
-        return (self.submitter == user or
-                user.has_perm('reviews.can_edit_reviewrequest',
-                              self.local_site))
+        return (not is_site_read_only_for(user) and
+                (self.submitter == user or
+                 user.has_perm('reviews.can_edit_reviewrequest',
+                               self.local_site)))
 
     def is_status_mutable_by(self, user):
         """Returns whether the user can modify this review request's status."""
-        return (self.submitter == user or
-                user.has_perm('reviews.can_change_status', self.local_site))
+        return (not is_site_read_only_for(user) and
+                (self.submitter == user or
+                 user.has_perm('reviews.can_change_status', self.local_site)))
 
     def is_deletable_by(self, user):
         """Returns whether the user can delete this review request."""
diff --git a/reviewboard/reviews/templatetags/reviewtags.py b/reviewboard/reviews/templatetags/reviewtags.py
index 60899c03b04a0c83fd2e1660242a1a3922012103..c2d54ed7b27d15a5f34904314b66c0e28b80381a 100644
--- a/reviewboard/reviews/templatetags/reviewtags.py
+++ b/reviewboard/reviews/templatetags/reviewtags.py
@@ -18,6 +18,7 @@ from djblets.util.humanize import humanize_list
 
 from reviewboard.accounts.models import Profile, Trophy
 from reviewboard.accounts.trophies import UnknownTrophy
+from reviewboard.admin.read_only import is_site_read_only_for
 from reviewboard.diffviewer.diffutils import get_displayed_diff_line_ranges
 from reviewboard.reviews.actions import get_top_level_actions
 from reviewboard.reviews.fields import (get_review_request_fieldset,
@@ -230,6 +231,8 @@ def reply_section(context, review, comment, context_type, context_id,
     is responsible for invoking :tag:`reply_list` and as such passes these
     variables through. It does not make use of them itself.
     """
+    user = context.get('user', None)
+
     if comment != "":
         if type(comment) is ScreenshotComment:
             context_id += 's'
@@ -245,8 +248,9 @@ def reply_section(context, review, comment, context_type, context_id,
         'comment': comment,
         'context_type': context_type,
         'context_id': context_id,
-        'user': context.get('user', None),
+        'user': user,
         'local_site_name': context.get('local_site_name'),
+        'read_only': is_site_read_only_for(user),
         'reply_to_is_empty': reply_to_text == '',
         'request': context['request'],
     }
diff --git a/reviewboard/reviews/views.py b/reviewboard/reviews/views.py
index 4ab6fb18d40bb411ff3ff255d3c758abf51f1c77..402def127740f1ac76daa4cc1f9a694a948a4155 100644
--- a/reviewboard/reviews/views.py
+++ b/reviewboard/reviews/views.py
@@ -8,6 +8,7 @@ from django.contrib.auth.decorators import login_required
 from django.contrib.auth.models import User
 from django.contrib.sites.models import Site
 from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
+from django.core.urlresolvers import reverse
 from django.db.models import Q
 from django.http import (Http404,
                          HttpResponse,
@@ -34,6 +35,7 @@ from djblets.util.http import (encode_etag, set_last_modified,
 from reviewboard.accounts.decorators import (check_login_required,
                                              valid_prefs_required)
 from reviewboard.accounts.models import ReviewRequestVisit, Profile
+from reviewboard.admin.read_only import is_site_read_only_for
 from reviewboard.attachments.models import (FileAttachment,
                                             get_latest_file_attachments)
 from reviewboard.avatars import avatar_services
@@ -286,6 +288,9 @@ def new_review_request(request,
     valid_repos = []
     repos = Repository.objects.accessible(request.user, local_site=local_site)
 
+    if is_site_read_only_for(request.user):
+        return HttpResponseRedirect(reverse('dashboard'))
+
     if local_site:
         local_site_name = local_site.name
     else:
@@ -387,11 +392,12 @@ def review_detail(request,
 
     # Find out if we can bail early. Generate an ETag for this.
     etag = encode_etag(
-       '%s:%s:%s:%s:%s:%s:%s:%s:%s:%s' %
+       '%s:%s:%s:%s:%s:%s:%s:%s:%s:%s:%s' %
        (request.user, last_activity_time, draft_timestamp,
         data.latest_review_timestamp,
         review_request.last_review_activity_timestamp,
         is_rich_text_default_for_user(request.user),
+        is_site_read_only_for(request.user),
         [r.pk for r in blocks],
         starred, visited and visited.visibility, settings.AJAX_SERIAL))
 
diff --git a/reviewboard/settings.py b/reviewboard/settings.py
index c60a2208827ee6a2dcb1f1a038ec331735c20cd6..df945914cec4df19c245b7ec4180a5f06190f2bb 100644
--- a/reviewboard/settings.py
+++ b/reviewboard/settings.py
@@ -127,6 +127,7 @@ TEMPLATE_CONTEXT_PROCESSORS = (
     'djblets.urls.context_processors.site_root',
     'reviewboard.accounts.context_processors.auth_backends',
     'reviewboard.accounts.context_processors.profile',
+    'reviewboard.admin.context_processors.read_only',
     'reviewboard.admin.context_processors.version',
     'reviewboard.site.context_processors.localsite',
 )
diff --git a/reviewboard/static/rb/js/models/commentEditorModel.js b/reviewboard/static/rb/js/models/commentEditorModel.js
index 2138c1834a61e28d6034c776e9dc8d6aecc11274..a301c983897bd33d8c629f04cfcc2b457e38c6c1 100644
--- a/reviewboard/static/rb/js/models/commentEditorModel.js
+++ b/reviewboard/static/rb/js/models/commentEditorModel.js
@@ -263,7 +263,8 @@ RB.CommentEditor = Backbone.Model.extend(_.defaults({
 
         this.set('canEdit',
                  userSession.get('authenticated') &&
-                 !reviewRequest.get('hasDraft'));
+                 !reviewRequest.get('hasDraft') &&
+                 !userSession.get('readOnly'));
     },
 
     /*
diff --git a/reviewboard/static/rb/js/models/reviewRequestEditorModel.js b/reviewboard/static/rb/js/models/reviewRequestEditorModel.js
index abfbc4fbd32d6d0504170893c30d2c61d5a566fa..2e2b05127370639ece6ab73a389cbfdc32ac1779 100644
--- a/reviewboard/static/rb/js/models/reviewRequestEditorModel.js
+++ b/reviewboard/static/rb/js/models/reviewRequestEditorModel.js
@@ -173,6 +173,14 @@ RB.ReviewRequestEditor = Backbone.Model.extend({
 
                 if (_.isFunction(options.error)) {
                     rsp = xhr.errorPayload;
+
+                    if (rsp.fields === undefined) {
+                        options.error.call(context, {
+                            errorText: xhr.errorText,
+                        });
+                        return;
+                    }
+
                     fieldValue = rsp.fields[jsonFieldName];
                     fieldValueLen = fieldValue.length;
 
diff --git a/reviewboard/static/rb/js/models/userSessionModel.js b/reviewboard/static/rb/js/models/userSessionModel.js
index 50dc705326751032a0def2eeb9d3cd5cf3f21fe3..6f025154ba48b0349fadea3d9dc98cdef18c2aea 100644
--- a/reviewboard/static/rb/js/models/userSessionModel.js
+++ b/reviewboard/static/rb/js/models/userSessionModel.js
@@ -125,6 +125,7 @@ RB.UserSession = Backbone.Model.extend({
         diffsShowExtraWhitespace: false,
         fullName: null,
         loginURL: null,
+        readOnly: false,
         username: null,
         userPageURL: null,
         sessionURL: null,
diff --git a/reviewboard/static/rb/js/pages/views/dashboardView.js b/reviewboard/static/rb/js/pages/views/dashboardView.js
index 8660ff3498b78ff5a2320eb8ccccc0748018c9d8..dd042df772e797de863561a6398aa1d4637bb974 100644
--- a/reviewboard/static/rb/js/pages/views/dashboardView.js
+++ b/reviewboard/static/rb/js/pages/views/dashboardView.js
@@ -13,17 +13,19 @@ var DashboardActionsView = Backbone.View.extend({
         '<div class="datagrid-actions-content">',
         ' <p class="count"></p>',
         ' <ul>',
+        '<% if (!read_only) { %>',
         '  <li><a class="discard" href="#"><%= close_discarded_text %></a></li>',
         '  <li><a class="submit" href="#"><%= close_submitted_text %></a></li>',
         '  <li>&nbsp;</li>',
         '  <li><a class="archive" href="#"><%= archive_text %></a></li>',
-        '<% if (show_archived) { %>',
+        '<%  if (show_archived) { %>',
         '  <li><a class="unarchive" href="#"><%= unarchive_text %></a></li>',
-        '<% } %>',
+        '<%  } %>',
         '  <li>&nbsp;</li>',
         '  <li><a class="mute" href="#"><%= mute_text %></a></li>',
-        '<% if (show_archived) { %>',
+        '<%  if (show_archived) { %>',
         '  <li><a class="unmute" href="#"><%= unmute_text %></a></li>',
+        '<%  } %>',
         '<% } %>',
         ' </ul>',
         '</div>'
@@ -59,6 +61,7 @@ var DashboardActionsView = Backbone.View.extend({
                 close_submitted_text: gettext('<b>Close</b> Submitted'),
                 archive_text: gettext('<b>Archive</b>'),
                 mute_text: gettext('<b>Mute</b>'),
+                read_only: RB.UserSession.instance.get('readOnly', false),
                 unarchive_text: gettext('<b>Unarchive</b>'),
                 unmute_text: gettext('<b>Unmute</b>'),
                 show_archived: show_archived
diff --git a/reviewboard/static/rb/js/utils/apiUtils.es6.js b/reviewboard/static/rb/js/utils/apiUtils.es6.js
index 1e01282128b633bd49bf29aca3d432d1bc99700f..f231cca6c947549991fee43ada1173a608838e48 100644
--- a/reviewboard/static/rb/js/utils/apiUtils.es6.js
+++ b/reviewboard/static/rb/js/utils/apiUtils.es6.js
@@ -197,6 +197,10 @@ RB.apiCall = function(options) {
 
     options.type = options.type || 'POST';
 
+    if (options.type !== 'GET' && RB.UserSession.instance.get('readOnly', false)) {
+        return;
+    }
+
     // We allow disabling the function queue for the sake of unit tests.
     if (RB.ajaxOptions.enableQueuing && options.type !== 'GET') {
         $.funcQueue('rbapicall').add(doCall);
diff --git a/reviewboard/static/rb/js/views/commentDialogView.js b/reviewboard/static/rb/js/views/commentDialogView.js
index 5713f8ef035a5f0f2d95896e2dbb0154cc9a9b02..b15f00ac0e33d5fea9008cb0fe47721e65068e11 100644
--- a/reviewboard/static/rb/js/views/commentDialogView.js
+++ b/reviewboard/static/rb/js/views/commentDialogView.js
@@ -120,6 +120,8 @@ RB.CommentDialogView = Backbone.View.extend({
         ' </p>',
         '<% } else if (hasDraft) { %>',
         ' <p class="draft-warning"><%= draftWarning %></p>',
+        '<% } else if (readOnly) { %>',
+        ' <p class="read-only-text"><%= readOnlyText %></p>',
         '<% } %>',
         ' <div class="comment-dlg-body">',
         '  <div class="comment-text-field"></div>',
@@ -170,6 +172,7 @@ RB.CommentDialogView = Backbone.View.extend({
             .html(this.template({
                 authenticated: userSession.get('authenticated'),
                 hasDraft: reviewRequest.get('hasDraft'),
+                readOnly: userSession.get('readOnly'),
                 markdownDocsURL: MANUAL_URL + 'users/markdown/',
                 markdownText: gettext('Markdown'),
                 otherReviewsText: gettext('Other reviews'),
@@ -181,6 +184,7 @@ RB.CommentDialogView = Backbone.View.extend({
                     [reviewRequest.get('reviewURL')]),
                 openAnIssueText: gettext('Open an <u>I</u>ssue'),
                 enableMarkdownText: gettext('Enable <u>M</u>arkdown'),
+                readOnlyText: gettext('Review Board is currently in read-only mode.'),
                 saveButton: gettext('Save'),
                 cancelButton: gettext('Cancel'),
                 deleteButton: gettext('Delete'),
diff --git a/reviewboard/static/rb/js/views/reviewRequestEditorView.js b/reviewboard/static/rb/js/views/reviewRequestEditorView.js
index d6f6edfd21d6e513b93a36a7e9e97aadc4cefd72..c7e6fd171ce7b507a8c801fcb34325a9d61f2008 100644
--- a/reviewboard/static/rb/js/views/reviewRequestEditorView.js
+++ b/reviewboard/static/rb/js/views/reviewRequestEditorView.js
@@ -3,7 +3,8 @@
 
 var BannerView,
     ClosedBannerView,
-    DraftBannerView;
+    DraftBannerView,
+    readOnly = RB.UserSession.instance.get('readOnly', false);
 
 
 /*
@@ -17,7 +18,8 @@ BannerView = Backbone.View.extend({
     title: '',
     subtitle: '',
     actions: [],
-    showChangesField: true,
+    showActions: !readOnly,
+    showChangesField: !readOnly,
     describeText: '',
     fieldOptions: {},
     descriptionFieldID: 'changedescription',
@@ -32,10 +34,12 @@ BannerView = Backbone.View.extend({
         '<p><%- subtitle %></p>',
         '<% } %>',
         '<span class="banner-actions">',
-        '<% _.each(actions, function(action) { %>',
+        '<% if (showActions) { %>',
+        '<%  _.each(actions, function(action) { %>',
         ' <input type="button" id="<%= action.id %>" ',
         '        value="<%- action.label %>" />',
-        '<% }); %>',
+        '<%  }); %>',
+        '<% } %>',
         '<% if (showSendEmail) { %>',
         ' <label>',
         '  <input type="checkbox" class="send-email" checked />',
@@ -76,6 +80,7 @@ BannerView = Backbone.View.extend({
         }, this.fieldOptions));
 
         this.$buttons = null;
+        console.log("asdf" + readOnly);
     },
 
     /*
@@ -91,6 +96,7 @@ BannerView = Backbone.View.extend({
                 title: this.title,
                 subtitle: this.subtitle,
                 actions: this.actions,
+                showActions: this.showActions,
                 showChangesField: this.showChangesField,
                 describeText: this.describeText,
                 descriptionFieldHTML: this.descriptionFieldHTML,
@@ -1739,6 +1745,10 @@ RB.ReviewRequestEditorView = Backbone.View.extend({
 
         this.$('#hide-review-request-link')
             .html('<span class="rb-icon ' + iconClass + '"></span>');
+
+        if (readOnly) {
+            this.$('#hide-review-request-menu').hide();
+        }
     },
 
     /*
diff --git a/reviewboard/static/rb/js/views/starManagerView.js b/reviewboard/static/rb/js/views/starManagerView.js
index b33c13cd5b4761f9dbde320fd2b56a638cd6a67f..8fec4f68cf1a1e87c6f6a30f09d8b93cbc6da6ba 100644
--- a/reviewboard/static/rb/js/views/starManagerView.js
+++ b/reviewboard/static/rb/js/views/starManagerView.js
@@ -132,6 +132,10 @@ RB.StarManagerView = Backbone.View.extend({
         e.preventDefault();
         e.stopPropagation();
 
+        if (RB.UserSession.instance.get('readOnly')) {
+            return;
+        }
+
         obj.setStarred(objStarred);
         starred[objID] = objStarred;
 
diff --git a/reviewboard/templates/base.html b/reviewboard/templates/base.html
index bd7d03b9b0e0785729e569f7fac9e326a7ba4579..5cbbe53288c6cab2d45d381863b10edf12372454 100644
--- a/reviewboard/templates/base.html
+++ b/reviewboard/templates/base.html
@@ -116,6 +116,9 @@
         defaultUseRichText: {{user_profile.should_use_rich_text|yesno:"true,false"}},
 {%  endif %}
         fullName: "{{request.user|user_displayname|escapejs}}",
+{%  if is_read_only %}
+        readOnly: true,
+{%  endif %}
 {% if siteconfig_settings.avatars_enabled %}
         avatarURLs: {
             32: {% avatar_urls request.user 32 %}
diff --git a/reviewboard/templates/base/_nav_support_menu.html b/reviewboard/templates/base/_nav_support_menu.html
index 8be58800700d856cfdadb29c92a373143c181c2b..7812ca68e74608e634cc52b736aff3c278086018 100644
--- a/reviewboard/templates/base/_nav_support_menu.html
+++ b/reviewboard/templates/base/_nav_support_menu.html
@@ -18,7 +18,9 @@
    </a>
 {%  endspaceless %}
    <ul>
+{%  if not is_read_only %}
     <li><a href="{% url 'user-preferences' %}">{% trans "My account" %}</a></li>
+{%  endif %}
 {%  if request.user.is_staff %}
     <li><a href="{% url 'reviewboard.admin.views.dashboard' %}">{% trans "Admin" %}</a></li>
 {%  endif %}
diff --git a/reviewboard/templates/base/navbar.html b/reviewboard/templates/base/navbar.html
index 09a1fdb017622c39f0998d96f8e0f19eacaf1814..879665654f2d15e0b519af85f864431418a7d4d3 100644
--- a/reviewboard/templates/base/navbar.html
+++ b/reviewboard/templates/base/navbar.html
@@ -4,7 +4,9 @@
 {% if request.user.is_authenticated or not siteconfig_settings.auth_require_sitewide_login %}
 {%  if request.user.is_authenticated %}
  <li><a href="{% url 'dashboard' %}">{% trans "My Dashboard" %}</a></li>
+{%   if not is_read_only %}
  <li><a href="{% url 'new-review-request' %}">{% trans "New Review Request" %}</a></li>
+{%   endif %}
 {%  endif %}
  <li><a href="{% url 'all-review-requests' %}">{% trans "All Review Requests" %}</a></li>
  <li><a href="{% url 'all-users' %}">{% trans "Users" %}</a></li>
diff --git a/reviewboard/templates/reviews/review_reply_section.html b/reviewboard/templates/reviews/review_reply_section.html
index 44c5b91e0f1721a8476e396326b6189bb43723b9..839af01c9af201e8a32ae4f4e87a03128be81312 100644
--- a/reviewboard/templates/reviews/review_reply_section.html
+++ b/reviewboard/templates/reviews/review_reply_section.html
@@ -7,7 +7,7 @@
  <ol class="reply-comments">
   {% reply_list review comment context_type context_id %}
  </ol>
-{% if user.is_authenticated %}
+{% if user.is_authenticated and not read_only %}
  <ul class="controls">
   <li>
    <a href="#" class="add_comment_link">
diff --git a/reviewboard/webapi/base.py b/reviewboard/webapi/base.py
index ed8a93f12b7e92c5a35a6e05350240c835b6e19f..efd16510a1aaf3569e72dae67cb0d91f9889581b 100644
--- a/reviewboard/webapi/base.py
+++ b/reviewboard/webapi/base.py
@@ -7,7 +7,6 @@ from django.utils.encoding import force_unicode
 from django.utils.six.moves.urllib.parse import quote as urllib_quote
 from django.utils.translation import ugettext_lazy as _
 from djblets.registries.errors import RegistrationError
-from djblets.siteconfig.models import SiteConfiguration
 from djblets.util.decorators import augment_method_from
 from djblets.webapi.decorators import (SPECIAL_PARAMS,
                                        webapi_login_required,
@@ -18,6 +17,7 @@ from djblets.webapi.resources.base import \
 from djblets.webapi.resources.mixins.api_tokens import ResourceAPITokenMixin
 from djblets.webapi.resources.mixins.queries import APIQueryUtilsMixin
 
+from reviewboard.admin.read_only import is_site_read_only_for
 from reviewboard.registries.registry import Registry
 from reviewboard.site.models import LocalSite
 from reviewboard.site.urlresolvers import local_site_reverse
@@ -246,10 +246,7 @@ class WebAPIResource(ResourceAPITokenMixin, APIQueryUtilsMixin,
             if not feature.is_enabled(request=request):
                 return PERMISSION_DENIED
 
-        siteconfig = SiteConfiguration.objects.get_current()
-
-        if (not request.user.is_superuser and
-            siteconfig.get('site_read_only') and
+        if (is_site_read_only_for(request.user) and
             request.method in ('POST', 'PUT', 'DELETE')):
             return READ_ONLY_ERROR
 
