diff --git a/opentech/apply/activity/messaging.py b/opentech/apply/activity/messaging.py
index a067900c00be2986f45590e369374f1a173747cd..c8e6c41b1d5c79129c119f0670e628e5d479722b 100644
--- a/opentech/apply/activity/messaging.py
+++ b/opentech/apply/activity/messaging.py
@@ -44,6 +44,7 @@ neat_related = {
     MESSAGES.UPDATE_LEAD: 'old_lead',
     MESSAGES.NEW_REVIEW: 'review',
     MESSAGES.TRANSITION: 'old_phase',
+    MESSAGES.BATCH_TRANSITION: 'transitions',
     MESSAGES.APPLICANT_EDIT: 'revision',
     MESSAGES.EDIT: 'revision',
     MESSAGES.COMMENT: 'comment',
@@ -52,6 +53,14 @@ neat_related = {
 }
 
 
+def is_transition(message_type):
+    return message_type in [MESSAGES.TRANSITION, MESSAGES.BATCH_TRANSITION]
+
+
+def is_ready_for_review(message_type):
+    return message_type in [MESSAGES.READY_FOR_REVIEW, MESSAGES.BATCH_READY_FOR_REVIEW]
+
+
 class AdapterBase:
     messages = {}
     always_send = False
@@ -183,6 +192,7 @@ class ActivityAdapter(AdapterBase):
     always_send = True
     messages = {
         MESSAGES.TRANSITION: 'handle_transition',
+        MESSAGES.BATCH_TRANSITION: 'handle_batch_transition',
         MESSAGES.NEW_SUBMISSION: 'Submitted {submission.title} for {submission.page.title}',
         MESSAGES.EDIT: 'Edited',
         MESSAGES.APPLICANT_EDIT: 'Edited',
@@ -200,11 +210,13 @@ class ActivityAdapter(AdapterBase):
     def recipients(self, message_type, **kwargs):
         return [None]
 
-    def extra_kwargs(self, message_type, submission, **kwargs):
+    def extra_kwargs(self, message_type, submission, submissions, **kwargs):
         from .models import INTERNAL
         if message_type in [MESSAGES.OPENED_SEALED, MESSAGES.REVIEWERS_UPDATED, MESSAGES.SCREENING]:
             return {'visibility': INTERNAL}
-        if message_type == MESSAGES.TRANSITION and not submission.phase.permissions.can_view(submission.user):
+
+        submission = submission or submissions[0]
+        if is_transition(message_type) and not submission.phase.permissions.can_view(submission.user):
             # User's shouldn't see status activity changes for stages that aren't visible to the them
             return {'visibility': INTERNAL}
         return {}
@@ -251,6 +263,12 @@ class ActivityAdapter(AdapterBase):
 
         return staff_message
 
+    def handle_batch_transition(self, transitions, submissions, **kwargs):
+        kwargs.pop('submission')
+        for submission in submissions:
+            old_phase = transitions[submission.id]
+            return self.handle_transition(old_phase=old_phase, submission=submission, **kwargs)
+
     def send_message(self, message, user, submission, submissions, **kwargs):
         from .models import Activity, PUBLIC
         visibility = kwargs.get('visibility', PUBLIC)
@@ -289,6 +307,7 @@ class SlackAdapter(AdapterBase):
         MESSAGES.REVIEWERS_UPDATED: 'reviewers_updated',
         MESSAGES.BATCH_REVIEWERS_UPDATED: 'handle_batch_reviewers',
         MESSAGES.TRANSITION: '{user} has updated the status of <{link}|{submission.title}>: {old_phase.display_name} → {submission.phase}',
+        MESSAGES.BATCH_TRANSITION: 'handle_batch_transition',
         MESSAGES.DETERMINATION_OUTCOME: 'A determination for <{link}|{submission.title}> was sent by email. Outcome: {determination.clean_outcome}',
         MESSAGES.PROPOSAL_SUBMITTED: 'A proposal has been submitted for review: <{link}|{submission.title}>',
         MESSAGES.INVITED_TO_PROPOSAL: '<{link}|{submission.title}> by {submission.user} has been invited to submit a proposal',
@@ -296,6 +315,8 @@ class SlackAdapter(AdapterBase):
         MESSAGES.READY_FOR_REVIEW: 'notify_reviewers',
         MESSAGES.OPENED_SEALED: '{user} has opened the sealed submission: <{link}|{submission.title}>',
         MESSAGES.REVIEW_OPINION: '{user} {opinion.opinion_display}s with {opinion.review.author}''s review of {submission}',
+        MESSAGES.BATCH_READY_FOR_REVIEW: 'batch_notify_reviewers',
+
     }
 
     def __init__(self):
@@ -303,6 +324,12 @@ class SlackAdapter(AdapterBase):
         self.destination = settings.SLACK_DESTINATION_URL
         self.target_room = settings.SLACK_DESTINATION_ROOM
 
+    def slack_links(self, links, submissions):
+        return ', '.join(
+            f'<{links[submission.id]}|{submission.title}>'
+            for submission in submissions
+        )
+
     def extra_kwargs(self, message_type, **kwargs):
         submission = kwargs['submission']
         submissions = kwargs['submissions']
@@ -344,10 +371,7 @@ class SlackAdapter(AdapterBase):
         return ' '.join(message)
 
     def handle_batch_reviewers(self, submissions, links, user, added, **kwargs):
-        submissions_text = ', '.join(
-            f'<{links[submission.id]}|{submission.title}>'
-            for submission in submissions
-        )
+        submissions_text = self.slack_links(links, submissions)
         reviewers_text = ', '.join([str(user) for user in added])
         return (
             '{user} has batch added {reviewers_text} as reviewers on: {submissions_text}'.format(
@@ -357,7 +381,23 @@ class SlackAdapter(AdapterBase):
             )
         )
 
-    def notify_reviewers(self, submission, **kwargs):
+    def handle_batch_transition(self, user, links, submissions, transitions, **kwargs):
+        submissions_text = [
+            ': '.join([
+                self.slack_links(links, [submission]),
+                f'{transitions[submission.id].display_name} → {submission.phase}',
+            ])
+            for submission in submissions
+        ]
+        submissions_links = ','.join(submissions_text)
+        return (
+            '{user} has transitioned the following submissions: {submissions_links}'.format(
+                user=user,
+                submissions_links=submissions_links,
+            )
+        )
+
+    def notify_reviewers(self, submission, link, **kwargs):
         reviewers_to_notify = []
         for reviewer in submission.reviewers.all():
             if submission.phase.permissions.can_review(reviewer):
@@ -369,12 +409,20 @@ class SlackAdapter(AdapterBase):
 
         return (
             '<{link}|{submission.title}> is ready for review. The following are assigned as reviewers: {reviewers}'.format(
+                link=link,
                 reviewers=reviewers,
                 submission=submission,
-                **kwargs
             )
         )
 
+    def batch_notify_reviewers(self, submissions, links, **kwargs):
+        kwargs.pop('submission')
+        kwargs.pop('link')
+        return '. '.join(
+            self.notify_reviewers(submission, link=links[submission.id], **kwargs)
+            for submission in submissions
+        )
+
     def slack_id(self, user):
         if user.slack:
             return f'<{user.slack}>'
@@ -432,14 +480,16 @@ class EmailAdapter(AdapterBase):
         MESSAGES.COMMENT: 'notify_comment',
         MESSAGES.EDIT: 'messages/email/edit.html',
         MESSAGES.TRANSITION: 'messages/email/transition.html',
+        MESSAGES.BATCH_TRANSITION: 'handle_batch_transition',
         MESSAGES.DETERMINATION_OUTCOME: 'messages/email/determination.html',
         MESSAGES.INVITED_TO_PROPOSAL: 'messages/email/invited_to_proposal.html',
+        MESSAGES.BATCH_READY_FOR_REVIEW: 'messages/email/batch_ready_to_review.html',
         MESSAGES.READY_FOR_REVIEW: 'messages/email/ready_to_review.html',
     }
 
     def get_subject(self, message_type, submission):
         if submission:
-            if message_type == MESSAGES.READY_FOR_REVIEW:
+            if is_ready_for_review(message_type):
                 subject = 'Application ready to review: {submission.title}'.format(submission=submission)
             else:
                 subject = submission.page.specific.subject or 'Your application to Open Technology Fund: {submission.title}'.format(submission=submission)
@@ -450,6 +500,17 @@ class EmailAdapter(AdapterBase):
             'subject': self.get_subject(message_type, submission),
         }
 
+    def handle_batch_transition(self, transitions, submissions, **kwargs):
+        kwargs.pop('submission')
+        for submission in submissions:
+            old_phase = transitions[submission.id]
+            return self.render_message(
+                'messages/email/transition.html',
+                submission=submission,
+                old_phase=old_phase,
+                **kwargs
+            )
+
     def notify_comment(self, **kwargs):
         comment = kwargs['comment']
         submission = kwargs['submission']
@@ -457,15 +518,32 @@ class EmailAdapter(AdapterBase):
             return self.render_message('messages/email/comment.html', **kwargs)
 
     def recipients(self, message_type, submission, **kwargs):
-        if message_type == MESSAGES.READY_FOR_REVIEW:
+        if is_ready_for_review(message_type):
             return self.reviewers(submission)
 
-        if message_type == MESSAGES.TRANSITION:
+        if is_transition(message_type):
             # Only notify the applicant if the new phase can be seen within the workflow
             if not submission.phase.permissions.can_view(submission.user):
                 return []
         return [submission.user.email]
 
+    def batch_recipients(self, message_type, submissions, **kwargs):
+        if not is_ready_for_review(message_type):
+            return super().batch_recipients(message_type, submissions, **kwargs)
+
+        reviewers_to_message = defaultdict(list)
+        for submission in submissions:
+            reviewers = self.reviewers(submission)
+            for reviewer in reviewers:
+                reviewers_to_message[reviewer].append(submission)
+
+        return [
+            {
+                'recipients': [reviewer],
+                'submissions': submissions,
+            } for reviewer, submissions in reviewers_to_message.items()
+        ]
+
     def reviewers(self, submission):
         return [
             reviewer.email
@@ -495,6 +573,7 @@ class DjangoMessagesAdapter(AdapterBase):
 
     messages = {
         MESSAGES.BATCH_REVIEWERS_UPDATED: 'batch_reviewers_updated',
+        MESSAGES.BATCH_TRANSITION: 'batch_transition',
     }
 
     def batch_reviewers_updated(self, added, submissions, **kwargs):
@@ -505,6 +584,19 @@ class DjangoMessagesAdapter(AdapterBase):
             ', '.join(['"{}"'.format(submission.title) for submission in submissions])
         )
 
+    def batch_transition(self, submissions, transitions, **kwargs):
+        base_message = 'Successfully updated:'
+        transition = '{submission} [{old_display} → {new_display}].'
+        transition_messages = [
+            transition.format(
+                submission=submission.title,
+                old_display=transitions[submission.id],
+                new_display=submission.phase,
+            ) for submission in submissions
+        ]
+        messages = [base_message, *transition_messages]
+        return ' '.join(messages)
+
     def recipients(self, *args, **kwargs):
         return [None]
 
diff --git a/opentech/apply/activity/migrations/0015_add_batch_transition.py b/opentech/apply/activity/migrations/0015_add_batch_transition.py
new file mode 100644
index 0000000000000000000000000000000000000000..539c08b218900e4610fe896aa84dcab174319b5c
--- /dev/null
+++ b/opentech/apply/activity/migrations/0015_add_batch_transition.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.9 on 2019-02-16 22:01
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('activity', '0014_add_batch_reviewer_message'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='event',
+            name='type',
+            field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('READY_FOR_REVIEW', 'Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission')], max_length=50),
+        ),
+    ]
diff --git a/opentech/apply/activity/migrations/0016_add_batch_ready.py b/opentech/apply/activity/migrations/0016_add_batch_ready.py
new file mode 100644
index 0000000000000000000000000000000000000000..558e135df3df7c0eec8526e767c9ebf64f8854d6
--- /dev/null
+++ b/opentech/apply/activity/migrations/0016_add_batch_ready.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.9 on 2019-02-17 09:10
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('activity', '0015_add_batch_transition'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='event',
+            name='type',
+            field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission')], max_length=50),
+        ),
+    ]
diff --git a/opentech/apply/activity/options.py b/opentech/apply/activity/options.py
index f5f70f4b2bae6e0a75895d73407aebfe389a4f27..7041ce9e343aa4bbf7782417d2c4786e5cbc9e7e 100644
--- a/opentech/apply/activity/options.py
+++ b/opentech/apply/activity/options.py
@@ -8,11 +8,13 @@ class MESSAGES(Enum):
     NEW_SUBMISSION = 'New Submission'
     SCREENING = 'Screening'
     TRANSITION = 'Transition'
+    BATCH_TRANSITION = 'Batch Transition'
     DETERMINATION_OUTCOME = 'Determination Outcome'
     INVITED_TO_PROPOSAL = 'Invited To Proposal'
     REVIEWERS_UPDATED = 'Reviewers Updated'
     BATCH_REVIEWERS_UPDATED = 'Batch Reviewers Updated'
     READY_FOR_REVIEW = 'Ready For Review'
+    BATCH_READY_FOR_REVIEW = 'Batch Ready For Review'
     NEW_REVIEW = 'New Review'
     COMMENT = 'Comment'
     PROPOSAL_SUBMITTED = 'Proposal Submitted'
diff --git a/opentech/apply/activity/templates/messages/email/batch_ready_to_review.html b/opentech/apply/activity/templates/messages/email/batch_ready_to_review.html
new file mode 100644
index 0000000000000000000000000000000000000000..25d2aa7a6ab25a1c1c7e99841e5af8449ffde663
--- /dev/null
+++ b/opentech/apply/activity/templates/messages/email/batch_ready_to_review.html
@@ -0,0 +1,12 @@
+{% extends "messages/email/base.html" %}
+{% block salutation %}Dear Reviewer,{% endblock %}
+
+{% block content %}
+New proposals have been added to your review list.
+{% for submission in submissions %}
+
+Title: {{ submission.title }}
+Link: {{ request.scheme }}://{{ request.get_host }}{{ submission.get_absolute_url }}
+{% endfor %}
+
+{% endblock %}
diff --git a/opentech/apply/activity/templates/messages/email/transition.html b/opentech/apply/activity/templates/messages/email/transition.html
index a10355e55628dcee05f6bdf21982405e3105f742..522c08cf89bd1af54aab399d6313418cf887ea0c 100644
--- a/opentech/apply/activity/templates/messages/email/transition.html
+++ b/opentech/apply/activity/templates/messages/email/transition.html
@@ -1,3 +1,3 @@
 {% extends "messages/email/applicant_base.html" %}
 
-{% block content %}Your application has been progressed from {{ old_phase.display_name }} to {{ submission.phase }}.{% endblock %}
+{% block content %}Your application has been progressed from {{ old_phase.public_name }} to {{ submission.phase.public_name }}.{% endblock %}
diff --git a/opentech/apply/activity/tests/test_messaging.py b/opentech/apply/activity/tests/test_messaging.py
index e2c8b6318234ba997d35a0d04d96a0b259e23e23..c7e1d854021a069fa7ae5264592e0979571c5f5e 100644
--- a/opentech/apply/activity/tests/test_messaging.py
+++ b/opentech/apply/activity/tests/test_messaging.py
@@ -247,13 +247,13 @@ class TestActivityAdapter(TestCase):
 
     def test_internal_transition_kwarg_for_invisible_transition(self):
         submission = ApplicationSubmissionFactory(status='post_review_discussion')
-        kwargs = self.adapter.extra_kwargs(MESSAGES.TRANSITION, submission=submission)
+        kwargs = self.adapter.extra_kwargs(MESSAGES.TRANSITION, submission=submission, submissions=None)
 
         self.assertEqual(kwargs['visibility'], INTERNAL)
 
     def test_public_transition_kwargs(self):
         submission = ApplicationSubmissionFactory()
-        kwargs = self.adapter.extra_kwargs(MESSAGES.TRANSITION, submission=submission)
+        kwargs = self.adapter.extra_kwargs(MESSAGES.TRANSITION, submission=submission, submissions=None)
 
         self.assertNotIn('visibility', kwargs)
 
diff --git a/opentech/apply/activity/views.py b/opentech/apply/activity/views.py
index 12600a8bd8033929a4513bcc34f4511fd1a92289..07ccbd32c0358a21384d9ac4eab48519af0808e9 100644
--- a/opentech/apply/activity/views.py
+++ b/opentech/apply/activity/views.py
@@ -71,6 +71,6 @@ class CommentFormView(DelegatedViewMixin, CreateView):
         return self.object.submission.get_absolute_url() + '#communications'
 
     @classmethod
-    def contribute_form(cls, submission, user):
+    def contribute_form(cls, instance, user):
         # We dont want to pass the submission as the instance
-        return super().contribute_form(None, user=user)
+        return super().contribute_form(instance=None, user=user)
diff --git a/opentech/apply/funds/forms.py b/opentech/apply/funds/forms.py
index 0a71b92e8892a8f0e27251e79c5abb814e7595d5..8c51547999823658ff3b4ec665c765c19c5976a9 100644
--- a/opentech/apply/funds/forms.py
+++ b/opentech/apply/funds/forms.py
@@ -6,6 +6,7 @@ from opentech.apply.users.models import User
 
 from .models import ApplicationSubmission, AssignedReviewers, ReviewerRole
 from .widgets import Select2MultiCheckboxesWidget, Select2IconWidget
+from .workflow import get_action_mapping
 
 
 class ProgressSubmissionForm(forms.ModelForm):
@@ -24,6 +25,29 @@ class ProgressSubmissionForm(forms.ModelForm):
         self.should_show = bool(choices)
 
 
+class BatchProgressSubmissionForm(forms.Form):
+    action = forms.ChoiceField(label='Take action')
+    submissions = forms.CharField(widget=forms.HiddenInput(attrs={'class': 'js-submissions-id'}))
+
+    def __init__(self, *args, round=None, **kwargs):
+        self.user = kwargs.pop('user')
+        super().__init__(*args, **kwargs)
+        workflow = round and round.workflow
+        self.action_mapping = get_action_mapping(workflow)
+        choices = [(action, detail['display']) for action, detail in self.action_mapping.items()]
+        self.fields['action'].choices = choices
+
+    def clean_submissions(self):
+        value = self.cleaned_data['submissions']
+        submission_ids = [int(submission) for submission in value.split(',')]
+        return ApplicationSubmission.objects.filter(id__in=submission_ids)
+
+    def clean_action(self):
+        value = self.cleaned_data['action']
+        action = self.action_mapping[value]['transitions']
+        return action
+
+
 class ScreeningSubmissionForm(forms.ModelForm):
 
     class Meta:
@@ -197,8 +221,12 @@ class BatchUpdateReviewersForm(forms.Form):
         queryset=User.objects.staff(),
         widget=Select2MultiCheckboxesWidget(attrs={'data-placeholder': 'Staff'}),
     )
-    submission_ids = forms.CharField(widget=forms.HiddenInput())
+    submissions = forms.CharField(widget=forms.HiddenInput(attrs={'class': 'js-submissions-id'}))
+
+    def __init__(self, *args, user=None, round=None, **kwargs):
+        super().__init__(*args, **kwargs)
 
-    def clean_submission_ids(self):
-        value = self.cleaned_data['submission_ids']
-        return [int(submission) for submission in value.split(',')]
+    def clean_submissions(self):
+        value = self.cleaned_data['submissions']
+        submission_ids = [int(submission) for submission in value.split(',')]
+        return ApplicationSubmission.objects.filter(id__in=submission_ids)
diff --git a/opentech/apply/funds/tables.py b/opentech/apply/funds/tables.py
index a515aa6b5cff5c9fa3341424eff3433f725e74d1..198373c993c2a89ee1e841ea721bfce45670887c 100644
--- a/opentech/apply/funds/tables.py
+++ b/opentech/apply/funds/tables.py
@@ -1,3 +1,4 @@
+import json
 import textwrap
 
 from django import forms
@@ -25,6 +26,12 @@ def make_row_class(record):
     return css_class
 
 
+def render_actions(table, record):
+    user = table.context['user']
+    actions = record.get_actions_for_user(user)
+    return json.dumps([slugify(action) for _, action in actions])
+
+
 def render_title(record):
     return textwrap.shorten(record.title, width=30, placeholder="...")
 
@@ -33,7 +40,7 @@ class SubmissionsTable(tables.Table):
     """Base table for listing submissions, do not include admin data to this table"""
     title = tables.LinkColumn('funds:submissions:detail', text=render_title, args=[A('pk')], orderable=True, attrs={'td': {'data-tooltip': lambda record: record.title, 'class': 'js-title'}})
     submit_time = tables.DateColumn(verbose_name="Submitted")
-    phase = tables.Column(verbose_name="Status", order_by=('status',))
+    phase = tables.Column(verbose_name="Status", order_by=('status',), attrs={'td': {'data-actions': render_actions, 'class': 'js-actions'}})
     stage = tables.Column(verbose_name="Type", order_by=('status',))
     fund = tables.Column(verbose_name="Fund", accessor='page')
     comments = tables.Column(accessor='comment_count', verbose_name="Comments")
diff --git a/opentech/apply/funds/templates/funds/includes/batch_progress_form.html b/opentech/apply/funds/templates/funds/includes/batch_progress_form.html
new file mode 100644
index 0000000000000000000000000000000000000000..20943a97776d577586d9301ee0d8d8d591a0516f
--- /dev/null
+++ b/opentech/apply/funds/templates/funds/includes/batch_progress_form.html
@@ -0,0 +1,9 @@
+<div class="modal modal--secondary" id="batch-progress">
+    <h4 class="modal__header-bar">Update Status</h4>
+    <div class="modal__list-item modal__list-item--meta" aria-live="polite">
+        <span class="js-batch-title-count"></span>
+        <a href="#" class="modal__hide-link js-toggle-batch-list">Show</a>
+    </div>
+    <div class="modal__list js-batch-titles is-closed" aria-live="polite"></div>
+    {% include 'funds/includes/delegated_form_base.html' with form=batch_progress_form value='Update'%}
+</div>
diff --git a/opentech/apply/funds/templates/funds/includes/table_filter_and_search.html b/opentech/apply/funds/templates/funds/includes/table_filter_and_search.html
index ba2f54d0b562e7ae0d35414fdbbda299eb7e45d8..2b4727e2b5128931ddd9b5b5621368b39901e32d 100644
--- a/opentech/apply/funds/templates/funds/includes/table_filter_and_search.html
+++ b/opentech/apply/funds/templates/funds/includes/table_filter_and_search.html
@@ -6,14 +6,14 @@
                 <h4 class="heading heading--normal heading--no-margin">{{ heading }}</h4>
             {% endif %}
 
+
             {% if use_batch_actions %}
                 <div class="actions-bar__inner actions-bar__inner--batch-actions">
                     <p class="actions-bar__total"><span class="js-total-actions">0</span> Selected</p>
-                    <form action="" class="js-batch-update-status">
-                        <button class="button button--action button--change-status" type="submit">Change status</button>
-                    </form>
 
-                    <button data-fancybox data-src="#batch-update-reviewers" class="button button--action button--reviewers js-batch-update-reviewers" type="button">Reviewers</button>
+                    <button data-fancybox data-src="#batch-progress" class="button button--action button--batch-status js-batch-button js-batch-progress" type="button">Status</button>
+
+                    <button data-fancybox data-src="#batch-update-reviewers" class="button button--action button--reviewers js-batch-button" type="button">Reviewers</button>
                 </div>
             {% endif %}
         </div>
@@ -53,3 +53,4 @@
 </div>
 
 {% include "funds/includes/batch_update_reviewer_form.html" %}
+{% include "funds/includes/batch_progress_form.html" %}
diff --git a/opentech/apply/funds/tests/views/test_batch_progress.py b/opentech/apply/funds/tests/views/test_batch_progress.py
new file mode 100644
index 0000000000000000000000000000000000000000..4d18a380f1a617e5e9adc12389df810464e79789
--- /dev/null
+++ b/opentech/apply/funds/tests/views/test_batch_progress.py
@@ -0,0 +1,110 @@
+from unittest import mock
+
+from opentech.apply.funds.models import ApplicationSubmission
+
+from opentech.apply.funds.tests.factories import (
+    ApplicationSubmissionFactory,
+    InvitedToProposalFactory,
+)
+from opentech.apply.users.tests.factories import (
+    ReviewerFactory,
+    StaffFactory,
+    UserFactory,
+)
+from opentech.apply.utils.testing.tests import BaseViewTestCase
+
+
+class BaseBatchProgressViewTestCase(BaseViewTestCase):
+    url_name = 'funds:submissions:{}'
+    base_view_name = 'list'
+
+    def data(self, action, submissions):
+        return {
+            'form-submitted-batch_progress_form': 'Update',
+            'action': action,
+            'submissions': ','.join([str(submission.id) for submission in submissions]),
+        }
+
+
+class StaffTestCase(BaseBatchProgressViewTestCase):
+    user_factory = StaffFactory
+
+    def test_can_progress_application(self):
+        submission = ApplicationSubmissionFactory()
+        action = 'open-review'
+        self.post_page(data=self.data(action, [submission]))
+        submission = self.refresh(submission)
+        self.assertEqual(submission.status, 'internal_review')
+
+    def test_can_progress_multiple_applications(self):
+        submissions = ApplicationSubmissionFactory.create_batch(3)
+        action = 'open-review'
+        self.post_page(data=self.data(action, submissions))
+
+        self.assertCountEqual(
+            [self.refresh(submission).status for submission in submissions],
+            ['internal_review'] * 3,
+        )
+
+    def test_cant_progress_in_incorrect_state(self):
+        submission = ApplicationSubmissionFactory()
+        action = 'close-review'
+        self.post_page(data=self.data(action, [submission]))
+        submission = self.refresh(submission)
+        self.assertEqual(submission.status, 'in_discussion')
+
+    def test_can_progress_one_in_mixed_state(self):
+        bad_submission = ApplicationSubmissionFactory()
+        good_submission = ApplicationSubmissionFactory(status='internal_review')
+        action = 'close-review'
+        self.post_page(data=self.data(action, [good_submission, bad_submission]))
+        good_submission = self.refresh(good_submission)
+        bad_submission = self.refresh(bad_submission)
+        self.assertEqual(bad_submission.status, 'in_discussion')
+        self.assertEqual(good_submission.status, 'post_review_discussion')
+
+    def test_can_progress_different_states(self):
+        submission = ApplicationSubmissionFactory()
+        other_submission = InvitedToProposalFactory()
+        action = 'open-review'
+        self.post_page(data=self.data(action, [submission, other_submission]))
+        submission = self.refresh(submission)
+        other_submission = self.refresh(other_submission)
+        self.assertEqual(submission.status, 'internal_review')
+        self.assertEqual(other_submission.status, 'proposal_internal_review')
+
+    @mock.patch('opentech.apply.funds.views.messenger')
+    def test_messenger_not_called_with_failed(self, patched):
+        submission = ApplicationSubmissionFactory()
+        action = 'close-review'
+        self.post_page(data=self.data(action, [submission]))
+        patched.assert_called_once()
+        _, _, kwargs = patched.mock_calls[0]
+        self.assertQuerysetEqual(kwargs['submissions'], ApplicationSubmission.objects.none())
+
+    @mock.patch('opentech.apply.funds.views.messenger')
+    def test_messenger_with_submission_in_review(self, patched):
+        submission = ApplicationSubmissionFactory()
+        action = 'open-review'
+        self.post_page(data=self.data(action, [submission]))
+        self.assertEqual(patched.call_count, 2)
+        _, _, kwargs = patched.mock_calls[0]
+        self.assertCountEqual(kwargs['submissions'], [submission])
+        _, _, kwargs = patched.mock_calls[1]
+        self.assertCountEqual(kwargs['submissions'], [submission])
+
+
+class ReivewersTestCase(BaseBatchProgressViewTestCase):
+    user_factory = ReviewerFactory
+
+    def test_cant_post_to_page(self):
+        response = self.post_page()
+        self.assertEqual(response.status_code, 405)
+
+
+class ApplicantTestCase(BaseBatchProgressViewTestCase):
+    user_factory = UserFactory
+
+    def test_cant_access_page_to_page(self):
+        response = self.post_page()
+        self.assertEqual(response.status_code, 403)
diff --git a/opentech/apply/funds/views.py b/opentech/apply/funds/views.py
index bff504909e79e9fb2c3900294a85e579af155782..01659e1e832604ade8551660dc9610befa41bb46 100644
--- a/opentech/apply/funds/views.py
+++ b/opentech/apply/funds/views.py
@@ -27,12 +27,12 @@ from opentech.apply.activity.messaging import messenger, MESSAGES
 from opentech.apply.determinations.views import DeterminationCreateOrUpdateView
 from opentech.apply.review.views import ReviewContextMixin
 from opentech.apply.users.decorators import staff_required
-from opentech.apply.users.models import User
 from opentech.apply.utils.views import DelegateableListView, DelegateableView, ViewDispatcher
 
 from .differ import compare
 from .forms import (
     BatchUpdateReviewersForm,
+    BatchProgressSubmissionForm,
     ProgressSubmissionForm,
     ScreeningSubmissionForm,
     UpdateReviewersForm,
@@ -54,7 +54,7 @@ from .tables import (
     SubmissionReviewerFilterAndSearch,
     SummarySubmissionsTable,
 )
-from .workflow import STAGE_CHANGE_ACTIONS, PHASES_MAPPING
+from .workflow import STAGE_CHANGE_ACTIONS, PHASES_MAPPING, review_statuses
 
 
 class BaseAdminSubmissionsTable(SingleTableMixin, FilterView):
@@ -108,10 +108,9 @@ class BatchUpdateReviewersView(DelegatedViewMixin, FormView):
         currently saved to that submission.
         Send out a message of updates.
         """
-        reviewers = User.objects.filter(id__in=form.cleaned_data['staff_reviewers'])
+        reviewers = form.cleaned_data['staff_reviewers']
 
-        submission_ids = form.cleaned_data['submission_ids']
-        submissions = ApplicationSubmission.objects.filter(id__in=submission_ids)
+        submissions = form.cleaned_data['submissions']
 
         for submission in submissions:
             submission.reviewers.add(*reviewers)
@@ -127,6 +126,64 @@ class BatchUpdateReviewersView(DelegatedViewMixin, FormView):
         return super().form_valid(form)
 
 
+@method_decorator(staff_required, name='dispatch')
+class BatchProgressSubmissionView(DelegatedViewMixin, FormView):
+    form_class = BatchProgressSubmissionForm
+    context_name = 'batch_progress_form'
+
+    def form_valid(self, form):
+        submissions = form.cleaned_data['submissions']
+        transitions = form.cleaned_data.get('action')
+
+        failed = []
+        phase_changes = {}
+        for submission in submissions:
+            valid_actions = {action for action, _ in submission.get_actions_for_user(self.request.user)}
+            old_phase = submission.phase
+            try:
+                transition = (valid_actions & set(transitions)).pop()
+                submission.perform_transition(
+                    transition,
+                    self.request.user,
+                    request=self.request,
+                    notify=False,
+                )
+            except (PermissionDenied, KeyError):
+                failed.append(submission)
+            else:
+                phase_changes[submission.id] = old_phase
+
+        if failed:
+            messages.warning(
+                self.request,
+                _('Failed to update: ') +
+                ', '.join(str(submission) for submission in failed)
+            )
+
+        succeeded_submissions = submissions.exclude(id__in=[submission.id for submission in failed])
+        messenger(
+            MESSAGES.BATCH_TRANSITION,
+            user=self.request.user,
+            request=self.request,
+            submissions=succeeded_submissions,
+            related=phase_changes,
+        )
+
+        ready_for_review = [
+            phase for phase in transitions
+            if phase in review_statuses
+        ]
+        if ready_for_review:
+            messenger(
+                MESSAGES.BATCH_READY_FOR_REVIEW,
+                user=self.request.user,
+                request=self.request,
+                submissions=succeeded_submissions.filter(status__in=ready_for_review),
+            )
+
+        return super().form_valid(form)
+
+
 class BaseReviewerSubmissionsTable(BaseAdminSubmissionsTable):
     table_class = ReviewerSubmissionsTable
     filterset_class = SubmissionReviewerFilterAndSearch
@@ -182,7 +239,8 @@ class SubmissionOverviewView(AllActivityContextMixin, BaseAdminSubmissionsTable)
 class SubmissionAdminListView(AllActivityContextMixin, BaseAdminSubmissionsTable, DelegateableListView):
     template_name = 'funds/submissions.html'
     form_views = [
-        BatchUpdateReviewersView
+        BatchUpdateReviewersView,
+        BatchProgressSubmissionView,
     ]
 
 
@@ -199,11 +257,17 @@ class SubmissionListView(ViewDispatcher):
 class SubmissionsByRound(AllActivityContextMixin, BaseAdminSubmissionsTable, DelegateableListView):
     template_name = 'funds/submissions_by_round.html'
     form_views = [
-        BatchUpdateReviewersView
+        BatchUpdateReviewersView,
+        BatchProgressSubmissionView,
     ]
 
     excluded_fields = ('round', 'lead', 'fund')
 
+    def get_form_kwargs(self):
+        kwargs = super().get_form_kwargs()
+        kwargs['round'] = self.obj
+        return kwargs
+
     def get_queryset(self):
         # We want to only show lab or Rounds in this view, their base class is Page
         try:
@@ -220,19 +284,22 @@ class SubmissionsByRound(AllActivityContextMixin, BaseAdminSubmissionsTable, Del
 
 
 @method_decorator(staff_required, name='dispatch')
-class SubmissionsByStatus(BaseAdminSubmissionsTable):
+class SubmissionsByStatus(BaseAdminSubmissionsTable, DelegateableListView):
     template_name = 'funds/submissions_by_status.html'
     status_mapping = PHASES_MAPPING
+    form_views = [
+        BatchUpdateReviewersView,
+        BatchProgressSubmissionView,
+    ]
 
-    def get(self, request, *args, **kwargs):
+    def dispatch(self, request, *args, **kwargs):
         self.status = kwargs.get('status')
         status_data = self.status_mapping[self.status]
         self.status_name = status_data['name']
         self.statuses = status_data['statuses']
         if self.status not in self.status_mapping:
             raise Http404(_("No statuses match the requested value"))
-
-        return super().get(request, *args, **kwargs)
+        return super().dispatch(request, *args, **kwargs)
 
     def get_filterset_kwargs(self, filterset_class, **kwargs):
         return super().get_filterset_kwargs(filterset_class, limit_statuses=self.statuses, **kwargs)
diff --git a/opentech/apply/funds/workflow.py b/opentech/apply/funds/workflow.py
index cb5744d5a2480962f84ab8a82375c63375a8cfc3..58b017eae90a70b4c7ae2535b16fa3dbdab62a8a 100644
--- a/opentech/apply/funds/workflow.py
+++ b/opentech/apply/funds/workflow.py
@@ -2,6 +2,7 @@ from collections import defaultdict
 from enum import Enum
 import itertools
 
+from django.utils.text import slugify
 
 """
 This file defines classes which allow you to compose workflows based on the following structure:
@@ -732,6 +733,23 @@ def get_determination_transitions():
     return transitions
 
 
+def get_action_mapping(workflow):
+    # Maps action names to the phase they originate from
+    transitions = defaultdict(lambda: {'display': '', 'transitions': []})
+    if workflow:
+        phases = workflow.items()
+    else:
+        phases = PHASES
+    for phase_name, phase in phases:
+        for transition_name, transition in phase.transitions.items():
+            transition_display = transition['display']
+            transition_key = slugify(transition_display)
+            transitions[transition_key]['transitions'].append(transition_name)
+            transitions[transition_key]['display'] = transition_display
+
+    return transitions
+
+
 DETERMINATION_OUTCOMES = get_determination_transitions()
 
 
diff --git a/opentech/apply/utils/views.py b/opentech/apply/utils/views.py
index 3e83f17d44a38942df0dc9f1133d0c3141c7cff2..1cd79277d148c4b15a67c4ab7ffa1563ab909d08 100644
--- a/opentech/apply/utils/views.py
+++ b/opentech/apply/utils/views.py
@@ -1,5 +1,6 @@
 from django.contrib.auth.decorators import login_required
 from django.forms.models import ModelForm
+from django.http import HttpResponseForbidden
 from django.utils.decorators import method_decorator
 from django.views import defaults
 from django.views.generic import View
@@ -34,7 +35,9 @@ class ViewDispatcher(View):
         elif self.reviewer_check(request):
             view = self.reviewer_view
 
-        return view.as_view()(request, *args, **kwargs)
+        if view:
+            return view.as_view()(request, *args, **kwargs)
+        return HttpResponseForbidden()
 
 
 class DelegatableBase(ContextMixin):
@@ -45,11 +48,12 @@ class DelegatableBase(ContextMixin):
     """
     form_prefix = 'form-submitted-'
 
-    def get_form_args(self):
-        return (None, None)
+    def get_form_kwargs(self):
+        return {}
 
     def get_context_data(self, **kwargs):
-        forms = dict(form_view.contribute_form(*self.get_form_args()) for form_view in self.form_views)
+        form_kwargs = self.get_form_kwargs()
+        forms = dict(form_view.contribute_form(**form_kwargs) for form_view in self.form_views)
 
         return super().get_context_data(
             form_prefix=self.form_prefix,
@@ -71,8 +75,11 @@ class DelegatableBase(ContextMixin):
 
 
 class DelegateableView(DelegatableBase):
-    def get_form_args(self):
-        return self.object, self.request.user
+    def get_form_kwargs(self):
+        return {
+            'user': self.request.user,
+            'instance': self.object,
+        }
 
     def post(self, request, *args, **kwargs):
         self.object = self.get_object()
@@ -83,6 +90,11 @@ class DelegateableView(DelegatableBase):
 
 
 class DelegateableListView(DelegatableBase):
+    def get_form_kwargs(self):
+        return {
+            'user': self.request.user,
+        }
+
     def post(self, request, *args, **kwargs):
         self.object_list = self.get_queryset()
         return super().post(request, *args, **kwargs)
@@ -96,8 +108,7 @@ class DelegatedViewMixin(View):
 
     def get_form_kwargs(self):
         kwargs = super().get_form_kwargs()
-        if self.is_model_form():
-            kwargs['user'] = self.request.user
+        kwargs['user'] = self.request.user
         return kwargs
 
     def get_form(self, *args, **kwargs):
@@ -117,16 +128,16 @@ class DelegatedViewMixin(View):
         return issubclass(cls.form_class, ModelForm)
 
     @classmethod
-    def contribute_form(cls, submission, user):
-        if cls.is_model_form():
-            form = cls.form_class(instance=submission, user=user)
-        else:
-            form = cls.form_class()  # This is for the batch update, we don't pass in the user or a single submission
+    def contribute_form(cls, **kwargs):
+        form = cls.form_class(**kwargs)
         form.name = cls.context_name
         return cls.context_name, form
 
     def get_success_url(self):
-        return self.request.path
+        query = self.request.GET.urlencode()
+        if query:
+            query = '?' + query
+        return self.request.path + query
 
 
 class CreateOrUpdateView(SingleObjectTemplateResponseMixin, ModelFormMixin, ProcessFormView):
diff --git a/opentech/static_src/src/javascript/apply/batch-actions.js b/opentech/static_src/src/javascript/apply/batch-actions.js
index 6d2af16f8d6269e0b28086a1440e76f8f18a2fab..37d457028b7fc73eb175b0a74f95d1b2462a31ec 100644
--- a/opentech/static_src/src/javascript/apply/batch-actions.js
+++ b/opentech/static_src/src/javascript/apply/batch-actions.js
@@ -5,10 +5,12 @@
     const $body = $('body');
     const $checkbox = $('.js-batch-select');
     const $allCheckboxInput = $('.js-batch-select-all');
-    const $batchReviewersButton = $('.js-batch-update-reviewers');
+    const $batchButtons = $('.js-batch-button');
+    const $batchProgress = $('.js-batch-progress');
+    const $actionOptions = $('#id_action option');
     const $batchTitlesList = $('.js-batch-titles');
     const $batchTitleCount = $('.js-batch-title-count');
-    const $hiddenIDlist = $('#id_submission_ids');
+    const $hiddenIDlist = $('.js-submissions-id');
     const $toggleBatchList = $('.js-toggle-batch-list');
     const activeClass = 'batch-actions-enabled';
     const closedClass = 'is-closed';
@@ -32,6 +34,7 @@
 
         toggleBatchActions();
         updateCount();
+        updateProgressButton();
     });
 
     $checkbox.change(function () {
@@ -45,34 +48,19 @@
         if (!$(this).is(':checked') && $allCheckboxInput.is(':checked')) {
             resetCheckAllInput();
         }
+
+        updateProgressButton();
     });
 
     // append selected project titles to batch update reviewer modal
-    $batchReviewersButton.click(function () {
-        $batchTitlesList.html('');
-        $batchTitleCount.html('');
-        $batchTitlesList.addClass(closedClass);
-        $toggleBatchList.html('Show');
-
-        let selectedIDs = [];
-
-        $checkbox.each(function () {
-            if ($(this).is(':checked')) {
-                const href = $(this).parents('tr').find('.js-title').find('a').attr('href');
-                const title = $(this).parents('tr').find('.js-title').data('tooltip');
-
-                $batchTitlesList.append(`
-                    <a href="${href}" class="modal__list-item" target="_blank" rel="noopener noreferrer" title="${title}">
-                        ${title}
-                        <svg class="modal__open-link-icon"><use xlink:href="#open-in-new-tab"></use></svg>
-                    </a>
-                `);
-                selectedIDs.push($(this).parents('tr').data('record-id'));
-            }
+    $batchButtons.each(function () {
+        $(this).click(function () {
+            prepareBatchListing();
         });
+    });
 
-        $batchTitleCount.append(`${selectedIDs.length} submissions selected`);
-        $hiddenIDlist.val(selectedIDs.join(','));
+    $batchProgress.click(function () {
+        updateProgressButton();
     });
 
     // show/hide the list of actions
@@ -89,6 +77,55 @@
         $batchTitlesList.toggleClass(closedClass);
     });
 
+    function prepareBatchListing() {
+        $batchTitlesList.html('');
+        $batchTitleCount.html('');
+        $batchTitlesList.addClass(closedClass);
+        $toggleBatchList.html('Show');
+
+        let selectedIDs = [];
+
+        $checkbox.filter(':checked').each(function () {
+            const href = $(this).parents('tr').find('.js-title').find('a').attr('href');
+            const title = $(this).parents('tr').find('.js-title').data('tooltip');
+
+            $batchTitlesList.append(`
+                <a href="${href}" class="modal__list-item" target="_blank" rel="noopener noreferrer" title="${title}">
+                    ${title}
+                    <svg class="modal__open-link-icon"><use xlink:href="#open-in-new-tab"></use></svg>
+                </a>
+            `);
+            selectedIDs.push($(this).parents('tr').data('record-id'));
+        });
+
+        $batchTitleCount.append(`${selectedIDs.length} submissions selected`);
+        $hiddenIDlist.val(selectedIDs.join(','));
+    }
+
+    function updateProgressButton() {
+        var actions = $actionOptions.map(function () { return this.value; }).get();
+        $checkbox.filter(':checked').each(function () {
+            let newActions = $(this).parents('tr').find('.js-actions').data('actions');
+            actions = actions.filter(action => newActions.includes(action));
+        });
+        $actionOptions.each(function () {
+            if (!actions.includes(this.value)) {
+                $(this).attr('disabled', 'disabled');
+            }
+            else {
+                $(this).removeAttr('disabled');
+            }
+        });
+        $actionOptions.filter(':enabled:first').prop('selected', true);
+        if (actions.length === 0) {
+            $batchProgress.attr('disabled', 'disabled');
+        }
+        else {
+            $batchProgress.removeAttr('disabled');
+        }
+    }
+
+
     function toggleBatchActions() {
         if ($('.js-batch-select:checked').length) {
             $body.addClass(activeClass);
diff --git a/opentech/static_src/src/sass/apply/components/_button.scss b/opentech/static_src/src/sass/apply/components/_button.scss
index 9c2afcc23920c948901e7a58eafd243d1c9a4841..1934b4ed2904254e1a9309941ed5dc86b6ed4f2c 100644
--- a/opentech/static_src/src/sass/apply/components/_button.scss
+++ b/opentech/static_src/src/sass/apply/components/_button.scss
@@ -242,9 +242,7 @@
         }
     }
 
-    &--change-status {
-        display: none;
-
+    &--batch-status {
         &::before {
             background-image: url('./../../images/arrow-split.svg');
             transform: rotate(90deg);