Add "point of order" feature to ListOfSpeakers

Adds the option "point of order" to the list of speakers
- You can make a point of order even though you normally have no
permission to add yourself to the ListOfSpeakers
- You can make a point of order even though you are already on theListOfSpeakers (but you may only be there once)
- new points of order will be on top of the list of speakers
- Point of orders will be highlighted by a red triangle

This feature can be used to request to speak with a higher level of
urgency
This commit is contained in:
Sean 2020-10-14 15:08:14 +02:00 committed by Finn Stutzenstein
parent 266f9b73e9
commit ccc48e6b3f
No known key found for this signature in database
GPG Key ID: 9042F605C6324654
16 changed files with 416 additions and 94 deletions

View File

@ -143,6 +143,14 @@ export class ListOfSpeakersRepositoryService extends BaseHasContentObjectReposit
} }
}; };
public async delete(viewModel: ViewListOfSpeakers): Promise<void> {
throw new Error('Not supported');
}
public async create(model: ListOfSpeakers): Promise<Identifiable> {
throw new Error('Not supported');
}
/** /**
* Add a new speaker to a list of speakers. * Add a new speaker to a list of speakers.
* Sends the users id to the server * Sends the users id to the server
@ -150,9 +158,13 @@ export class ListOfSpeakersRepositoryService extends BaseHasContentObjectReposit
* @param userId {@link User} id of the new speaker * @param userId {@link User} id of the new speaker
* @param listOfSpeakers the target agenda item * @param listOfSpeakers the target agenda item
*/ */
public async createSpeaker(listOfSpeakers: ViewListOfSpeakers, userId: number): Promise<Identifiable> { public async createSpeaker(
listOfSpeakers: ViewListOfSpeakers,
userId: number,
pointOfOrder?: boolean
): Promise<Identifiable> {
const restUrl = this.getRestUrl(listOfSpeakers.id, 'manage_speaker'); const restUrl = this.getRestUrl(listOfSpeakers.id, 'manage_speaker');
return await this.httpService.post<Identifiable>(restUrl, { user: userId }); return await this.httpService.post<Identifiable>(restUrl, { user: userId, point_of_order: pointOfOrder });
} }
/** /**
@ -162,9 +174,20 @@ export class ListOfSpeakersRepositoryService extends BaseHasContentObjectReposit
* @param speakerId (otional) the speakers id. If no id is given, the speaker with the * @param speakerId (otional) the speakers id. If no id is given, the speaker with the
* current operator is removed. * current operator is removed.
*/ */
public async delete(listOfSpeakers: ViewListOfSpeakers, speakerId?: number): Promise<void> { public async deleteSpeaker(
listOfSpeakers: ViewListOfSpeakers,
speakerId?: number,
pointOfOrder?: boolean
): Promise<void> {
const restUrl = this.getRestUrl(listOfSpeakers.id, 'manage_speaker'); const restUrl = this.getRestUrl(listOfSpeakers.id, 'manage_speaker');
await this.httpService.delete(restUrl, speakerId ? { speaker: speakerId } : null); const payload: { speaker?: number; point_of_order?: boolean } = {};
if (speakerId) {
payload.speaker = speakerId;
}
if (pointOfOrder) {
payload.point_of_order = pointOfOrder;
}
await this.httpService.delete(restUrl, payload);
} }
/** /**

View File

@ -4,9 +4,7 @@
<span> <span>
{{ title }} {{ title }}
</span> </span>
<mat-icon *ngIf="closed" matTooltip="{{ 'The list of speakers is closed.' | translate }}"> <mat-icon *ngIf="closed" matTooltip="{{ 'The list of speakers is closed.' | translate }}"> lock </mat-icon>
lock
</mat-icon>
</h1> </h1>
<span *ngIf="customTitle"> <span *ngIf="customTitle">
<ng-content></ng-content> <ng-content></ng-content>
@ -22,6 +20,7 @@
<div class="finished-speaker-grid"> <div class="finished-speaker-grid">
<div class="number">{{ number + 1 }}.</div> <div class="number">{{ number + 1 }}.</div>
<div class="name">{{ speaker.getTitle() }}</div> <div class="name">{{ speaker.getTitle() }}</div>
<div class="point-of-order" *ngIf="speaker.point_of_order"><mat-icon color="warn">warning</mat-icon></div>
<div class="time"> <div class="time">
{{ durationString(speaker) }} ({{ 'Start time' | translate }}: {{ startTimeToString(speaker) }}) {{ durationString(speaker) }} ({{ 'Start time' | translate }}: {{ startTimeToString(speaker) }})
</div> </div>
@ -30,7 +29,7 @@
mat-icon-button mat-icon-button
matTooltip="{{ 'Remove' | translate }}" matTooltip="{{ 'Remove' | translate }}"
*osPerms="'agenda.can_manage_list_of_speakers'" *osPerms="'agenda.can_manage_list_of_speakers'"
(click)="onDeleteButton(speaker)" (click)="removeSpeaker(speaker)"
> >
<mat-icon>close</mat-icon> <mat-icon>close</mat-icon>
</button> </button>
@ -53,6 +52,15 @@
<span class="name">{{ activeSpeaker.getListTitle() }}</span> <span class="name">{{ activeSpeaker.getListTitle() }}</span>
<span class="suffix"> <span class="suffix">
<!-- point of order visible for everyone -->
<button
mat-icon-button
matTooltip="{{ 'Point of order' | translate }}"
*ngIf="activeSpeaker.point_of_order"
>
<mat-icon color="warn">warning</mat-icon>
</button>
<!-- Stop speaker button --> <!-- Stop speaker button -->
<button <button
mat-icon-button mat-icon-button
@ -76,7 +84,7 @@
> >
<!-- 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>
<span *osPerms="'agenda.can_manage_list_of_speakers'"> <span *osPerms="'agenda.can_manage_list_of_speakers'; and: !speaker.point_of_order">
<!-- Speaker count --> <!-- Speaker count -->
<span *ngIf="hasSpokenCount(speaker)" class="red-warning-text speaker-warning"> <span *ngIf="hasSpokenCount(speaker)" class="red-warning-text speaker-warning">
{{ hasSpokenCount(speaker) + 1 }}. <span>{{ 'contribution' | translate }}</span> {{ hasSpokenCount(speaker) + 1 }}. <span>{{ 'contribution' | translate }}</span>
@ -95,12 +103,13 @@
</span> </span>
<!-- Start, start and delete buttons --> <!-- Start, start and delete buttons -->
<span *osPerms="'agenda.can_manage_list_of_speakers'"> <span>
<!-- start button --> <!-- start button -->
<button <button
mat-icon-button mat-icon-button
matTooltip="{{ 'Begin speech' | translate }}" matTooltip="{{ 'Begin speech' | translate }}"
(click)="onStartButton(speaker)" (click)="onStartButton(speaker)"
*osPerms="'agenda.can_manage_list_of_speakers'"
> >
<mat-icon>play_arrow</mat-icon> <mat-icon>play_arrow</mat-icon>
</button> </button>
@ -110,21 +119,34 @@
mat-icon-button mat-icon-button
matTooltip="{{ 'Mark speaker' | translate }}" matTooltip="{{ 'Mark speaker' | translate }}"
(click)="onMarkButton(speaker)" (click)="onMarkButton(speaker)"
*osPerms="'agenda.can_manage_list_of_speakers'; and: !speaker.point_of_order"
> >
<mat-icon>{{ speaker.marked ? 'star' : 'star_border' }}</mat-icon> <mat-icon>{{ speaker.marked ? 'star' : 'star_border' }}</mat-icon>
</button> </button>
<!-- point of order visible for everyone -->
<button
mat-icon-button
matTooltip="{{ 'Point of order' | translate }}"
*ngIf="speaker.point_of_order"
>
<mat-icon color="warn"> warning </mat-icon>
</button>
<!-- delete button --> <!-- delete button -->
<button mat-icon-button matTooltip="{{ 'Remove' | translate }}" (click)="onDeleteButton(speaker)"> <button
mat-icon-button
matTooltip="{{ 'Remove' | translate }}"
(click)="removeSpeaker(speaker)"
*osPerms="'agenda.can_manage_list_of_speakers'"
>
<mat-icon>close</mat-icon> <mat-icon>close</mat-icon>
</button> </button>
</span> </span>
<!-- For thouse without LOS --> <!-- For thouse without LOS -->
<span *osPerms="'agenda.can_manage_list_of_speakers'; complement: true"> <span *osPerms="'agenda.can_manage_list_of_speakers'; complement: true">
<mat-icon *ngIf="speaker.marked"> <mat-icon *ngIf="speaker.marked"> star </mat-icon>
star
</mat-icon>
</span> </span>
</ng-template> </ng-template>
</os-sorting-list> </os-sorting-list>
@ -155,17 +177,37 @@
</form> </form>
</div> </div>
<div class="add-self-buttons">
<!-- Add me and remove me if OP has correct permission --> <!-- Add me and remove me if OP has correct permission -->
<div *osPerms="'agenda.can_be_speaker'" class="add-self-buttons">
<div *ngIf="waitingSpeakers && !closed"> <button
<button mat-stroked-button [disabled]="closed" (click)="addNewSpeaker()" *ngIf="!isOpInList && canAddSelf"> *osPerms="'agenda.can_be_speaker'; and: !isOpInWaitlist()"
mat-stroked-button
[disabled]="closed || !canAddSelf"
(click)="addUserAsNewSpeaker()"
>
<mat-icon>add</mat-icon> <mat-icon>add</mat-icon>
<span>{{ 'Add me' | translate }}</span> <span>{{ 'Add me' | translate }}</span>
</button> </button>
<button mat-stroked-button (click)="onDeleteButton()" *ngIf="isOpInList">
<button
*osPerms="'agenda.can_be_speaker'; and: isOpInWaitlist()"
mat-stroked-button
[disabled]="closed"
(click)="removeSpeaker()"
>
<mat-icon>remove</mat-icon> <mat-icon>remove</mat-icon>
<span>{{ 'Remove me' | translate }}</span> <span>{{ 'Remove me' | translate }}</span>
</button> </button>
</div>
<!-- Point Of order -->
<button mat-raised-button color="warn" (click)="addPointOfOrder()" *ngIf="showPointOfOrders && !isOpInWaitlist(true)">
<mat-icon>warning</mat-icon>
<span>{{ 'Point of order' | translate }}</span>
</button>
<button mat-raised-button color="warn" (click)="removePointOfOrder()" *ngIf="showPointOfOrders && isOpInWaitlist(true)">
<mat-icon>warning</mat-icon>
<span>{{ 'Remove point of order request' | translate }}</span>
</button>
</div> </div>
</mat-card> </mat-card>

View File

@ -26,7 +26,7 @@
@include desktop { @include desktop {
.finished-speaker-grid { .finished-speaker-grid {
grid-template-areas: 'number name time controls'; grid-template-areas: 'number name point-of-order time controls';
grid-template-columns: 30px 1fr min-content min-content; grid-template-columns: 30px 1fr min-content min-content;
} }
} }
@ -41,6 +41,18 @@
margin: 0; margin: 0;
} }
.point-of-order {
grid-area: point-of-order;
margin: 0;
//allows pushing this grid area as small as possible and aligns the end to the same level
white-space: nowrap;
font-size: 80%;
.mat-icon {
line-height: 19px;
}
}
.time { .time {
grid-area: time; grid-area: time;
margin: 0; margin: 0;
@ -102,7 +114,7 @@
} }
.search-new-speaker-form { .search-new-speaker-form {
padding: 15px 25px 10px 25px; padding: 15px 25px 0 25px;
width: auto; width: auto;
.search-users-field { .search-users-field {
@ -117,7 +129,12 @@
} }
.add-self-buttons { .add-self-buttons {
padding: 15px 0 20px 25px; margin: 15px 25px;
display: flex;
button + button {
margin-left: 20px;
}
} }
.speaker-warning { .speaker-warning {

View File

@ -58,6 +58,8 @@ export class ListOfSpeakersContentComponent extends BaseViewComponentDirective i
public showFistContributionHint: boolean; public showFistContributionHint: boolean;
public showPointOfOrders: boolean;
public get title(): string { public get title(): string {
return this.viewListOfSpeakers?.getTitle(); return this.viewListOfSpeakers?.getTitle();
} }
@ -70,10 +72,6 @@ export class ListOfSpeakersContentComponent extends BaseViewComponentDirective i
return this.operator.hasPerms(this.permission.agendaCanManageListOfSpeakers); return this.operator.hasPerms(this.permission.agendaCanManageListOfSpeakers);
} }
public get isOpInList(): boolean {
return this.waitingSpeakers.some(speaker => speaker.user_id === this.operator.user.id);
}
public get canAddSelf(): boolean { public get canAddSelf(): boolean {
return !this.config.instant('agenda_present_speakers_only') || this.operator.user.is_present; return !this.config.instant('agenda_present_speakers_only') || this.operator.user.is_present;
} }
@ -98,7 +96,7 @@ export class ListOfSpeakersContentComponent extends BaseViewComponentDirective i
private isListOfSpeakersEmptyEvent = new EventEmitter<boolean>(); private isListOfSpeakersEmptyEvent = new EventEmitter<boolean>();
@Output() @Output()
private hasFinishesSpeakersEvent = new EventEmitter<boolean>(); private canReaddLastSpeakerEvent = new EventEmitter<boolean>();
public constructor( public constructor(
title: Title, title: Title,
@ -129,7 +127,7 @@ export class ListOfSpeakersContentComponent extends BaseViewComponentDirective i
this.addSpeakerForm.valueChanges.subscribe(formResult => { this.addSpeakerForm.valueChanges.subscribe(formResult => {
// resetting a form triggers a form.next(null) - check if user_id // resetting a form triggers a form.next(null) - check if user_id
if (formResult && formResult.user_id) { if (formResult && formResult.user_id) {
this.addNewSpeaker(formResult.user_id); this.addUserAsNewSpeaker(formResult.user_id);
} }
}), }),
// observe changes to the viewport // observe changes to the viewport
@ -144,6 +142,10 @@ export class ListOfSpeakersContentComponent extends BaseViewComponentDirective i
// observe changes to the agenda_show_first_contribution config // observe changes to the agenda_show_first_contribution config
this.config.get<boolean>('agenda_show_first_contribution').subscribe(show => { this.config.get<boolean>('agenda_show_first_contribution').subscribe(show => {
this.showFistContributionHint = show; this.showFistContributionHint = show;
}),
// observe point of order settings
this.config.get<boolean>('agenda_enable_point_of_order_speakers').subscribe(show => {
this.showPointOfOrders = show;
}) })
); );
} }
@ -157,8 +159,16 @@ export class ListOfSpeakersContentComponent extends BaseViewComponentDirective i
return this.isListOfSpeakersEmptyEvent.emit(!this.activeSpeaker); return this.isListOfSpeakersEmptyEvent.emit(!this.activeSpeaker);
} }
private hasFinishesSpeakers(): void { private updateCanReaddLastSpeaker(): void {
this.hasFinishesSpeakersEvent.emit(this.finishedSpeakers?.length > 0); let canReaddLast;
if (this.finishedSpeakers?.length > 0) {
const lastSpeaker = this.finishedSpeakers[this.finishedSpeakers.length - 1];
const isLastSpeakerWaiting = this.waitingSpeakers.some(speaker => speaker.user_id === lastSpeaker.user_id);
canReaddLast = !lastSpeaker.point_of_order && !isLastSpeakerWaiting;
} else {
canReaddLast = false;
}
this.canReaddLastSpeakerEvent.emit(canReaddLast);
} }
/** /**
@ -166,10 +176,13 @@ export class ListOfSpeakersContentComponent extends BaseViewComponentDirective i
* *
* @param userId the user id to add to the list. No parameter adds the operators user as speaker. * @param userId the user id to add to the list. No parameter adds the operators user as speaker.
*/ */
public addNewSpeaker(userId?: number): void { public async addUserAsNewSpeaker(userId?: number): Promise<void> {
this.listOfSpeakersRepo try {
.createSpeaker(this.viewListOfSpeakers, userId) await this.listOfSpeakersRepo.createSpeaker(this.viewListOfSpeakers, userId);
.then(() => this.addSpeakerForm.reset(), this.raiseError); this.addSpeakerForm.reset();
} catch (e) {
this.raiseError(e);
}
} }
/** /**
@ -178,13 +191,13 @@ export class ListOfSpeakersContentComponent extends BaseViewComponentDirective i
* @param speaker optional speaker to remove. If none is given, * @param speaker optional speaker to remove. If none is given,
* the operator themself is removed * the operator themself is removed
*/ */
public async onDeleteButton(speaker?: ViewSpeaker): Promise<void> { public async removeSpeaker(speaker?: ViewSpeaker): Promise<void> {
const title = this.translate.instant( const title = this.translate.instant(
'Are you sure you want to delete this speaker from this list of speakers?' 'Are you sure you want to delete this speaker from this list of speakers?'
); );
if (await this.promptService.open(title)) { if (await this.promptService.open(title)) {
try { try {
await this.listOfSpeakersRepo.delete(this.viewListOfSpeakers, speaker ? speaker.id : null); await this.listOfSpeakersRepo.deleteSpeaker(this.viewListOfSpeakers, speaker ? speaker.id : null);
this.filterUsers(); this.filterUsers();
} catch (e) { } catch (e) {
this.raiseError(e); this.raiseError(e);
@ -192,6 +205,30 @@ export class ListOfSpeakersContentComponent extends BaseViewComponentDirective i
} }
} }
public async addPointOfOrder(): Promise<void> {
const title = this.translate.instant('Are you sure you want to submit a new point of order?');
if (await this.promptService.open(title)) {
try {
await this.listOfSpeakersRepo.createSpeaker(this.viewListOfSpeakers, undefined, true);
} catch (e) {
this.raiseError(e);
}
}
}
public removePointOfOrder(): void {
this.listOfSpeakersRepo.deleteSpeaker(this.viewListOfSpeakers, undefined, true).catch(this.raiseError);
}
public isOpInWaitlist(pointOfOrder: boolean = false): boolean {
if (!this.waitingSpeakers) {
return false;
}
return this.waitingSpeakers.some(
speaker => speaker.user_id === this.operator.user.id && speaker.point_of_order === pointOfOrder
);
}
/** /**
* Click on the mic button to mark a speaker as speaking * Click on the mic button to mark a speaker as speaking
* *
@ -268,7 +305,7 @@ export class ListOfSpeakersContentComponent extends BaseViewComponentDirective i
}); });
this.activeSpeaker = allSpeakers?.find(speaker => speaker.state === SpeakerState.CURRENT); this.activeSpeaker = allSpeakers?.find(speaker => speaker.state === SpeakerState.CURRENT);
this.hasFinishesSpeakers(); this.updateCanReaddLastSpeaker();
this.isListOfSpeakersEmpty(); this.isListOfSpeakersEmpty();
} }
@ -293,7 +330,7 @@ export class ListOfSpeakersContentComponent extends BaseViewComponentDirective i
*/ */
public async onCreateUser(username: string): Promise<void> { public async onCreateUser(username: string): Promise<void> {
const newUser = await this.userRepository.createFromString(username); const newUser = await this.userRepository.createFromString(username);
this.addNewSpeaker(newUser.id); this.addUserAsNewSpeaker(newUser.id);
} }
/** /**

View File

@ -33,7 +33,6 @@
.section-three { .section-three {
display: table-cell; display: table-cell;
padding-right: 10px; padding-right: 10px;
vertical-align: middle;
width: auto; width: auto;
white-space: nowrap; white-space: nowrap;
} }

View File

@ -15,6 +15,7 @@ export class Speaker extends BaseModel<Speaker> {
public weight: number; public weight: number;
public marked: boolean; public marked: boolean;
public item_id: number; public item_id: number;
public point_of_order: boolean;
/** /**
* ISO datetime string to indicate the begin time of the speech. Empty if * ISO datetime string to indicate the begin time of the speech. Empty if

View File

@ -18,7 +18,7 @@
mat-icon-button mat-icon-button
matTooltip="{{ 'Re-add last speaker' | translate }}" matTooltip="{{ 'Re-add last speaker' | translate }}"
(click)="readdLastSpeaker()" (click)="readdLastSpeaker()"
[disabled]="!hasFinishedSpeakers" [disabled]="!canReaddLastSpeaker"
> >
<mat-icon>undo</mat-icon> <mat-icon>undo</mat-icon>
</button> </button>
@ -31,7 +31,7 @@
[speakers]="viewListOfSpeakers" [speakers]="viewListOfSpeakers"
[sortMode]="manualSortMode" [sortMode]="manualSortMode"
(isListOfSpeakersEmptyEvent)="isListOfSpeakersEmpty = $event" (isListOfSpeakersEmptyEvent)="isListOfSpeakersEmpty = $event"
(hasFinishesSpeakersEvent)="hasFinishedSpeakers = $event" (canReaddLastSpeakerEvent)="canReaddLastSpeaker = $event"
></os-list-of-speakers-content> ></os-list-of-speakers-content>
<mat-menu #speakerMenu="matMenu"> <mat-menu #speakerMenu="matMenu">

View File

@ -63,7 +63,7 @@ export class ListOfSpeakersComponent extends BaseViewComponentDirective implemen
/** /**
* filled by child component * filled by child component
*/ */
public hasFinishedSpeakers: boolean; public canReaddLastSpeaker: boolean;
/** /**
* Constructor for speaker list component. Generates the forms. * Constructor for speaker list component. Generates the forms.

View File

@ -15,7 +15,6 @@
"order": ["static-field", "static-method", "instance-field", "constructor", "instance-method"] "order": ["static-field", "static-method", "instance-field", "constructor", "instance-method"]
} }
], ],
"no-unused-variable": true,
"typedef": [ "typedef": [
true, true,
"call-signature", "call-signature",

View File

@ -136,6 +136,16 @@ def get_config_variables():
validators=(MinValueValidator(-1),), validators=(MinValueValidator(-1),),
) )
yield ConfigVariable(
name="agenda_enable_point_of_order_speakers",
default_value=False,
input_type="boolean",
label="Enables point of order speakers",
weight=223,
group="Agenda",
subgroup="List of speakers",
)
yield ConfigVariable( yield ConfigVariable(
name="agenda_countdown_warning_time", name="agenda_countdown_warning_time",
default_value=0, default_value=0,

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.16 on 2020-10-13 11:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("agenda", "0009_item_tags"),
]
operations = [
migrations.AddField(
model_name="speaker",
name="point_of_order",
field=models.BooleanField(default=False),
),
]

View File

@ -424,7 +424,7 @@ class SpeakerManager(models.Manager):
Manager for Speaker model. Provides a customized add method. Manager for Speaker model. Provides a customized add method.
""" """
def add(self, user, list_of_speakers, skip_autoupdate=False): def add(self, user, list_of_speakers, skip_autoupdate=False, point_of_order=False):
""" """
Customized manager method to prevent anonymous users to be on the Customized manager method to prevent anonymous users to be on the
list of speakers and that someone is twice on one list (off coming list of speakers and that someone is twice on one list (off coming
@ -433,20 +433,39 @@ class SpeakerManager(models.Manager):
if isinstance(user, AnonymousUser): if isinstance(user, AnonymousUser):
raise OpenSlidesError("An anonymous user can not be on lists of speakers.") raise OpenSlidesError("An anonymous user can not be on lists of speakers.")
if point_of_order and not config["agenda_enable_point_of_order_speakers"]:
raise OpenSlidesError("Point of order speakers are not enabled.")
if self.filter( if self.filter(
user=user, list_of_speakers=list_of_speakers, begin_time=None user=user,
list_of_speakers=list_of_speakers,
begin_time=None,
point_of_order=point_of_order,
).exists(): ).exists():
raise OpenSlidesError(f"{user} is already on the list of speakers.") raise OpenSlidesError(f"{user} is already on the list of speakers.")
if config["agenda_present_speakers_only"] and not user.is_present: if config["agenda_present_speakers_only"] and not user.is_present:
raise OpenSlidesError("Only present users can be on the lists of speakers.") raise OpenSlidesError("Only present users can be on the lists of speakers.")
if point_of_order:
weight = (
self.filter(list_of_speakers=list_of_speakers).aggregate(
models.Min("weight")
)["weight__min"]
or 0
) - 1
else:
weight = ( weight = (
self.filter(list_of_speakers=list_of_speakers).aggregate( self.filter(list_of_speakers=list_of_speakers).aggregate(
models.Max("weight") models.Max("weight")
)["weight__max"] )["weight__max"]
or 0 or 0
) ) + 1
speaker = self.model( speaker = self.model(
list_of_speakers=list_of_speakers, user=user, weight=weight + 1 list_of_speakers=list_of_speakers,
user=user,
weight=weight,
point_of_order=point_of_order,
) )
speaker.save( speaker.save(
force_insert=True, force_insert=True,
@ -495,6 +514,11 @@ class Speaker(RESTModelMixin, models.Model):
Marks a speaker. Marks a speaker.
""" """
point_of_order = models.BooleanField(default=False)
"""
Identifies the speaker as someone with a point of order
"""
class Meta: class Meta:
default_permissions = () default_permissions = ()
permissions = (("can_be_speaker", "Can put oneself on the list of speakers"),) permissions = (("can_be_speaker", "Can put oneself on the list of speakers"),)

View File

@ -10,7 +10,15 @@ class SpeakerSerializer(ModelSerializer):
class Meta: class Meta:
model = Speaker model = Speaker
fields = ("id", "user", "begin_time", "end_time", "weight", "marked") fields = (
"id",
"user",
"begin_time",
"end_time",
"weight",
"marked",
"point_of_order",
)
class RelatedItemRelatedField(RelatedField): class RelatedItemRelatedField(RelatedField):

View File

@ -310,8 +310,10 @@ class ListOfSpeakersViewSet(
def manage_speaker(self, request, pk=None): def manage_speaker(self, request, pk=None):
""" """
Special view endpoint to add users to the list of speakers or remove Special view endpoint to add users to the list of speakers or remove
them. Send POST {'user': <user_id>} to add a new speaker. Omit them. Send POST {'user': <user_id>} to add a new speaker.
data to add yourself. Send DELETE {'speaker': <speaker_id>} or Send POST {'user': <user_id>, 'point_of_order': True } to add a point
of order to the list of speakers.
Omit data to add yourself. Send DELETE {'speaker': <speaker_id>} or
DELETE {'speaker': [<speaker_id>, <speaker_id>, ...]} to remove one or DELETE {'speaker': [<speaker_id>, <speaker_id>, ...]} to remove one or
more speakers from the list of speakers. Omit data to remove yourself. more speakers from the list of speakers. Omit data to remove yourself.
Send PATCH {'user': <user_id>, 'marked': <bool>} to mark the speaker. Send PATCH {'user': <user_id>, 'marked': <bool>} to mark the speaker.
@ -329,16 +331,22 @@ class ListOfSpeakersViewSet(
if request.method == "POST": # Add new speaker if request.method == "POST": # Add new speaker
# Retrieve user_id # Retrieve user_id
user_id = request.data.get("user") user_id = request.data.get("user")
point_of_order = request.data.get("point_of_order") or False
if not isinstance(point_of_order, bool):
raise ValidationError({"detail": "point_of_order has to be a bool."})
# Check permissions and other conditions. Get user instance. # Check permissions and other conditions. Get user instance.
if user_id is None: if user_id is None:
# Add oneself # Add oneself
if not has_perm(self.request.user, "agenda.can_be_speaker"): if not point_of_order and not has_perm(
self.request.user, "agenda.can_be_speaker"
):
self.permission_denied(request) self.permission_denied(request)
if list_of_speakers.closed: if list_of_speakers.closed:
raise ValidationError({"detail": "The list of speakers is closed."}) raise ValidationError({"detail": "The list of speakers is closed."})
user = self.request.user user = self.request.user
else: else:
point_of_order = False # not for someone else
# Add someone else. # Add someone else.
if not has_perm( if not has_perm(
self.request.user, "agenda.can_manage_list_of_speakers" self.request.user, "agenda.can_manage_list_of_speakers"
@ -352,7 +360,9 @@ 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 = Speaker.objects.add(user, list_of_speakers) speaker = Speaker.objects.add(
user, list_of_speakers, point_of_order=point_of_order
)
except OpenSlidesError as e: except OpenSlidesError as e:
raise ValidationError({"detail": str(e)}) raise ValidationError({"detail": str(e)})
@ -360,7 +370,7 @@ class ListOfSpeakersViewSet(
# to see users may not have it but can get it now. # to see users may not have it but can get it now.
inform_changed_data(user, disable_history=True) inform_changed_data(user, disable_history=True)
# Toggle 'marked' for the speaker # Set 'marked' for the speaker
elif request.method == "PATCH": elif request.method == "PATCH":
# Check permissions # Check permissions
if not has_perm(self.request.user, "agenda.can_manage_list_of_speakers"): if not has_perm(self.request.user, "agenda.can_manage_list_of_speakers"):
@ -380,17 +390,12 @@ class ListOfSpeakersViewSet(
queryset = Speaker.objects.filter( queryset = Speaker.objects.filter(
list_of_speakers=list_of_speakers, user=user, begin_time=None list_of_speakers=list_of_speakers, user=user, begin_time=None
) )
try:
# We assume that there aren't multiple entries for speakers that if not queryset.exists():
# did not yet begin to speak, because this
# is forbidden by the Manager's add method. We assume that
# there is only one speaker instance or none.
speaker = queryset.get()
except Speaker.DoesNotExist:
raise ValidationError( raise ValidationError(
{"detail": "The user is not in the list of speakers."} {"detail": "The user is not in the list of speakers."}
) )
else: for speaker in queryset.all():
speaker.marked = marked speaker.marked = marked
speaker.save() speaker.save()
@ -400,21 +405,29 @@ class ListOfSpeakersViewSet(
# Check permissions and other conditions. Get speaker instance. # Check permissions and other conditions. Get speaker instance.
if speaker_ids is None: if speaker_ids is None:
point_of_order = request.data.get("point_of_order") or False
if not isinstance(point_of_order, bool):
raise ValidationError(
{"detail": "point_of_order has to be a bool."}
)
# Remove oneself # Remove oneself
queryset = Speaker.objects.filter( queryset = Speaker.objects.filter(
list_of_speakers=list_of_speakers, user=self.request.user list_of_speakers=list_of_speakers,
user=self.request.user,
point_of_order=point_of_order,
).exclude(weight=None) ).exclude(weight=None)
try:
# We assume that there aren't multiple entries because this if not queryset.exists():
# is forbidden by the Manager's add method. We assume that
# there is only one speaker instance or none.
speaker = queryset.get()
except Speaker.DoesNotExist:
raise ValidationError( raise ValidationError(
{"detail": "You are not on the list of speakers."} {"detail": "The user is not in the list of speakers."}
) )
else: # We delete all() from the queryset and do not use get():
speaker.delete() # The Speaker.objects.add method should assert, that there
# is only one speaker. But due to race conditions, sometimes
# there are multiple ones. Using all() ensures, that there is
# no server crash, if this happens.
queryset.all().delete()
inform_changed_data(list_of_speakers)
else: else:
# Remove someone else. # Remove someone else.
if not has_perm( if not has_perm(
@ -423,7 +436,7 @@ class ListOfSpeakersViewSet(
self.permission_denied(request) self.permission_denied(request)
if isinstance(speaker_ids, int): if isinstance(speaker_ids, int):
speaker_ids = [speaker_ids] speaker_ids = [speaker_ids]
deleted_speaker_count = 0 deleted_some_speakers = False
for speaker_id in speaker_ids: for speaker_id in speaker_ids:
try: try:
speaker = Speaker.objects.get(pk=int(speaker_id)) speaker = Speaker.objects.get(pk=int(speaker_id))
@ -431,9 +444,9 @@ class ListOfSpeakersViewSet(
pass pass
else: else:
speaker.delete(skip_autoupdate=True) speaker.delete(skip_autoupdate=True)
deleted_speaker_count += 1 deleted_some_speakers = True
# send autoupdate if speakers are deleted # send autoupdate if speakers are deleted
if deleted_speaker_count > 0: if deleted_some_speakers:
inform_changed_data(list_of_speakers) inform_changed_data(list_of_speakers)
return Response() return Response()
@ -544,6 +557,16 @@ class ListOfSpeakersViewSet(
if not last_speaker: if not last_speaker:
raise ValidationError({"detail": "There is no last speaker at the moment."}) raise ValidationError({"detail": "There is no last speaker at the moment."})
if last_speaker.point_of_order:
raise ValidationError(
{"detail": "You cannot readd a point of order speaker."}
)
if list_of_speakers.speakers.filter(
user=last_speaker.user, begin_time=None
).exists():
raise ValidationError({"detail": "The last speaker is already waiting."})
next_speaker = list_of_speakers.get_next_speaker() next_speaker = list_of_speakers.get_next_speaker()
new_weight = 1 new_weight = 1
# if there is a next speaker, insert last speaker before it # if there is a next speaker, insert last speaker before it

View File

@ -294,6 +294,7 @@ class ManageSpeaker(TestCase):
title="test_title_aZaedij4gohn5eeQu8fe" title="test_title_aZaedij4gohn5eeQu8fe"
).list_of_speakers ).list_of_speakers
self.user, _ = self.create_user() self.user, _ = self.create_user()
self.admin = get_user_model().objects.get(username="admin")
def test_add_oneself_once(self): def test_add_oneself_once(self):
response = self.client.post( response = self.client.post(
@ -303,9 +304,7 @@ class ManageSpeaker(TestCase):
self.assertTrue(Speaker.objects.all().exists()) self.assertTrue(Speaker.objects.all().exists())
def test_add_oneself_twice(self): def test_add_oneself_twice(self):
Speaker.objects.add( Speaker.objects.add(self.admin, self.list_of_speakers)
get_user_model().objects.get(username="admin"), self.list_of_speakers
)
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])
) )
@ -320,9 +319,19 @@ class ManageSpeaker(TestCase):
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
def test_remove_oneself(self): def test_remove_oneself(self):
Speaker.objects.add( Speaker.objects.add(self.admin, self.list_of_speakers)
get_user_model().objects.get(username="admin"), self.list_of_speakers response = self.client.delete(
reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk])
) )
self.assertEqual(response.status_code, 200)
self.assertFalse(Speaker.objects.all().exists())
def test_remove_oneself_if_twice_on_los(self):
# This one is a test, if there is malformed speaker data, that
# this method still works.
Speaker.objects.add(self.admin, self.list_of_speakers)
s2 = Speaker(user=self.admin, list_of_speakers=self.list_of_speakers, weight=2)
s2.save()
response = self.client.delete( response = self.client.delete(
reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]) reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk])
) )
@ -347,6 +356,94 @@ class ManageSpeaker(TestCase):
).exists() ).exists()
) )
def test_point_of_order_single(self):
config["agenda_enable_point_of_order_speakers"] = True
self.assertEqual(Speaker.objects.all().count(), 0)
response = self.client.post(
reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]),
{"point_of_order": True},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(Speaker.objects.all().count(), 1)
self.assertTrue(Speaker.objects.get().point_of_order)
self.assertEqual(Speaker.objects.get().weight, -1)
def test_point_of_order_not_enabled(self):
self.assertEqual(Speaker.objects.all().count(), 0)
response = self.client.post(
reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]),
{"point_of_order": True},
)
self.assertEqual(response.status_code, 400)
self.assertEqual(Speaker.objects.all().count(), 0)
def test_point_of_order_before_other(self):
config["agenda_enable_point_of_order_speakers"] = True
normal_speaker = Speaker.objects.add(self.user, self.list_of_speakers)
response = self.client.post(
reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]),
{"point_of_order": True},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(Speaker.objects.all().count(), 2)
poo_speaker = Speaker.objects.get(user=self.admin)
self.assertTrue(poo_speaker.point_of_order)
self.assertEqual(poo_speaker.weight, normal_speaker.weight - 1)
def test_point_of_order_with_normal_speaker(self):
config["agenda_enable_point_of_order_speakers"] = True
Speaker.objects.add(self.admin, self.list_of_speakers)
response = self.client.post(
reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]),
{"point_of_order": True},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(Speaker.objects.all().count(), 2)
self.assertTrue(Speaker.objects.filter(user=self.admin).count(), 2)
def test_point_of_order_twice(self):
config["agenda_enable_point_of_order_speakers"] = True
Speaker.objects.add(self.admin, self.list_of_speakers, point_of_order=True)
response = self.client.post(
reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]),
{"point_of_order": True},
)
self.assertEqual(response.status_code, 400)
self.assertEqual(Speaker.objects.all().count(), 1)
def test_remove_point_of_order(self):
config["agenda_enable_point_of_order_speakers"] = True
Speaker.objects.add(self.admin, self.list_of_speakers, point_of_order=True)
response = self.client.delete(
reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]),
{"point_of_order": True},
)
self.assertEqual(response.status_code, 200)
self.assertFalse(Speaker.objects.all().exists())
def test_remove_point_of_order_with_normal_speaker(self):
config["agenda_enable_point_of_order_speakers"] = True
Speaker.objects.add(self.admin, self.list_of_speakers)
Speaker.objects.add(self.admin, self.list_of_speakers, point_of_order=True)
response = self.client.delete(
reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]),
{"point_of_order": True},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(Speaker.objects.all().count(), 1)
self.assertFalse(Speaker.objects.get().point_of_order)
def test_remove_self_with_point_of_order(self):
config["agenda_enable_point_of_order_speakers"] = True
Speaker.objects.add(self.admin, self.list_of_speakers)
Speaker.objects.add(self.admin, self.list_of_speakers, point_of_order=True)
response = self.client.delete(
reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk])
)
self.assertEqual(response.status_code, 200)
self.assertEqual(Speaker.objects.all().count(), 1)
self.assertTrue(Speaker.objects.get().point_of_order)
def test_invalid_data_string_instead_of_integer(self): def test_invalid_data_string_instead_of_integer(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]),
@ -447,6 +544,7 @@ class ManageSpeaker(TestCase):
speaker.end_time = timezone.now() speaker.end_time = timezone.now()
speaker.weight = None speaker.weight = None
speaker.save() speaker.save()
return speaker
def test_readd_last_speaker_no_speaker(self): def test_readd_last_speaker_no_speaker(self):
response = self.client.post( response = self.client.post(
@ -514,6 +612,29 @@ class ManageSpeaker(TestCase):
) )
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
def test_readd_last_speaker_already_waiting(self):
self.util_add_user_as_last_speaker()
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_point_of_order(self):
speaker = self.util_add_user_as_last_speaker()
speaker.point_of_order = True
speaker.save()
response = self.client.post(
reverse(
"listofspeakers-readd-last-speaker", args=[self.list_of_speakers.pk]
)
)
self.assertEqual(response.status_code, 400)
class Speak(TestCase): class Speak(TestCase):
""" """

View File

@ -32,7 +32,7 @@ class ListOfSpeakersViewSetManageSpeaker(TestCase):
self.view_instance.manage_speaker(self.request) self.view_instance.manage_speaker(self.request)
mock_speaker.objects.add.assert_called_with( mock_speaker.objects.add.assert_called_with(
self.request.user, self.mock_list_of_speakers self.request.user, self.mock_list_of_speakers, point_of_order=False
) )
@patch("openslides.agenda.views.inform_changed_data") @patch("openslides.agenda.views.inform_changed_data")
@ -56,7 +56,7 @@ class ListOfSpeakersViewSetManageSpeaker(TestCase):
MockUser.objects.get.assert_called_with(pk=2) MockUser.objects.get.assert_called_with(pk=2)
mock_speaker.objects.add.assert_called_with( mock_speaker.objects.add.assert_called_with(
mock_user, self.mock_list_of_speakers mock_user, self.mock_list_of_speakers, point_of_order=False
) )
@patch("openslides.agenda.views.Speaker") @patch("openslides.agenda.views.Speaker")
@ -66,7 +66,7 @@ class ListOfSpeakersViewSetManageSpeaker(TestCase):
self.request.data = {} self.request.data = {}
self.view_instance.manage_speaker(self.request) self.view_instance.manage_speaker(self.request)
mock_queryset = mock_speaker.objects.filter.return_value.exclude.return_value mock_queryset = mock_speaker.objects.filter.return_value.exclude.return_value
mock_queryset.get.return_value.delete.assert_called_with() mock_queryset.all.return_value.delete.assert_called_with()
@patch("openslides.agenda.views.inform_changed_data") @patch("openslides.agenda.views.inform_changed_data")
@patch("openslides.agenda.views.has_perm") @patch("openslides.agenda.views.has_perm")