Merge pull request #5069 from jsangmeister/los-edit-mode

Edit mode for List of Speakers
This commit is contained in:
Finn Stutzenstein 2019-10-15 16:08:10 +03:00 committed by GitHub
commit 7282c541dd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 226 additions and 63 deletions

View File

@ -168,6 +168,16 @@ export class ListOfSpeakersRepositoryService extends BaseHasContentObjectReposit
await this.httpService.post(restUrl, { speakers: speakerIds }); 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<void> {
const restUrl = this.getRestUrl(listOfSpeakers.id, 'readd_last_speaker');
await this.httpService.post(restUrl);
}
/** /**
* Marks all speakers for a given user * 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 listOfSpeakersId id of the list of speakers
* @param method the desired speaker action * @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}/`; return `/rest/agenda/list-of-speakers/${listOfSpeakersId}/${method}/`;
} }
} }

View File

@ -1,10 +1,10 @@
<div cdkDropList [cdkDropListDisabled]="!enable" (cdkDropListDropped)="drop($event)"> <div cdkDropList [cdkDropListDisabled]="!enable" (cdkDropListDropped)="drop($event)">
<div class="line" *ngIf="!array.length"> <div class="line" *ngIf="!sortedItems.length">
<span translate>No data</span> <span translate>No data</span>
</div> </div>
<div <div
[ngClass]="isSelectedRow(i) ? 'backgroundColorSelected line' : 'backgroundColorLight line'" [ngClass]="isSelectedRow(i) ? 'backgroundColorSelected line' : 'backgroundColorLight line'"
*ngFor="let item of array; let i = index" *ngFor="let item of sortedItems; let i = index"
cdkDrag cdkDrag
(click)="onItemClick($event, i)" (click)="onItemClick($event, i)"
(cdkDragStarted)="dragStarted(i)" (cdkDragStarted)="dragStarted(i)"

View File

@ -34,7 +34,7 @@ export class SortingListComponent implements OnInit, OnDestroy {
/** /**
* Sorted and returned * Sorted and returned
*/ */
public array: Selectable[]; public sortedItems: Selectable[];
/** /**
* The index of multiple selected elements. Allows for multiple items to be * The index of multiple selected elements. Allows for multiple items to be
@ -104,6 +104,11 @@ export class SortingListComponent implements OnInit, OnDestroy {
*/ */
private inputSubscription: Subscription | null; private inputSubscription: Subscription | null;
/**
* Always stores the current items from the last update. Needed for restore and changing between live=true/false
*/
private currentItems: Selectable[];
/** /**
* Inform the parent view about sorting. * Inform the parent view about sorting.
* Alternative approach to submit a new order of elements * Alternative approach to submit a new order of elements
@ -118,7 +123,7 @@ export class SortingListComponent implements OnInit, OnDestroy {
* @param translate the translation service * @param translate the translation service
*/ */
public constructor(protected translate: TranslateService) { public constructor(protected translate: TranslateService) {
this.array = []; this.sortedItems = [];
} }
/** /**
@ -141,14 +146,21 @@ export class SortingListComponent implements OnInit, OnDestroy {
* @param newValues The new values to set. * @param newValues The new values to set.
*/ */
private updateArray(newValues: Selectable[]): void { private updateArray(newValues: Selectable[]): void {
if (this.array.length !== newValues.length || this.live) { this.currentItems = newValues.map(val => val);
this.array = []; if (this.sortedItems.length !== newValues.length || this.live) {
this.array = newValues.map(val => val); this.sortedItems = newValues.map(val => val);
} else { } 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 * Handles the start of a dragDrop event and clears multiSelect if the ittem dragged
* is not part of the selected items * is not part of the selected items
@ -171,35 +183,35 @@ export class SortingListComponent implements OnInit, OnDestroy {
dropBehind?: boolean dropBehind?: boolean
): void { ): void {
if (!this.multiSelectedIndex.length) { if (!this.multiSelectedIndex.length) {
moveItemInArray(this.array, event.previousIndex, event.currentIndex); moveItemInArray(this.sortedItems, event.previousIndex, event.currentIndex);
} else { } else {
const before: Selectable[] = []; const before: Selectable[] = [];
const insertions: Selectable[] = []; const insertions: Selectable[] = [];
const behind: 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 (!this.multiSelectedIndex.includes(i)) {
if (i < event.currentIndex) { if (i < event.currentIndex) {
before.push(this.array[i]); before.push(this.sortedItems[i]);
} else if (i > event.currentIndex) { } else if (i > event.currentIndex) {
behind.push(this.array[i]); behind.push(this.sortedItems[i]);
} else { } else {
if (dropBehind === false) { if (dropBehind === false) {
behind.push(this.array[i]); behind.push(this.sortedItems[i]);
} else if (dropBehind === true) { } else if (dropBehind === true) {
before.push(this.array[i]); before.push(this.sortedItems[i]);
} else { } else {
Math.min(...this.multiSelectedIndex) < i Math.min(...this.multiSelectedIndex) < i
? before.push(this.array[i]) ? before.push(this.sortedItems[i])
: behind.push(this.array[i]); : behind.push(this.sortedItems[i]);
} }
} }
} else { } 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 = []; this.multiSelectedIndex = [];
} }

View File

@ -1,12 +1,14 @@
<os-head-bar [nav]="false" [goBack]="true"> <os-head-bar [nav]="false" [goBack]="true" [editMode]="isSortMode" (cancelEditEvent)="onCancelSorting()" (saveEvent)="onSaveSorting()">
<!-- Title --> <!-- Title -->
<div class="title-slot"> <div class="title-slot">
<h2> <h2>
<span *ngIf="!isCurrentListOfSpeakers" translate>List of speakers</span> <span *ngIf="!isCurrentListOfSpeakers && !isSortMode" translate>List of speakers</span>
<span *ngIf="isCurrentListOfSpeakers" translate>Current list of speakers</span> <span *ngIf="isCurrentListOfSpeakers && !isSortMode" translate>Current list of speakers</span>
<span *ngIf="isSortMode" translate>Sort mode</span>
</h2> </h2>
</div> </div>
<div class="menu-slot" *osPerms="['agenda.can_manage_list_of_speakers', 'core.can_manage_projector']"> <div class="menu-slot" *osPerms="['agenda.can_manage_list_of_speakers', 'core.can_manage_projector']">
<button type="button" mat-icon-button matTooltip="{{ 'Re-add last speaker' | translate }}" (click)="readdLastSpeaker()" [disabled]="!finishedSpeakers || !finishedSpeakers.length"><mat-icon>undo</mat-icon></button>
<button type="button" mat-icon-button [matMenuTriggerFor]="speakerMenu"><mat-icon>more_vert</mat-icon></button> <button type="button" mat-icon-button [matMenuTriggerFor]="speakerMenu"><mat-icon>more_vert</mat-icon></button>
</div> </div>
</os-head-bar> </os-head-bar>
@ -77,10 +79,9 @@
<div class="waiting-list" *ngIf="speakers && speakers.length > 0"> <div class="waiting-list" *ngIf="speakers && speakers.length > 0">
<os-sorting-list <os-sorting-list
[input]="speakers" [input]="speakers"
[live]="true" [live]="!isSortMode"
[count]="true" [count]="true"
[enable]="opCanManage()" [enable]="opCanManage() && isSortMode"
(sortEvent)="onSortingChange($event)"
> >
<!-- implicit speaker references into the component using ng-template slot --> <!-- implicit speaker references into the component using ng-template slot -->
<ng-template let-speaker> <ng-template let-speaker>
@ -154,6 +155,11 @@
</mat-card> </mat-card>
<mat-menu #speakerMenu="matMenu"> <mat-menu #speakerMenu="matMenu">
<button mat-menu-item (click)="isSortMode = true">
<mat-icon>sort</mat-icon>
<span translate>Enable sorting</span>
</button>
<os-projector-button <os-projector-button
*ngIf="viewListOfSpeakers && projectors && projectors.length > 1" *ngIf="viewListOfSpeakers && projectors && projectors.length > 1"
[object]="getClosSlide()" [object]="getClosSlide()"

View File

@ -1,4 +1,4 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit, ViewChild } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms'; import { FormControl, FormGroup } from '@angular/forms';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
@ -15,6 +15,7 @@ import { UserRepositoryService } from 'app/core/repositories/users/user-reposito
import { ConfigService } from 'app/core/ui-services/config.service'; import { ConfigService } from 'app/core/ui-services/config.service';
import { DurationService } from 'app/core/ui-services/duration.service'; import { DurationService } from 'app/core/ui-services/duration.service';
import { PromptService } from 'app/core/ui-services/prompt.service'; import { PromptService } from 'app/core/ui-services/prompt.service';
import { SortingListComponent } from 'app/shared/components/sorting-list/sorting-list.component';
import { BaseViewComponent } from 'app/site/base/base-view'; import { BaseViewComponent } from 'app/site/base/base-view';
import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable'; import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable';
import { ViewProjector } from 'app/site/projector/models/view-projector'; import { ViewProjector } from 'app/site/projector/models/view-projector';
@ -33,11 +34,19 @@ import { SpeakerState, ViewSpeaker } from '../../models/view-speaker';
styleUrls: ['./list-of-speakers.component.scss'] styleUrls: ['./list-of-speakers.component.scss']
}) })
export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit { export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit {
@ViewChild(SortingListComponent, { static: false })
public listElement: SortingListComponent;
/** /**
* Determine if the user is viewing the current list if speakers * Determine if the user is viewing the current list if speakers
*/ */
public isCurrentListOfSpeakers = false; public isCurrentListOfSpeakers = false;
/**
* Holds whether the list is in sort mode or not
*/
public isSortMode = false;
/** /**
* Holds the view item to the given topic * Holds the view item to the given topic
*/ */
@ -297,14 +306,25 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit
} }
/** /**
* React to manual in the sorting order. * send the current order of the sorting list to the server
* Informs the repo about changes in the order
* @param listInNewOrder Contains the newly ordered list of ViewSpeakers
*/ */
public onSortingChange(listInNewOrder: ViewSpeaker[]): void { public onSaveSorting(): void {
// extract the ids from the ViewSpeaker array if (this.isSortMode) {
const userIds = listInNewOrder.map(speaker => speaker.id); this.isSortMode = false;
this.listOfSpeakersRepo.sortSpeakers(this.viewListOfSpeakers, userIds).then(null, this.raiseError); 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. * @param speaker The speaker clicked on.
*/ */
public onMarkButton(speaker: ViewSpeaker): void { public onMarkButton(speaker: ViewSpeaker): void {
this.listOfSpeakersRepo this.listOfSpeakersRepo.markSpeaker(this.viewListOfSpeakers, speaker, !speaker.marked).catch(this.raiseError);
.markSpeaker(this.viewListOfSpeakers, speaker, !speaker.marked) }
.then(null, 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,6 +376,10 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit
* the operator themself is removed * the operator themself is removed
*/ */
public async onDeleteButton(speaker?: ViewSpeaker): Promise<void> { public async onDeleteButton(speaker?: ViewSpeaker): Promise<void> {
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 { try {
await this.listOfSpeakersRepo.delete(this.viewListOfSpeakers, speaker ? speaker.id : null); await this.listOfSpeakersRepo.delete(this.viewListOfSpeakers, speaker ? speaker.id : null);
this.filterUsers(); this.filterUsers();
@ -358,6 +387,7 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit
this.raiseError(e); this.raiseError(e);
} }
} }
}
/** /**
* Returns true if the operator is in the list of (waiting) speakers * Returns true if the operator is in the list of (waiting) speakers

View File

@ -216,7 +216,7 @@ export class CategoryMotionsSortComponent extends BaseViewComponent implements O
if (this.sortSelector.multiSelectedIndex.length) { if (this.sortSelector.multiSelectedIndex.length) {
} }
const content = this.translate.instant('Move selected items ...'); const content = this.translate.instant('Move selected items ...');
const choices = this.sortSelector.array const choices = this.sortSelector.sortedItems
.map((item, index) => { .map((item, index) => {
return { id: index, label: item.getTitle() }; return { id: index, label: item.getTitle() };
}) })

View File

@ -290,7 +290,13 @@ class ListOfSpeakersViewSet(
result = has_perm(self.request.user, "agenda.can_see_list_of_speakers") result = has_perm(self.request.user, "agenda.can_see_list_of_speakers")
# For manage_speaker requests the rest of the check is # For manage_speaker requests the rest of the check is
# done in the specific method. See below. # 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( result = has_perm(
self.request.user, "agenda.can_see_list_of_speakers" self.request.user, "agenda.can_see_list_of_speakers"
) and has_perm(self.request.user, "agenda.can_manage_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 # Try to add the user. This ensurse that a user is not twice in the
# list of coming speakers. # list of coming speakers.
try: try:
Speaker.objects.add(user, list_of_speakers) speaker = Speaker.objects.add(user, list_of_speakers)
except OpenSlidesError as e: except OpenSlidesError as e:
raise ValidationError({"detail": str(e)}) raise ValidationError({"detail": str(e)})
@ -520,3 +526,32 @@ class ListOfSpeakersViewSet(
# Initiate response. # Initiate response.
return Response({"detail": "List of speakers successfully sorted."}) 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()

View File

@ -3,6 +3,7 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from rest_framework import status from rest_framework import status
from rest_framework.test import APIClient from rest_framework.test import APIClient
@ -295,6 +296,14 @@ class ManageSpeaker(TestCase):
password="test_password_e6paev4zeeh9n", 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): def test_add_oneself_once(self):
response = self.client.post( response = self.client.post(
reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]) reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk])
@ -374,12 +383,7 @@ class ManageSpeaker(TestCase):
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
def test_add_someone_else_non_admin(self): def test_add_someone_else_non_admin(self):
admin = get_user_model().objects.get(username="admin") self.revoke_admin_rights()
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)
response = self.client.post( response = self.client.post(
reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]), reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]),
@ -415,12 +419,7 @@ class ManageSpeaker(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_remove_someone_else_non_admin(self): def test_remove_someone_else_non_admin(self):
admin = get_user_model().objects.get(username="admin") self.revoke_admin_rights()
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)
speaker = Speaker.objects.add(self.user, self.list_of_speakers) speaker = Speaker.objects.add(self.user, self.list_of_speakers)
response = self.client.delete( response = self.client.delete(
@ -441,12 +440,7 @@ class ManageSpeaker(TestCase):
self.assertTrue(Speaker.objects.get().marked) self.assertTrue(Speaker.objects.get().marked)
def test_mark_speaker_non_admin(self): def test_mark_speaker_non_admin(self):
admin = get_user_model().objects.get(username="admin") self.revoke_admin_rights()
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)
Speaker.objects.add(self.user, self.list_of_speakers) Speaker.objects.add(self.user, self.list_of_speakers)
response = self.client.patch( response = self.client.patch(
@ -456,6 +450,79 @@ class ManageSpeaker(TestCase):
self.assertEqual(response.status_code, 403) 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): class Speak(TestCase):
""" """