Agenda templates

- Added manage controls for list of speakers of agenda items.
- New slide for list of speakers.
- Fixed typo (speach -> speech)
This commit is contained in:
Emanuel Schuetze 2015-09-04 18:24:41 +02:00
parent 6acdb3c305
commit 77d027c1cc
10 changed files with 197 additions and 88 deletions

View File

@ -377,7 +377,7 @@ class Speaker(RESTModelMixin, models.Model):
end_time = models.DateTimeField(null=True) end_time = models.DateTimeField(null=True)
""" """
Saves the time, when the speaker ends his speach. None, if he is not finished yet. Saves the time, when the speaker ends his speech. None, if he is not finished yet.
""" """
weight = models.IntegerField(null=True) weight = models.IntegerField(null=True)
@ -393,19 +393,19 @@ class Speaker(RESTModelMixin, models.Model):
def __str__(self): def __str__(self):
return str(self.user) return str(self.user)
def begin_speach(self): def begin_speech(self):
""" """
Let the user speak. Let the user speak.
Set the weight to None and the time to now. If anyone is still Set the weight to None and the time to now. If anyone is still
speaking, end his speach. speaking, end his speech.
""" """
try: try:
actual_speaker = Speaker.objects.filter(item=self.item, end_time=None).exclude(begin_time=None).get() actual_speaker = Speaker.objects.filter(item=self.item, end_time=None).exclude(begin_time=None).get()
except Speaker.DoesNotExist: except Speaker.DoesNotExist:
pass pass
else: else:
actual_speaker.end_speach() actual_speaker.end_speech()
self.weight = None self.weight = None
self.begin_time = datetime.now() self.begin_time = datetime.now()
self.save() self.save()
@ -416,9 +416,9 @@ class Speaker(RESTModelMixin, models.Model):
# start_countdown() # start_countdown()
pass pass
def end_speach(self): def end_speech(self):
""" """
The speach is finished. Set the time to now. The speech is finished. Set the time to now.
""" """
self.end_time = datetime.now() self.end_time = datetime.now()
self.save() self.save()

View File

@ -81,7 +81,7 @@ class ItemDetailSlide(ProjectorElement):
view_class=ItemViewSet, view_class=ItemViewSet,
view_action='retrieve', view_action='retrieve',
pk=str(item.pk)) pk=str(item.pk))
for speaker in item.speaker_set.all(): for speaker in item.speakers.all():
yield ProjectorRequirement( yield ProjectorRequirement(
view_class=speaker.user.get_view_class(), view_class=speaker.user.get_view_class(),
view_action='retrieve', view_action='retrieve',

View File

@ -48,7 +48,7 @@ def setup_agenda_config(sender, **kwargs):
default_value=False, default_value=False,
input_type='boolean', input_type='boolean',
label=ugettext_lazy('Couple countdown with the list of speakers'), label=ugettext_lazy('Couple countdown with the list of speakers'),
help_text=ugettext_lazy('[Begin speach] starts the countdown, [End speach] stops the countdown.'), help_text=ugettext_lazy('[Begin speech] starts the countdown, [End speech] stops the countdown.'),
weight=230, weight=230,
group=ugettext_lazy('Agenda')) group=ugettext_lazy('Agenda'))

View File

@ -181,7 +181,7 @@ angular.module('OpenSlidesApp.agenda.site', ['OpenSlidesApp.agenda'])
// close/open list of speakers of current item // close/open list of speakers of current item
$scope.closeList = function (listClosed) { $scope.closeList = function (listClosed) {
item.speakerListClosed = listClosed; item.speaker_list_closed = listClosed;
Agenda.save(item); Agenda.save(item);
}; };
// add user to list of speakers // add user to list of speakers
@ -198,7 +198,34 @@ angular.module('OpenSlidesApp.agenda.site', ['OpenSlidesApp.agenda'])
$scope.removeSpeaker = function (speakerId) { $scope.removeSpeaker = function (speakerId) {
$http.delete('/rest/agenda/item/' + item.id + '/manage_speaker/', $http.delete('/rest/agenda/item/' + item.id + '/manage_speaker/',
{headers: {'Content-Type': 'application/json'}, {headers: {'Content-Type': 'application/json'},
data: JSON.stringify({speaker: speakerId})}); data: JSON.stringify({speaker: speakerId})})
.error(function(data){
$scope.alert = { type: 'danger', msg: data.detail, show: true };
});
};
// begin speech of selected/next speaker
$scope.beginSpeech = function (speakerId) {
$http.put('/rest/agenda/item/' + item.id + '/speak/', {'speaker': speakerId})
.success(function(data){
$scope.alert.show = false;
})
.error(function(data){
$scope.alert = { type: 'danger', msg: data.detail, show: true };
});
};
// end speech of current speaker
$scope.endSpeech = function () {
$http.delete('/rest/agenda/item/' + item.id + '/speak/',
{headers: {'Content-Type': 'application/json'},
data: JSON.stringify()})
.error(function(data){
$scope.alert = { type: 'danger', msg: data.detail, show: true };
});
};
// project list of speakers
$scope.projectListOfSpeakers = function () {
$http.post('/rest/core/projector/1/prune_elements/',
[{name: 'agenda/item', id: item.id, list_of_speakers: true}]);
}; };
}) })
@ -301,15 +328,22 @@ angular.module('OpenSlidesApp.agenda.projector', ['OpenSlidesApp.agenda'])
}); });
}) })
.controller('SlideItemDetailCtrl', function($scope, Agenda) { .controller('SlideItemDetailCtrl', [
// Attention! Each object that is used here has to be dealt on server side. '$scope',
// Add it to the coresponding get_requirements method of the ProjectorElement 'Agenda',
// class. 'User',
var id = $scope.element.context.id; function($scope, Agenda, User) {
Agenda.find(id); // Attention! Each object that is used here has to be dealt on server side.
Agenda.bindOne(id, $scope, 'item'); // Add it to the coresponding get_requirements method of the ProjectorElement
// class.
}) var id = $scope.element.context.id;
Agenda.find(id);
User.findAll();
Agenda.bindOne(id, $scope, 'item');
// get flag for list-of-speakers-slide (true/false)
$scope.is_list_of_speakers = $scope.element.context.list_of_speakers;
}
])
.controller('SlideItemListCtrl', function($scope, $http, Agenda) { .controller('SlideItemListCtrl', function($scope, $http, Agenda) {
// Attention! Each object that is used here has to be dealt on server side. // Attention! Each object that is used here has to be dealt on server side.

View File

@ -23,62 +23,114 @@
<div class="white-space-pre-line">{{ item.text }}</div> <div class="white-space-pre-line">{{ item.text }}</div>
<div os-perm="agenda.can_manage"> <div os-perm="agenda.can_manage">
<h3 os-perm="agenda.can_manage" translate>Duration</h3> <h2 os-perm="agenda.can_manage" translate>Duration</h2>
{{ item.duration }} {{ item.duration }}
</div> </div>
<div os-perm="agenda.can_manage"> <div os-perm="agenda.can_manage">
<h3 os-perm="agenda.can_manage" translate>Comment</h3> <h2 os-perm="agenda.can_manage" translate>Comment</h2>
<div class="white-space-pre-line">{{ item.comment }}</div> <div class="white-space-pre-line">{{ item.comment }}</div>
</div> </div>
<h3 translate>List of speakers <h2 translate>List of speakers
<span ng-if="item.speakerListClosed" class="label label-danger" translate>closed</span>
<span os-perms="agenda.can_manage"> <span os-perms="agenda.can_manage">
<button ng-if="item.speakerListClosed" ng-click="closeList(false)" <button ng-if="item.speaker_list_closed" ng-click="closeList(false)"
class="btn btn-sm btn-default" translate> class="btn btn-sm btn-danger" translate>
Open list Closed
</button> </button>
<button ng-if="!item.speakerListClosed" ng-click="closeList(true)" <button ng-if="!item.speaker_list_closed" ng-click="closeList(true)"
class="btn btn-sm btn-default" translate> class="btn btn-sm btn-success" translate>
Close list Opened
</button> </button>
</span> </span>
</h3> <!-- project list -->
<a os-perms="core.can_manage_projector" class="btn btn-default btn-sm"
ng-class="{ 'btn-primary': item.isProjected() }"
ng-click="projectListOfSpeakers()">
<i class="fa fa-video-camera"></i> Project list
</a>
</h2>
<!-- TODO: <!-- TODO:
* project list * show only 'add me' OR 'remove me' button
* show old/current/next speakers
* start/stop speech
* button 'put/remove me on/from the list'
* check permissions
--> -->
<ol> <div class="well">
<li ng-repeat="speaker in item.speakers"> <button class="btn btn-default btn-xs" type="button"
{{ speaker.user.get_full_name() }} data-toggle="collapse" data-target="#old_speakers"
<button os-perms="agenda.can_manage" ng-click="removeSpeaker(speaker.id)" aria-expanded="false" aria-controls="collapseExample">
class="btn btn-default btn-xs"> Show all old speakers
<i class="fa fa-times"></i> </button>
</button>
</li>
</ol>
<div os-perms="agenda.can_manage" class="form-group col-sm-6"> <div class="collapse" id="old_speakers">
<alert ng-show="alert.show" type="{{ alert.type }}" ng-click="alert={}" close="alert={}">{{ alert.msg }}</alert> <h3 translate>Old speakers:</h3>
<div class="input-group"> <ol>
<ui-select ng-model="speaker.selected" ng-change="addSpeaker(speaker.selected.id)"> <li ng-repeat="speaker in item.speakers | filter: {end_time: '!!'}">
<ui-select-match placeholder="{{ 'Select or search a participant...' | translate }}"> {{ speaker.user.get_full_name() }}
{{ $select.selected.get_full_name() }} <small class="grey">
</ui-select-match> [{{speaker.begin_time | date:'yyyy-MM-dd HH:mm:ss'}}
<ui-select-choices repeat="user in users | filter: $select.search"> {{speaker.end_time | date:'yyyy-MM-dd HH:mm:ss'}}]
<div ng-bind-html="user.get_full_name() | highlight: $select.search"></div> </small>
</ui-select-choices> </ol>
</ui-select> </div>
<span class="input-group-btn">
<a ng-click="speaker={}" class="btn btn-default"> <h3 translate>Current speaker:</h3>
<i class="fa fa-times-circle"></i> <strong ng-repeat="speaker in item.speakers | filter: {end_time: null, begin_time: '!!'}">
</a> {{ speaker.user.get_full_name() }}
</span> </strong>
<h3 translate>Next speakers:</h3>
<ol>
<li ng-repeat="speaker in item.speakers | filter: {begin_time: null}">
{{ speaker.user.get_full_name() }}
<button os-perms="agenda.can_manage" ng-click="removeSpeaker(speaker.id)"
class="btn btn-default btn-xs">
<i class="fa fa-times"></i>
</button>
<button os-perms="agenda.can_manage" ng-click="beginSpeech(speaker.id)"
class="btn btn-default btn-xs">
<i class="fa fa-play"></i>
</button>
</ol>
<div class="form-group">
<alert ng-show="alert.show" type="{{ alert.type }}" ng-click="alert={}" close="alert={}">
{{alert.msg}}
</alert>
<div os-perms="agenda.can_manage" class="input-group">
<ui-select ng-model="speaker.selected" ng-change="addSpeaker(speaker.selected.id)">
<ui-select-match placeholder="{{ 'Select or search a participant...' | translate }}">
{{ $select.selected.get_full_name() }}
</ui-select-match>
<ui-select-choices repeat="user in users | filter: $select.search">
<div ng-bind-html="user.get_full_name() | highlight: $select.search"></div>
</ui-select-choices>
</ui-select>
<span class="input-group-btn">
<a ng-click="speaker={}" class="btn btn-default">
<i class="fa fa-times-circle"></i>
</a>
</span>
</div>
<p os-perm="agenda.can_be_speaker">
<button ng-click="addSpeaker()" class="btn btn-default">
<i class="fa fa-plus"></i>
<translate>Add me</translate>
</button>
<button ng-click="removeSpeaker()" class="btn btn-default">
<i class="fa fa-minus"></i>
<translate>Remove me</translate>
</button>
<p os-perms="agenda.can_manage">
<button ng-click="beginSpeech()"
class="btn btn-primary">
<i class="fa fa-play"></i>
<translate>Start next speaker</translate>
</button>
<button ng-click="endSpeech()"
class="btn btn-default">
<i class="fa fa-stop"></i>
<translate>Stop current speaker</translate>
</button>
</div> </div>
</div> </div>

View File

@ -1,4 +1,27 @@
<div ng-controller="SlideItemDetailCtrl" class="content"> <div ng-controller="SlideItemDetailCtrl" class="content">
<h1>{{ item.title }}</h1> <h1>
<div class="white-space-pre-line">{{ item.text }}</div> {{ item.title }}
<small ng-if="is_list_of_speakers">
<span translate>List of speakers</span>
<span ng-if="item.speaker_list_closed" class="label label-danger" translate>Closed</span>
</small>
</h1>
<!-- Item text -->
<div ng-if="!is_list_of_speakers" class="white-space-pre-line">{{ item.text }}</div>
<!-- List of speakers -->
<div ng-if="is_list_of_speakers">
<!-- TODO: show last old speakers -->
<!-- current speaker -->
<p>
<strong ng-repeat="speaker in item.speakers | filter: {end_time: null, begin_time: '!!'}">
{{ speaker.user.get_full_name() }}
</strong>
<!-- next speakers -->
<ol id="list_of_speakers">
<li ng-repeat="speaker in item.speakers | filter: {begin_time: null}">
{{ speaker.user.get_full_name() }}
</ol>
</div>
</div> </div>

View File

@ -152,9 +152,9 @@ class ItemViewSet(ModelViewSet):
@detail_route(methods=['PUT', 'DELETE']) @detail_route(methods=['PUT', 'DELETE'])
def speak(self, request, pk=None): def speak(self, request, pk=None):
""" """
Special view endpoint to begin and end speach of speakers. Send PUT Special view endpoint to begin and end speech of speakers. Send PUT
{'speaker': <speaker_id>} to begin speach. Omit data to begin speach of {'speaker': <speaker_id>} to begin speech. Omit data to begin speech of
the next speaker. Send DELETE to end speach of current speaker. the next speaker. Send DELETE to end speech of current speaker.
""" """
# Retrieve item. # Retrieve item.
item = self.get_object() item = self.get_object()
@ -171,21 +171,21 @@ class ItemViewSet(ModelViewSet):
speaker = Speaker.objects.get(pk=int(speaker_id)) speaker = Speaker.objects.get(pk=int(speaker_id))
except (ValueError, Speaker.DoesNotExist): except (ValueError, Speaker.DoesNotExist):
raise ValidationError({'detail': _('Speaker does not exist.')}) raise ValidationError({'detail': _('Speaker does not exist.')})
speaker.begin_speach() speaker.begin_speech()
message = _('User is now speaking.') message = _('User is now speaking.')
else: else:
# request.method == 'DELETE' # request.method == 'DELETE'
try: try:
# We assume that there aren't multiple entries because this # We assume that there aren't multiple entries because this
# is forbidden by the Model's begin_speach method. We assume that # is forbidden by the Model's begin_speech method. We assume that
# there is only one speaker instance or none. # there is only one speaker instance or none.
current_speaker = Speaker.objects.filter(item=item, end_time=None).exclude(begin_time=None).get() current_speaker = Speaker.objects.filter(item=item, end_time=None).exclude(begin_time=None).get()
except Speaker.DoesNotExist: except Speaker.DoesNotExist:
raise ValidationError( raise ValidationError(
{'detail': _('There is no one speaking at the moment according to %(item)s.') % {'item': item}}) {'detail': _('There is no one speaking at the moment according to %(item)s.') % {'item': item}})
current_speaker.end_speach() current_speaker.end_speech()
message = _('The speach is finished now.') message = _('The speech is finished now.')
# Initiate response. # Initiate response.
return Response({'detail': message}) return Response({'detail': message})

View File

@ -125,7 +125,7 @@ class ManageSpeaker(TestCase):
class Speak(TestCase): class Speak(TestCase):
""" """
Tests view to begin or end speach. Tests view to begin or end speech.
""" """
def setUp(self): def setUp(self):
self.client = APIClient() self.client = APIClient()
@ -135,7 +135,7 @@ class Speak(TestCase):
username='test_user_Aigh4vohb3seecha4aa4', username='test_user_Aigh4vohb3seecha4aa4',
password='test_password_eneupeeVo5deilixoo8j') password='test_password_eneupeeVo5deilixoo8j')
def test_begin_speach(self): def test_begin_speech(self):
Speaker.objects.add(self.user, self.item) Speaker.objects.add(self.user, self.item)
speaker = Speaker.objects.add(get_user_model().objects.get(username='admin'), self.item) speaker = Speaker.objects.add(get_user_model().objects.get(username='admin'), self.item)
self.assertTrue(Speaker.objects.get(pk=speaker.pk).begin_time is None) self.assertTrue(Speaker.objects.get(pk=speaker.pk).begin_time is None)
@ -145,7 +145,7 @@ class Speak(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertFalse(Speaker.objects.get(pk=speaker.pk).begin_time is None) self.assertFalse(Speaker.objects.get(pk=speaker.pk).begin_time is None)
def test_begin_speach_next_speaker(self): def test_begin_speech_next_speaker(self):
speaker = Speaker.objects.add(self.user, self.item) speaker = Speaker.objects.add(self.user, self.item)
Speaker.objects.add(get_user_model().objects.get(username='admin'), self.item) Speaker.objects.add(get_user_model().objects.get(username='admin'), self.item)
@ -154,27 +154,27 @@ class Speak(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertFalse(Speaker.objects.get(pk=speaker.pk).begin_time is None) self.assertFalse(Speaker.objects.get(pk=speaker.pk).begin_time is None)
def test_begin_speach_invalid_speaker_id(self): def test_begin_speech_invalid_speaker_id(self):
response = self.client.put( response = self.client.put(
reverse('item-speak', args=[self.item.pk]), reverse('item-speak', args=[self.item.pk]),
{'speaker': '1'}) {'speaker': '1'})
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
def test_begin_speach_invalid_data(self): def test_begin_speech_invalid_data(self):
response = self.client.put( response = self.client.put(
reverse('item-speak', args=[self.item.pk]), reverse('item-speak', args=[self.item.pk]),
{'speaker': 'invalid'}) {'speaker': 'invalid'})
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
def test_end_speach(self): def test_end_speech(self):
speaker = Speaker.objects.add(get_user_model().objects.get(username='admin'), self.item) speaker = Speaker.objects.add(get_user_model().objects.get(username='admin'), self.item)
speaker.begin_speach() speaker.begin_speech()
self.assertFalse(Speaker.objects.get(pk=speaker.pk).begin_time is None) self.assertFalse(Speaker.objects.get(pk=speaker.pk).begin_time is None)
self.assertTrue(Speaker.objects.get(pk=speaker.pk).end_time is None) self.assertTrue(Speaker.objects.get(pk=speaker.pk).end_time is None)
response = self.client.delete(reverse('item-speak', args=[self.item.pk])) response = self.client.delete(reverse('item-speak', args=[self.item.pk]))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertFalse(Speaker.objects.get(pk=speaker.pk).end_time is None) self.assertFalse(Speaker.objects.get(pk=speaker.pk).end_time is None)
def test_end_speach_no_current_speaker(self): def test_end_speech_no_current_speaker(self):
response = self.client.delete(reverse('item-speak', args=[self.item.pk])) response = self.client.delete(reverse('item-speak', args=[self.item.pk]))
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)

View File

@ -46,18 +46,18 @@ class ListOfSpeakerModelTests(TestCase):
speaker1_item1 = Speaker.objects.add(self.speaker1, self.item1) speaker1_item1 = Speaker.objects.add(self.speaker1, self.item1)
self.assertIsNone(speaker1_item1.begin_time) self.assertIsNone(speaker1_item1.begin_time)
self.assertIsNone(speaker1_item1.end_time) self.assertIsNone(speaker1_item1.end_time)
speaker1_item1.begin_speach() speaker1_item1.begin_speech()
self.assertIsNotNone(Speaker.objects.get(pk=speaker1_item1.pk).begin_time) self.assertIsNotNone(Speaker.objects.get(pk=speaker1_item1.pk).begin_time)
self.assertIsNone(Speaker.objects.get(pk=speaker1_item1.pk).weight) self.assertIsNone(Speaker.objects.get(pk=speaker1_item1.pk).weight)
speaker1_item1.end_speach() speaker1_item1.end_speech()
self.assertIsNotNone(Speaker.objects.get(pk=speaker1_item1.pk).end_time) self.assertIsNotNone(Speaker.objects.get(pk=speaker1_item1.pk).end_time)
def test_finish_when_other_speaker_begins(self): def test_finish_when_other_speaker_begins(self):
speaker1_item1 = Speaker.objects.add(self.speaker1, self.item1) speaker1_item1 = Speaker.objects.add(self.speaker1, self.item1)
speaker2_item1 = Speaker.objects.add(self.speaker2, self.item1) speaker2_item1 = Speaker.objects.add(self.speaker2, self.item1)
speaker1_item1.begin_speach() speaker1_item1.begin_speech()
self.assertIsNone(speaker1_item1.end_time) self.assertIsNone(speaker1_item1.end_time)
self.assertIsNone(speaker2_item1.begin_time) self.assertIsNone(speaker2_item1.begin_time)
speaker2_item1.begin_speach() speaker2_item1.begin_speech()
self.assertIsNotNone(Speaker.objects.get(user=self.speaker1, item=self.item1).end_time) self.assertIsNotNone(Speaker.objects.get(user=self.speaker1, item=self.item1).end_time)
self.assertIsNotNone(speaker2_item1.begin_time) self.assertIsNotNone(speaker2_item1.begin_time)

View File

@ -56,7 +56,7 @@ class ItemViewSetManageSpeaker(TestCase):
class ItemViewSetSpeak(TestCase): class ItemViewSetSpeak(TestCase):
""" """
Tests views of ItemViewSet to begin and end speach. Tests views of ItemViewSet to begin and end speech.
""" """
def setUp(self): def setUp(self):
self.request = MagicMock() self.request = MagicMock()
@ -65,27 +65,27 @@ class ItemViewSetSpeak(TestCase):
self.view_instance.get_object = get_object_mock = MagicMock() self.view_instance.get_object = get_object_mock = MagicMock()
get_object_mock.return_value = self.mock_item = MagicMock() get_object_mock.return_value = self.mock_item = MagicMock()
def test_begin_speach(self): def test_begin_speech(self):
self.request.method = 'PUT' self.request.method = 'PUT'
self.request.user.has_perm.return_value = True self.request.user.has_perm.return_value = True
self.request.data = {} self.request.data = {}
self.mock_item.get_next_speaker.return_value = mock_next_speaker = MagicMock() self.mock_item.get_next_speaker.return_value = mock_next_speaker = MagicMock()
self.view_instance.speak(self.request) self.view_instance.speak(self.request)
mock_next_speaker.begin_speach.assert_called_with() mock_next_speaker.begin_speech.assert_called_with()
@patch('openslides.agenda.views.Speaker') @patch('openslides.agenda.views.Speaker')
def test_begin_speach_specific_speaker(self, mock_speaker): def test_begin_speech_specific_speaker(self, mock_speaker):
self.request.method = 'PUT' self.request.method = 'PUT'
self.request.user.has_perm.return_value = True self.request.user.has_perm.return_value = True
self.request.data = {'speaker': '1'} self.request.data = {'speaker': '1'}
mock_speaker.objects.get.return_value = mock_next_speaker = MagicMock() mock_speaker.objects.get.return_value = mock_next_speaker = MagicMock()
self.view_instance.speak(self.request) self.view_instance.speak(self.request)
mock_next_speaker.begin_speach.assert_called_with() mock_next_speaker.begin_speech.assert_called_with()
@patch('openslides.agenda.views.Speaker') @patch('openslides.agenda.views.Speaker')
def test_end_speach(self, mock_speaker): def test_end_speech(self, mock_speaker):
self.request.method = 'DELETE' self.request.method = 'DELETE'
self.request.user.has_perm.return_value = True self.request.user.has_perm.return_value = True
mock_speaker.objects.filter.return_value.exclude.return_value.get.return_value = mock_speaker = MagicMock() mock_speaker.objects.filter.return_value.exclude.return_value.get.return_value = mock_speaker = MagicMock()
self.view_instance.speak(self.request) self.view_instance.speak(self.request)
mock_speaker.end_speach.assert_called_with() mock_speaker.end_speech.assert_called_with()