diff --git a/client/src/app/core/repositories/agenda/list-of-speakers-repository.service.ts b/client/src/app/core/repositories/agenda/list-of-speakers-repository.service.ts index 1acc8358e..f0b8eb091 100644 --- a/client/src/app/core/repositories/agenda/list-of-speakers-repository.service.ts +++ b/client/src/app/core/repositories/agenda/list-of-speakers-repository.service.ts @@ -168,6 +168,16 @@ export class ListOfSpeakersRepositoryService extends BaseHasContentObjectReposit await this.httpService.post(restUrl, { speakers: speakerIds }); } + /** + * Readds the last speaker to the list of speakers + * + * @param listOfSpeakers the list of speakers to modify + */ + public async readdLastSpeaker(listOfSpeakers: ViewListOfSpeakers): Promise { + const restUrl = this.getRestUrl(listOfSpeakers.id, 'readd_last_speaker'); + await this.httpService.post(restUrl); + } + /** * Marks all speakers for a given user * @@ -207,7 +217,10 @@ export class ListOfSpeakersRepositoryService extends BaseHasContentObjectReposit * @param listOfSpeakersId id of the list of speakers * @param method the desired speaker action */ - private getRestUrl(listOfSpeakersId: number, method: 'manage_speaker' | 'sort_speakers' | 'speak'): string { + private getRestUrl( + listOfSpeakersId: number, + method: 'manage_speaker' | 'sort_speakers' | 'speak' | 'readd_last_speaker' + ): string { return `/rest/agenda/list-of-speakers/${listOfSpeakersId}/${method}/`; } } diff --git a/client/src/app/shared/components/sorting-list/sorting-list.component.html b/client/src/app/shared/components/sorting-list/sorting-list.component.html index d041e8978..957a2fdd7 100644 --- a/client/src/app/shared/components/sorting-list/sorting-list.component.html +++ b/client/src/app/shared/components/sorting-list/sorting-list.component.html @@ -1,10 +1,10 @@
-
+
No data
val); + this.currentItems = newValues.map(val => val); + if (this.sortedItems.length !== newValues.length || this.live) { + this.sortedItems = newValues.map(val => val); } else { - this.array = this.array.map(arrayValue => newValues.find(val => val.id === arrayValue.id)); + this.sortedItems = this.sortedItems.map(arrayValue => newValues.find(val => val.id === arrayValue.id)); } } + /** + * Restore the old order from the last update + */ + public restore(): void { + this.sortedItems = this.currentItems.map(val => val); + } + /** * Handles the start of a dragDrop event and clears multiSelect if the ittem dragged * is not part of the selected items @@ -171,35 +183,35 @@ export class SortingListComponent implements OnInit, OnDestroy { dropBehind?: boolean ): void { if (!this.multiSelectedIndex.length) { - moveItemInArray(this.array, event.previousIndex, event.currentIndex); + moveItemInArray(this.sortedItems, event.previousIndex, event.currentIndex); } else { const before: Selectable[] = []; const insertions: Selectable[] = []; const behind: Selectable[] = []; - for (let i = 0; i < this.array.length; i++) { + for (let i = 0; i < this.sortedItems.length; i++) { if (!this.multiSelectedIndex.includes(i)) { if (i < event.currentIndex) { - before.push(this.array[i]); + before.push(this.sortedItems[i]); } else if (i > event.currentIndex) { - behind.push(this.array[i]); + behind.push(this.sortedItems[i]); } else { if (dropBehind === false) { - behind.push(this.array[i]); + behind.push(this.sortedItems[i]); } else if (dropBehind === true) { - before.push(this.array[i]); + before.push(this.sortedItems[i]); } else { Math.min(...this.multiSelectedIndex) < i - ? before.push(this.array[i]) - : behind.push(this.array[i]); + ? before.push(this.sortedItems[i]) + : behind.push(this.sortedItems[i]); } } } else { - insertions.push(this.array[i]); + insertions.push(this.sortedItems[i]); } } - this.array = [...before, ...insertions, ...behind]; + this.sortedItems = [...before, ...insertions, ...behind]; } - this.sortEvent.emit(this.array); + this.sortEvent.emit(this.sortedItems); this.multiSelectedIndex = []; } diff --git a/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.html b/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.html index 1d624cb5a..fc5e61525 100644 --- a/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.html +++ b/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.html @@ -1,12 +1,14 @@ - +

- List of speakers - Current list of speakers + List of speakers + Current list of speakers + Sort mode

@@ -77,10 +79,9 @@
@@ -154,6 +155,11 @@ + + speaker.id); - this.listOfSpeakersRepo.sortSpeakers(this.viewListOfSpeakers, userIds).then(null, this.raiseError); + public onSaveSorting(): void { + if (this.isSortMode) { + this.isSortMode = false; + this.listOfSpeakersRepo + .sortSpeakers(this.viewListOfSpeakers, this.listElement.sortedItems.map(el => el.id)) + .catch(this.raiseError); + } + } + + /** + * Restore old order on cancel + */ + public onCancelSorting(): void { + if (this.isSortMode) { + this.isSortMode = false; + this.listElement.restore(); + } } /** @@ -339,9 +359,14 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit * @param speaker The speaker clicked on. */ public onMarkButton(speaker: ViewSpeaker): void { - this.listOfSpeakersRepo - .markSpeaker(this.viewListOfSpeakers, speaker, !speaker.marked) - .then(null, this.raiseError); + this.listOfSpeakersRepo.markSpeaker(this.viewListOfSpeakers, speaker, !speaker.marked).catch(this.raiseError); + } + + /** + * Removes the last finished speaker from the list an re-adds him on pole position + */ + public readdLastSpeaker(): void { + this.listOfSpeakersRepo.readdLastSpeaker(this.viewListOfSpeakers).catch(this.raiseError); } /** @@ -351,11 +376,16 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit * the operator themself is removed */ public async onDeleteButton(speaker?: ViewSpeaker): Promise { - try { - await this.listOfSpeakersRepo.delete(this.viewListOfSpeakers, speaker ? speaker.id : null); - this.filterUsers(); - } catch (e) { - this.raiseError(e); + const title = this.translate.instant( + 'Are you sure you want to delete this speaker from this list of speakers?' + ); + if (await this.promptService.open(title)) { + try { + await this.listOfSpeakersRepo.delete(this.viewListOfSpeakers, speaker ? speaker.id : null); + this.filterUsers(); + } catch (e) { + this.raiseError(e); + } } } diff --git a/client/src/app/site/motions/modules/category/components/category-motions-sort/category-motions-sort.component.ts b/client/src/app/site/motions/modules/category/components/category-motions-sort/category-motions-sort.component.ts index 9d1cb9484..8928e7d91 100644 --- a/client/src/app/site/motions/modules/category/components/category-motions-sort/category-motions-sort.component.ts +++ b/client/src/app/site/motions/modules/category/components/category-motions-sort/category-motions-sort.component.ts @@ -216,7 +216,7 @@ export class CategoryMotionsSortComponent extends BaseViewComponent implements O if (this.sortSelector.multiSelectedIndex.length) { } const content = this.translate.instant('Move selected items ...'); - const choices = this.sortSelector.array + const choices = this.sortSelector.sortedItems .map((item, index) => { return { id: index, label: item.getTitle() }; }) diff --git a/openslides/agenda/views.py b/openslides/agenda/views.py index efc6618be..faa1da242 100644 --- a/openslides/agenda/views.py +++ b/openslides/agenda/views.py @@ -290,7 +290,13 @@ class ListOfSpeakersViewSet( result = has_perm(self.request.user, "agenda.can_see_list_of_speakers") # For manage_speaker requests the rest of the check is # done in the specific method. See below. - elif self.action in ("update", "partial_update", "speak", "sort_speakers"): + elif self.action in ( + "update", + "partial_update", + "speak", + "sort_speakers", + "readd_last_speaker", + ): result = has_perm( self.request.user, "agenda.can_see_list_of_speakers" ) and has_perm(self.request.user, "agenda.can_manage_list_of_speakers") @@ -344,7 +350,7 @@ class ListOfSpeakersViewSet( # Try to add the user. This ensurse that a user is not twice in the # list of coming speakers. try: - Speaker.objects.add(user, list_of_speakers) + speaker = Speaker.objects.add(user, list_of_speakers) except OpenSlidesError as e: raise ValidationError({"detail": str(e)}) @@ -520,3 +526,32 @@ class ListOfSpeakersViewSet( # Initiate response. return Response({"detail": "List of speakers successfully sorted."}) + + @detail_route(methods=["POST"]) + def readd_last_speaker(self, request, pk=None): + """ + Special view endpoint to re-add the last finished speaker to the list of speakers. + """ + list_of_speakers = self.get_object() + + # Retrieve speaker which spoke last and next speaker + ordered_speakers = list_of_speakers.speakers.order_by("-end_time") + if len(ordered_speakers) == 0: + raise ValidationError({"detail": "There is no last speaker at the moment."}) + + last_speaker = ordered_speakers[0] + if last_speaker.end_time is None: + raise ValidationError({"detail": "There is no last speaker at the moment."}) + + next_speaker = list_of_speakers.get_next_speaker() + new_weight = 1 + # if there is a next speaker, insert last speaker before it + if next_speaker: + new_weight = next_speaker.weight - 1 + + # reset times of last speaker and prepend it to the list of active speakers + last_speaker.begin_time = last_speaker.end_time = None + last_speaker.weight = new_weight + last_speaker.save() + + return Response() diff --git a/tests/integration/agenda/test_viewset.py b/tests/integration/agenda/test_viewset.py index fc7280043..78f957708 100644 --- a/tests/integration/agenda/test_viewset.py +++ b/tests/integration/agenda/test_viewset.py @@ -3,6 +3,7 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse +from django.utils import timezone from rest_framework import status from rest_framework.test import APIClient @@ -295,6 +296,14 @@ class ManageSpeaker(TestCase): password="test_password_e6paev4zeeh9n", ) + def revoke_admin_rights(self): + admin = get_user_model().objects.get(username="admin") + group_admin = admin.groups.get(name="Admin") + group_delegates = type(group_admin).objects.get(name="Delegates") + admin.groups.add(group_delegates) + admin.groups.remove(group_admin) + inform_changed_data(admin) + def test_add_oneself_once(self): response = self.client.post( reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]) @@ -374,12 +383,7 @@ class ManageSpeaker(TestCase): self.assertEqual(response.status_code, 400) def test_add_someone_else_non_admin(self): - admin = get_user_model().objects.get(username="admin") - group_admin = admin.groups.get(name="Admin") - group_delegates = type(group_admin).objects.get(name="Delegates") - admin.groups.add(group_delegates) - admin.groups.remove(group_admin) - inform_changed_data(admin) + self.revoke_admin_rights() response = self.client.post( reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]), @@ -415,12 +419,7 @@ class ManageSpeaker(TestCase): self.assertEqual(response.status_code, 200) def test_remove_someone_else_non_admin(self): - admin = get_user_model().objects.get(username="admin") - group_admin = admin.groups.get(name="Admin") - group_delegates = type(group_admin).objects.get(name="Delegates") - admin.groups.add(group_delegates) - admin.groups.remove(group_admin) - inform_changed_data(admin) + self.revoke_admin_rights() speaker = Speaker.objects.add(self.user, self.list_of_speakers) response = self.client.delete( @@ -441,12 +440,7 @@ class ManageSpeaker(TestCase): self.assertTrue(Speaker.objects.get().marked) def test_mark_speaker_non_admin(self): - admin = get_user_model().objects.get(username="admin") - group_admin = admin.groups.get(name="Admin") - group_delegates = type(group_admin).objects.get(name="Delegates") - admin.groups.add(group_delegates) - admin.groups.remove(group_admin) - inform_changed_data(admin) + self.revoke_admin_rights() Speaker.objects.add(self.user, self.list_of_speakers) response = self.client.patch( @@ -456,6 +450,79 @@ class ManageSpeaker(TestCase): self.assertEqual(response.status_code, 403) + # re-add last speaker + def util_add_user_as_last_speaker(self): + speaker = Speaker.objects.add(self.user, self.list_of_speakers) + speaker.begin_time = timezone.now() + speaker.end_time = timezone.now() + speaker.weight = None + speaker.save() + + def test_readd_last_speaker_no_speaker(self): + response = self.client.post( + reverse( + "listofspeakers-readd-last-speaker", args=[self.list_of_speakers.pk] + ) + ) + self.assertEqual(response.status_code, 400) + + def test_readd_last_speaker_no_last_speaker(self): + Speaker.objects.add(self.user, self.list_of_speakers) + response = self.client.post( + reverse( + "listofspeakers-readd-last-speaker", args=[self.list_of_speakers.pk] + ) + ) + self.assertEqual(response.status_code, 400) + + def test_readd_last_speaker_has_last_speaker_no_next_speaker(self): + self.util_add_user_as_last_speaker() + + response = self.client.post( + reverse( + "listofspeakers-readd-last-speaker", args=[self.list_of_speakers.pk] + ) + ) + self.assertEqual(response.status_code, 200) + speaker = Speaker.objects.get() + self.assertTrue( + speaker.begin_time is None + and speaker.end_time is None + and speaker.weight is not None + ) + + def test_readd_last_speaker_has_last_speaker_and_next_speaker(self): + self.util_add_user_as_last_speaker() + user2 = get_user_model().objects.create_user( + username="test_user_KLGHjkHJKBhjJHGGJKJn", + password="test_password_JHt678VbhjuGhj76hjGA", + ) + Speaker.objects.add(user2, self.list_of_speakers) + + response = self.client.post( + reverse( + "listofspeakers-readd-last-speaker", args=[self.list_of_speakers.pk] + ) + ) + self.assertEqual(response.status_code, 200) + speaker = Speaker.objects.get(user__pk=self.user.pk) + self.assertTrue( + speaker.begin_time is None + and speaker.end_time is None + and speaker.weight is not None + ) + + def test_readd_last_speaker_no_admin(self): + self.util_add_user_as_last_speaker() + self.revoke_admin_rights() + + response = self.client.post( + reverse( + "listofspeakers-readd-last-speaker", args=[self.list_of_speakers.pk] + ) + ) + self.assertEqual(response.status_code, 403) + class Speak(TestCase): """