pro/contra speakers and notes for point of order speakers

Enhance Pro Contra UI/UX by Sean
This commit is contained in:
Finn Stutzenstein 2021-04-22 11:25:27 +02:00 committed by Emanuel Schütze
parent 4f35770769
commit c9c90cd4a3
33 changed files with 738 additions and 209 deletions

@ -1 +1 @@
Subproject commit f17ad9db22e87c1b642cc5796ed6d07cd4e0f95b Subproject commit d3e251aa0ca1b01b2fed3c0f2b4f5e2db87f5684

View File

@ -117,6 +117,13 @@ export class OperatorService implements OnAfterAppsLoaded {
return this._viewUser; return this._viewUser;
} }
/**
* Returns 0 for the anonymous
*/
public get userId(): number {
return this.user?.id || 0;
}
public get isAnonymous(): boolean { public get isAnonymous(): boolean {
return !this.user || this.user.id === 0; return !this.user || this.user.id === 0;
} }

View File

@ -161,10 +161,11 @@ export class ListOfSpeakersRepositoryService extends BaseHasContentObjectReposit
public async createSpeaker( public async createSpeaker(
listOfSpeakers: ViewListOfSpeakers, listOfSpeakers: ViewListOfSpeakers,
userId: number, userId: number,
pointOfOrder?: boolean pointOfOrder?: boolean,
note?: string
): Promise<Identifiable> { ): 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, point_of_order: pointOfOrder }); return await this.httpService.post<Identifiable>(restUrl, { user: userId, point_of_order: pointOfOrder, note });
} }
/** /**
@ -222,15 +223,14 @@ export class ListOfSpeakersRepositoryService extends BaseHasContentObjectReposit
} }
/** /**
* Marks all speakers for a given user * Toggles the mark for a given speaker.
*
* @param userId {@link User} id of the user
* @param marked determine if the user should be marked or not
* @param listOfSpeakers the target list of speakers
*/ */
public async markSpeaker(listOfSpeakers: ViewListOfSpeakers, speaker: ViewSpeaker, marked: boolean): Promise<void> { public async toggleMarked(speaker: ViewSpeaker): Promise<void> {
const restUrl = this.getRestUrl(listOfSpeakers.id, 'manage_speaker'); await this.httpService.put(`/rest/agenda/speaker/${speaker.id}/`, { marked: !speaker.marked });
await this.httpService.patch(restUrl, { user: speaker.user.id, marked: marked }); }
public async setProContraSpeech(speaker: ViewSpeaker, proSpeech: boolean | null): Promise<void> {
await this.httpService.put(`/rest/agenda/speaker/${speaker.id}/`, { pro_speech: proSpeech });
} }
/** /**

View File

@ -20,7 +20,9 @@
<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="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>
@ -49,10 +51,24 @@
<mat-icon>mic</mat-icon> <mat-icon>mic</mat-icon>
</span> </span>
<span class="name">{{ activeSpeaker.getListTitle() }}</span> <span class="name">
{{ activeSpeaker.getListTitle() }}
<div class="active-speaker-subtitle">
<i *ngIf="activeSpeaker.pro_speech === true">
{{ 'Pro speech' | translate }}
</i>
<i *ngIf="activeSpeaker.pro_speech === false">
{{ 'Contra speech' | translate }}
</i>
<i *ngIf="activeSpeaker.marked">
{{ 'Contribution' | translate }}
</i>
</div>
</span>
<span class="suffix"> <span class="suffix">
<!-- point of order visible for everyone --> <!-- point of order visible for everyone -->
<button <button
mat-icon-button mat-icon-button
@ -84,70 +100,136 @@
> >
<!-- 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'; and: !speaker.point_of_order"> <div class="single-speaker-line">
<!-- Speaker count --> <div class="speaker-name">
<span *ngIf="hasSpokenCount(speaker)" class="red-warning-text speaker-warning"> <!-- Speaker name -->
{{ hasSpokenCount(speaker) + 1 }}. <span>{{ 'contribution' | translate }}</span> <div>
</span> {{ speaker.getTitle() }}
</div>
<!-- Extra line -->
<ng-container>
<!-- Pro Contra Mark -->
<ng-container *ngIf="speaker.pro_speech === true">
<mat-icon inline class="inline-icon-text-align green-text"> add_circle </mat-icon>
<i class="user-subtitle">
{{ 'Pro speech' | translate }}
</i>
</ng-container>
<!-- First contribution --> <ng-container *ngIf="speaker.pro_speech === false">
<span <mat-icon inline class="inline-icon-text-align red-warning-text">
*ngIf="showFistContributionHint && isFirstContribution(speaker)" remove_circle
class="red-warning-text speaker-warning" </mat-icon>
> <i class="user-subtitle">
{{ 'First speech' | translate }} {{ 'Contra speech' | translate }}
</span> </i>
</ng-container>
<!-- Speaker gender --> <ng-container *ngIf="speaker.marked">
<span *ngIf="speaker.gender">({{ speaker.gender | translate }})</span> <mat-icon inline class="inline-icon-text-align icon">star</mat-icon>
</span> <i class="user-subtitle">
{{ 'Contribution' | translate }}
</i>
</ng-container>
<!-- Start, start and delete buttons --> <!-- Point Of Order and note -->
<span> <ng-container>
<!-- start button --> <mat-icon
<button inline
mat-icon-button color="warn"
matTooltip="{{ 'Begin speech' | translate }}" class="inline-icon-text-align"
(click)="onStartButton(speaker)" *ngIf="speaker.point_of_order"
*osPerms="'agenda.can_manage_list_of_speakers'" >warning</mat-icon
> >
<mat-icon>play_arrow</mat-icon> <i *ngIf="showSpeakersOrderNote" class="red-warning-text">
</button> {{ speaker.note }}
</i>
</ng-container>
</ng-container>
</div>
<div class="speaker-controls one-line">
<!-- Extra stuff: Spoken Count, Gender, 1st Contribution -->
<span *osPerms="permission.agendaCanManageListOfSpeakers; and: !speaker.point_of_order">
<!-- Speaker count -->
<span *ngIf="hasSpokenCount(speaker)" class="red-warning-text speaker-warning">
{{ hasSpokenCount(speaker) + 1 }}. <span>{{ 'contribution' | translate }}</span>
</span>
<!-- star button --> <!-- First contribution -->
<button <span
mat-icon-button *ngIf="showFistContributionHint && isFirstContribution(speaker)"
matTooltip="{{ 'Mark speaker' | translate }}" class="red-warning-text speaker-warning"
(click)="onMarkButton(speaker)" >
*osPerms="'agenda.can_manage_list_of_speakers'; and: !speaker.point_of_order" {{ 'First speech' | translate }}
> </span>
<mat-icon>{{ speaker.marked ? 'star' : 'star_border' }}</mat-icon>
</button>
<!-- point of order visible for everyone --> <!-- Speaker gender -->
<button <span *ngIf="speaker.gender">({{ speaker.gender | translate }})</span>
mat-icon-button </span>
matTooltip="{{ 'Point of order' | translate }}"
*ngIf="speaker.point_of_order"
>
<mat-icon color="warn"> warning </mat-icon>
</button>
<!-- delete button --> <!-- For normal users -->
<button <span *osPerms="permission.agendaCanManageListOfSpeakers; complement: true">
mat-icon-button <ng-container *ngIf="speakerIsOperator(speaker)">
matTooltip="{{ 'Remove' | translate }}" <!-- pro -->
(click)="removeSpeaker(speaker)" <button
*osPerms="'agenda.can_manage_list_of_speakers'" mat-icon-button
> (click)="onProContraButtons(speaker, true)"
<mat-icon>close</mat-icon> matTooltip="{{ 'Pro speech' | translate }}"
</button> *ngIf="enableProContraSpeech && !speaker.point_of_order"
</span> >
<mat-icon class="user-subtitle" *ngIf="speaker.pro_speech !== true"> add_circle_outline </mat-icon>
<mat-icon class="green-text" *ngIf="speaker.pro_speech === true">
add_circle
</mat-icon>
</button>
<!-- For thouse without LOS --> <!-- contra -->
<span *osPerms="'agenda.can_manage_list_of_speakers'; complement: true"> <button
<mat-icon *ngIf="speaker.marked"> star </mat-icon> mat-icon-button
</span> (click)="onProContraButtons(speaker, false)"
matTooltip="{{ 'Contra speech' | translate }}"
*ngIf="enableProContraSpeech && !speaker.point_of_order"
>
<mat-icon class="user-subtitle" *ngIf="speaker.pro_speech !== false"> remove_circle_outline </mat-icon>
<mat-icon class="red-warning-text" *ngIf="speaker.pro_speech === false">
remove_circle
</mat-icon>
</button>
<!-- mark -->
<button
mat-icon-button
(click)="onMarkButton(speaker)"
matTooltip="{{ 'Contribution' | translate }}"
*ngIf="canMark(speaker) && !speaker.point_of_order"
>
<mat-icon *ngIf="speaker.marked">star</mat-icon>
<mat-icon class="user-subtitle" *ngIf="!speaker.marked">star_border</mat-icon> </button>
</ng-container>
</span>
<!-- Start and more button -->
<span *osPerms="permission.agendaCanManageListOfSpeakers">
<!-- start button -->
<button
mat-icon-button
matTooltip="{{ 'Begin speech' | translate }}"
(click)="onStartButton(speaker)"
>
<mat-icon>play_arrow</mat-icon>
</button>
<!-- more menu button -->
<button
mat-icon-button
[matMenuTriggerFor]="manageSpeakerMenu"
[matMenuTriggerData]="{ speaker: speaker }"
>
<mat-icon>more_vert</mat-icon>
</button>
</span>
</div>
</div>
</ng-template> </ng-template>
</os-sorting-list> </os-sorting-list>
</div> </div>
@ -188,24 +270,63 @@
<mat-icon>add</mat-icon> <mat-icon>add</mat-icon>
<span>{{ 'Add me' | translate }}</span> <span>{{ 'Add me' | translate }}</span>
</button> </button>
<button <button *ngIf="isOpInWaitlist()" mat-stroked-button [disabled]="closed" (click)="removeSpeaker()">
*ngIf="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>
<!-- Point Of order --> <!-- Point Of order -->
<button mat-stroked-button color="warn" (click)="addPointOfOrder()" *ngIf="showPointOfOrders && !isOpInWaitlist(true)"> <button
mat-stroked-button
color="warn"
(click)="addPointOfOrder()"
*ngIf="showPointOfOrders && !isOpInWaitlist(true)"
>
<mat-icon>warning</mat-icon>&nbsp; <mat-icon>warning</mat-icon>&nbsp;
<span>{{ 'Point of order' | translate }}</span> <span>{{ 'Point of order' | translate }}</span>
</button> </button>
<button mat-stroked-button color="warn" (click)="removePointOfOrder()" *ngIf="showPointOfOrders && isOpInWaitlist(true)"> <button
mat-stroked-button
color="warn"
(click)="removePointOfOrder()"
*ngIf="showPointOfOrders && isOpInWaitlist(true)"
>
<mat-icon>remove</mat-icon> <mat-icon>remove</mat-icon>
<span>{{ 'Withdraw point of order' | translate }}</span> <span>{{ 'Withdraw point of order' | translate }}</span>
</button> </button>
</div> </div>
</mat-card> </mat-card>
<!-- admin menu for managing single speakers -->
<mat-menu #manageSpeakerMenu>
<ng-template let-speaker="speaker" matMenuContent>
<ng-container *osPerms="permission.agendaCanManageListOfSpeakers">
<!-- pro button -->
<button mat-menu-item (click)="onProContraButtons(speaker, true)" *ngIf="enableProContraSpeech">
<mat-icon *ngIf="speaker.pro_speech !== true"> add_circle_outline </mat-icon>
<mat-icon class="green-text" *ngIf="speaker.pro_speech === true"> add_circle </mat-icon>
<span>{{ 'Pro speech' | translate }}</span>
</button>
<!-- contra button -->
<button mat-menu-item (click)="onProContraButtons(speaker, false)" *ngIf="enableProContraSpeech">
<mat-icon *ngIf="speaker.pro_speech !== false"> remove_circle_outline </mat-icon>
<mat-icon class="red-warning-text" *ngIf="speaker.pro_speech === false"> remove_circle </mat-icon>
<span>{{ 'Contra speech' | translate }}</span>
</button>
<!-- star button -->
<button mat-menu-item (click)="onMarkButton(speaker)">
<mat-icon>{{ speaker.marked ? 'star' : 'star_border' }}</mat-icon>
<span>{{ 'Contribution' | translate }}</span>
</button>
<mat-divider></mat-divider>
<!-- remove speaker from list -->
<button mat-menu-item (click)="removeSpeaker(speaker)">
<mat-icon color="warn">delete</mat-icon>
<span>{{ 'Remove' | translate }}</span>
</button>
</ng-container>
</ng-template>
</mat-menu>

View File

@ -83,6 +83,7 @@
height: 50px; height: 50px;
margin: 50px 25px 20px 25px; margin: 50px 25px 20px 25px;
box-shadow: 0px 3px 10px 0px rgba(0, 0, 0, 0.25); box-shadow: 0px 3px 10px 0px rgba(0, 0, 0, 0.25);
line-height: 1;
.prefix { .prefix {
display: table-cell; display: table-cell;
@ -99,6 +100,13 @@
font-weight: bold; font-weight: bold;
padding-left: 10px; padding-left: 10px;
width: 100%; width: 100%;
.active-speaker-subtitle {
* {
font-weight: initial;
font-size: 95%;
}
}
} }
.suffix { .suffix {
@ -111,6 +119,34 @@
.waiting-list { .waiting-list {
padding: 10px 25px 0 25px; padding: 10px 25px 0 25px;
.single-speaker-line {
display: grid;
grid-template-columns: auto min-content;
grid-column-gap: 0.5em;
.speaker-name {
margin: auto 0;
}
.speaker-controls {
margin-right: 0.5em;
}
}
.speaker-warning {
margin-right: 5px;
}
}
.add-self-buttons {
margin: 10px 20px;
display: inline-flex;
flex-wrap: wrap;
button {
margin: 5px;
}
} }
.search-new-speaker-form { .search-new-speaker-form {
@ -127,17 +163,3 @@
} }
} }
} }
.add-self-buttons {
margin: 10px 20px;
display: inline-flex;
flex-wrap: wrap;
button {
margin: 5px;
}
}
.speaker-warning {
margin-right: 5px;
}

View File

@ -9,6 +9,7 @@ import {
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms'; import { FormControl, FormGroup } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
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';
@ -22,10 +23,13 @@ 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 { ViewportService } from 'app/core/ui-services/viewport.service'; import { ViewportService } from 'app/core/ui-services/viewport.service';
import { Identifiable } from 'app/shared/models/base/identifiable';
import { infoDialogSettings } from 'app/shared/utils/dialog-settings';
import { ViewListOfSpeakers } from 'app/site/agenda/models/view-list-of-speakers'; import { ViewListOfSpeakers } from 'app/site/agenda/models/view-list-of-speakers';
import { SpeakerState, ViewSpeaker } from 'app/site/agenda/models/view-speaker'; import { SpeakerState, ViewSpeaker } from 'app/site/agenda/models/view-speaker';
import { BaseViewComponentDirective } from 'app/site/base/base-view'; import { BaseViewComponentDirective } from 'app/site/base/base-view';
import { ViewUser } from 'app/site/users/models/view-user'; import { ViewUser } from 'app/site/users/models/view-user';
import { PointOfOrderDialogComponent } from '../point-of-order-dialog/point-of-order-dialog.component';
import { Selectable } from '../selectable'; import { Selectable } from '../selectable';
import { SortingListComponent } from '../sorting-list/sorting-list.component'; import { SortingListComponent } from '../sorting-list/sorting-list.component';
@ -63,6 +67,9 @@ export class ListOfSpeakersContentComponent extends BaseViewComponentDirective i
} }
private pointOfOrderEnabled: boolean; private pointOfOrderEnabled: boolean;
public enableProContraSpeech: boolean;
private canSetMarkSelf: boolean;
private noteForAll: boolean;
public get title(): string { public get title(): string {
return this.viewListOfSpeakers?.getTitle(); return this.viewListOfSpeakers?.getTitle();
@ -80,6 +87,10 @@ export class ListOfSpeakersContentComponent extends BaseViewComponentDirective i
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;
} }
public get showSpeakersOrderNote(): boolean {
return this.noteForAll || this.opCanManage;
}
@Input() @Input()
public set speakers(los: ViewListOfSpeakers) { public set speakers(los: ViewListOfSpeakers) {
this.setListOfSpeakers(los); this.setListOfSpeakers(los);
@ -113,7 +124,8 @@ export class ListOfSpeakersContentComponent extends BaseViewComponentDirective i
private userRepository: UserRepositoryService, private userRepository: UserRepositoryService,
private config: ConfigService, private config: ConfigService,
private viewport: ViewportService, private viewport: ViewportService,
private cd: ChangeDetectorRef private cd: ChangeDetectorRef,
private dialog: MatDialog
) { ) {
super(title, translate, snackBar); super(title, translate, snackBar);
this.addSpeakerForm = new FormGroup({ user_id: new FormControl() }); this.addSpeakerForm = new FormGroup({ user_id: new FormControl() });
@ -150,6 +162,15 @@ export class ListOfSpeakersContentComponent extends BaseViewComponentDirective i
// observe point of order settings // observe point of order settings
this.config.get<boolean>('agenda_enable_point_of_order_speakers').subscribe(enabled => { this.config.get<boolean>('agenda_enable_point_of_order_speakers').subscribe(enabled => {
this.pointOfOrderEnabled = enabled; this.pointOfOrderEnabled = enabled;
}),
this.config.get<boolean>('agenda_list_of_speakers_enable_pro_contra_speech').subscribe(enabled => {
this.enableProContraSpeech = enabled;
}),
this.config.get<boolean>('agenda_list_of_speakers_can_set_mark_self').subscribe(canMark => {
this.canSetMarkSelf = canMark;
}),
this.config.get<boolean>('agenda_list_of_speakers_speaker_note_for_everyone').subscribe(noteForAll => {
this.noteForAll = noteForAll;
}) })
); );
} }
@ -209,13 +230,18 @@ export class ListOfSpeakersContentComponent extends BaseViewComponentDirective i
} }
public async addPointOfOrder(): Promise<void> { public async addPointOfOrder(): Promise<void> {
const title = this.translate.instant('Are you sure you want to submit a point of order?'); const dialogRef = this.dialog.open<PointOfOrderDialogComponent, ViewListOfSpeakers, Promise<Identifiable>>(
if (await this.promptService.open(title)) { PointOfOrderDialogComponent,
try { {
await this.listOfSpeakersRepo.createSpeaker(this.viewListOfSpeakers, undefined, true); data: this.viewListOfSpeakers,
} catch (e) { ...infoDialogSettings
this.raiseError(e);
} }
);
try {
await dialogRef.afterClosed().toPromise();
} catch (e) {
this.raiseError(e);
} }
} }
@ -235,7 +261,7 @@ export class ListOfSpeakersContentComponent extends BaseViewComponentDirective i
/** /**
* Click on the mic button to mark a speaker as speaking * Click on the mic button to mark a speaker as speaking
* *
* @param speaker the speaker marked in the list * @param speaker the speaker selected in the list
*/ */
public async onStartButton(speaker: ViewSpeaker): Promise<void> { public async onStartButton(speaker: ViewSpeaker): Promise<void> {
try { try {
@ -258,13 +284,26 @@ export class ListOfSpeakersContentComponent extends BaseViewComponentDirective i
} }
} }
public speakerIsOperator(speaker: ViewSpeaker): boolean {
return speaker.user_id === this.operator.userId;
}
public canMark(speaker: ViewSpeaker): boolean {
return this.opCanManage || (this.canSetMarkSelf && this.speakerIsOperator(speaker));
}
/** /**
* Click on the star button. Toggles the marked attribute. * Click on the star button. Toggles the marked attribute.
* *
* @param speaker The speaker clicked on. * @param speaker The speaker clicked on.
*/ */
public onMarkButton(speaker: ViewSpeaker): void { public onMarkButton(speaker: ViewSpeaker): void {
this.listOfSpeakersRepo.markSpeaker(this.viewListOfSpeakers, speaker, !speaker.marked).catch(this.raiseError); this.listOfSpeakersRepo.toggleMarked(speaker).catch(this.raiseError);
}
public onProContraButtons(speaker: ViewSpeaker, isPro: boolean): void {
const value = speaker.pro_speech === isPro ? null : isPro;
this.listOfSpeakersRepo.setProContraSpeech(speaker, value).catch(this.raiseError);
} }
/** /**

View File

@ -0,0 +1,19 @@
<h2 mat-dialog-title>
<span>{{ 'Are you sure you want to submit a point of order?' | translate }}</span>
</h2>
<mat-dialog-content>
{{ 'Request' | translate }}
<form class="edit-form" [formGroup]="editForm">
<mat-form-field>
<input matInput osAutofocus [maxLength]="MAX_LENGTH" formControlName="note" />
</mat-form-field>
</form>
{{ editForm.value.note.length }} / {{ MAX_LENGTH }}
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button osAutofocus (click)="onOk()" color="accent" [disabled]="!this.editForm.valid">
{{ 'OK' | translate }}
</button>
<button mat-button (click)="onCancel()">{{ 'Cancel' | translate }}</button>
</mat-dialog-actions>

View File

@ -0,0 +1,6 @@
.edit-form {
width: 100%;
.mat-form-field {
width: 100%;
}
}

View File

@ -0,0 +1,41 @@
import { Component, Inject } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { ListOfSpeakersRepositoryService } from 'app/core/repositories/agenda/list-of-speakers-repository.service';
import { Identifiable } from 'app/shared/models/base/identifiable';
import { ViewListOfSpeakers } from 'app/site/agenda/models/view-list-of-speakers';
@Component({
selector: 'os-point-of-order-dialog',
templateUrl: './point-of-order-dialog.component.html',
styleUrls: ['./point-of-order-dialog.component.scss']
})
export class PointOfOrderDialogComponent {
public editForm: FormGroup;
public readonly MAX_LENGTH = 80;
public constructor(
public dialogRef: MatDialogRef<PointOfOrderDialogComponent, Promise<Identifiable>>,
@Inject(MAT_DIALOG_DATA) public listOfSpeakers: ViewListOfSpeakers,
private fb: FormBuilder,
private repo: ListOfSpeakersRepositoryService
) {
this.editForm = this.fb.group({
note: ['', [Validators.required, Validators.maxLength(this.MAX_LENGTH)]]
});
}
public onOk(): void {
if (!this.editForm.valid) {
return;
}
const note = this.editForm.value.note;
this.dialogRef.close(this.repo.createSpeaker(this.listOfSpeakers, undefined, true, note));
}
public onCancel(): void {
this.dialogRef.close();
}
}

View File

@ -9,18 +9,22 @@
(click)="onItemClick($event, i)" (click)="onItemClick($event, i)"
(cdkDragStarted)="dragStarted(i)" (cdkDragStarted)="dragStarted(i)"
> >
<div class="section-one" cdkDragHandle *ngIf="enable"> <!-- drag handle -->
<div class="drag-indicator" cdkDragHandle *ngIf="enable">
<mat-icon>drag_indicator</mat-icon> <mat-icon>drag_indicator</mat-icon>
</div> </div>
<div class="section-two">
<!-- {number}. {item.getTitle()} --> <!-- Count number -->
<span *ngIf="count">{{ i + 1 }}.&nbsp;</span> <div class="count" *ngIf="count">
<span>{{ item?.getTitle() }}</span> <span>{{ i + 1 }}.</span>
</div> </div>
<div class="section-three">
<!-- Extra controls slot using implicit template references --> <!-- Content -->
<div class="content">
<ng-template [ngTemplateOutlet]="templateRef" [ngTemplateOutletContext]="{ $implicit: item }"></ng-template> <ng-template [ngTemplateOutlet]="templateRef" [ngTemplateOutletContext]="{ $implicit: item }"></ng-template>
</div> </div>
<!-- Drag prev -->
<div class="line" *cdkDragPreview> <div class="line" *cdkDragPreview>
<div class="spacer.left-10" *ngIf="multiSelectedIndex.length > 0"> <div class="spacer.left-10" *ngIf="multiSelectedIndex.length > 0">
{{ multiSelectedIndex.length }}&nbsp;<span>{{ 'items selected' | translate }}</span> {{ multiSelectedIndex.length }}&nbsp;<span>{{ 'items selected' | translate }}</span>

View File

@ -9,31 +9,26 @@
min-height: 50px; min-height: 50px;
margin-bottom: 5px; margin-bottom: 5px;
.section-one { .drag-indicator {
display: table-cell; display: table-cell;
padding: 0 10px;
line-height: 0px;
vertical-align: middle; vertical-align: middle;
padding: 0 0.5em;
line-height: 0px;
width: 50px; width: 50px;
color: slategrey; color: slategrey;
cursor: move; cursor: move;
} }
.section-two { .count {
display: table-cell;
vertical-align: middle;
min-width: 2em;
padding-left: 1.25em;
}
.content {
display: table-cell; display: table-cell;
vertical-align: middle; vertical-align: middle;
width: 100%; width: 100%;
padding-left: 20px;
span + span {
margin-left: 20px;
}
}
.section-three {
display: table-cell;
padding-right: 10px;
width: auto;
white-space: nowrap;
} }
} }

View File

@ -13,9 +13,11 @@ export class Speaker extends BaseModel<Speaker> {
public id: number; public id: number;
public user_id: number; public user_id: number;
public weight: number; public weight: number;
public note?: string;
public marked: boolean; public marked: boolean;
public item_id: number; public item_id: number;
public point_of_order: boolean; public point_of_order: boolean;
public pro_speech?: 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

@ -134,6 +134,7 @@ import { ApplauseBarDisplayComponent } from './components/applause-bar-display/a
import { ProgressComponent } from './components/progress/progress.component'; import { ProgressComponent } from './components/progress/progress.component';
import { NgParticlesModule } from 'ng-particles'; import { NgParticlesModule } from 'ng-particles';
import { ApplauseParticleDisplayComponent } from './components/applause-particle-display/applause-particle-display.component'; import { ApplauseParticleDisplayComponent } from './components/applause-particle-display/applause-particle-display.component';
import { PointOfOrderDialogComponent } from './components/point-of-order-dialog/point-of-order-dialog.component';
/** /**
* Share Module for all "dumb" components and pipes. * Share Module for all "dumb" components and pipes.
@ -305,7 +306,8 @@ import { ApplauseParticleDisplayComponent } from './components/applause-particle
AssignmentPollDetailContentComponent, AssignmentPollDetailContentComponent,
JitsiComponent, JitsiComponent,
VideoPlayerComponent, VideoPlayerComponent,
ListOfSpeakersContentComponent ListOfSpeakersContentComponent,
PointOfOrderDialogComponent
], ],
declarations: [ declarations: [
PermsDirective, PermsDirective,
@ -372,7 +374,8 @@ import { ApplauseParticleDisplayComponent } from './components/applause-particle
ListOfSpeakersContentComponent, ListOfSpeakersContentComponent,
ApplauseBarDisplayComponent, ApplauseBarDisplayComponent,
ProgressComponent, ProgressComponent,
ApplauseParticleDisplayComponent ApplauseParticleDisplayComponent,
PointOfOrderDialogComponent
], ],
providers: [ providers: [
{ {

View File

@ -155,15 +155,19 @@
> >
<!-- implicit item references into the component using ng-template slot --> <!-- implicit item references into the component using ng-template slot -->
<ng-template let-item> <ng-template let-item>
<span *ngIf="hasPerms('manage')"> <div class="single-candidate-line">
<button <span *ngIf="hasPerms('manage')"> {{ item.getTitle() }} </span>
mat-icon-button
matTooltip="{{ 'Remove candidate' | translate }}" <span *ngIf="hasPerms('manage')">
(click)="removeUser(item)" <button
> mat-icon-button
<mat-icon>clear</mat-icon> matTooltip="{{ 'Remove candidate' | translate }}"
</button> (click)="removeUser(item)"
</span> >
<mat-icon>clear</mat-icon>
</button>
</span>
</div>
</ng-template> </ng-template>
</os-sorting-list> </os-sorting-list>
</div> </div>

View File

@ -40,6 +40,16 @@
.candidates-list { .candidates-list {
padding: 10px 25px 0 25px; padding: 10px 25px 0 25px;
.single-candidate-line {
display: grid;
grid-template-columns: auto min-content;
grid-column-gap: 0.5em;
span {
margin: auto 0;
}
}
} }
.add-candidates { .add-candidates {

View File

@ -15,9 +15,16 @@
</div> </div>
<os-sorting-list (sortEvent)="onListUpdate($event)" [input]="motionObservable" #sorter> <os-sorting-list (sortEvent)="onListUpdate($event)" [input]="motionObservable" #sorter>
<ng-template let-motion> <ng-template let-motion>
<mat-basic-chip class="bluegrey" disableRipple matTooltip="{{ 'Sequential number' | translate }}"> <div class="single-motion-line">
{{ motion.id }} <span>
</mat-basic-chip> {{ motion.getTitle() }}
</span>
<span>
<mat-basic-chip class="bluegrey" disableRipple matTooltip="{{ 'Sequential number' | translate }}">
{{ motion.id }}
</mat-basic-chip>
</span>
</div>
</ng-template> </ng-template>
</os-sorting-list> </os-sorting-list>
</mat-card> </mat-card>

View File

@ -0,0 +1,10 @@
.single-motion-line {
display: grid;
grid-template-columns: auto min-content;
grid-column-gap: 0.5em;
margin-right: 0.5em;
span {
margin: auto 0;
}
}

View File

@ -8,5 +8,11 @@
<!-- Content --> <!-- Content -->
<mat-card class="os-form-card"> <mat-card class="os-form-card">
<!-- The sorting component --> <!-- The sorting component -->
<os-sorting-list (sortEvent)="onSortingChange($event)" [live]="true" [input]="comments" #sorter> </os-sorting-list> <os-sorting-list (sortEvent)="onSortingChange($event)" [live]="true" [input]="comments" #sorter>
<ng-template let-comment>
<span>
{{ comment.getTitle() }}
</span>
</ng-template>
</os-sorting-list>
</mat-card> </mat-card>

View File

@ -27,9 +27,22 @@
> >
<!-- implicit user references into the component using ng-template slot --> <!-- implicit user references into the component using ng-template slot -->
<ng-template let-user> <ng-template let-user>
<button type="button" mat-icon-button matTooltip="{{ 'Remove' | translate }}" (click)="onRemove(user)"> <div class="single-submitter-line">
<mat-icon>close</mat-icon> <span class="ellipsis-overflow">
</button> {{ user.getTitle() }}
</span>
<span>
<button
type="button"
mat-icon-button
matTooltip="{{ 'Remove' | translate }}"
(click)="onRemove(user)"
>
<mat-icon>close</mat-icon>
</button>
</span>
</div>
</ng-template> </ng-template>
</os-sorting-list> </os-sorting-list>

View File

@ -20,6 +20,17 @@ h4 {
} }
} }
.single-submitter-line {
display: grid;
grid-template-columns: auto min-content;
grid-column-gap: 0.5em;
margin-right: 0.5em;
span {
margin: auto 0;
}
}
.user .mat-chip { .user .mat-chip {
border-radius: 16px !important; border-radius: 16px !important;
padding: 5px 15px !important; padding: 5px 15px !important;

View File

@ -2,6 +2,7 @@ export interface SlideSpeaker {
user: string; user: string;
marked: boolean; marked: boolean;
point_of_order: boolean; point_of_order: boolean;
pro_speech?: boolean;
} }
export interface CommonListOfSpeakersSlideData { export interface CommonListOfSpeakersSlideData {

View File

@ -17,7 +17,9 @@
<div *ngFor="let speaker of data.data.finished"> <div *ngFor="let speaker of data.data.finished">
{{ speaker.user }} {{ speaker.user }}
<mat-icon *ngIf="speaker.marked">star</mat-icon> <mat-icon *ngIf="speaker.marked">star</mat-icon>
<mat-icon *ngIf="speaker.point_of_order" color="warn"> warning</mat-icon> <mat-icon *ngIf="speaker.pro_speech === true" class="green-text">add_circle</mat-icon>
<mat-icon *ngIf="speaker.pro_speech === false" class="red-warning-text">remove_circle</mat-icon>
<mat-icon *ngIf="speaker.point_of_order" color="warn">warning</mat-icon>
</div> </div>
</div> </div>
@ -26,7 +28,9 @@
<mat-icon class="micicon">mic</mat-icon> <mat-icon class="micicon">mic</mat-icon>
{{ data.data.current.user }} {{ data.data.current.user }}
<mat-icon *ngIf="data.data.current.marked">star</mat-icon> <mat-icon *ngIf="data.data.current.marked">star</mat-icon>
<mat-icon *ngIf="data.data.current.point_of_order" color="warn"> warning</mat-icon> <mat-icon *ngIf="data.data.current.pro_speech === true" class="green-text">add_circle</mat-icon>
<mat-icon *ngIf="data.data.current.pro_speech === false" class="red-warning-text">remove_circle</mat-icon>
<mat-icon *ngIf="data.data.current.point_of_order" color="warn">warning</mat-icon>
</div> </div>
<!-- Next speakers --> <!-- Next speakers -->
@ -35,7 +39,9 @@
<li *ngFor="let speaker of data.data.waiting"> <li *ngFor="let speaker of data.data.waiting">
{{ speaker.user }} {{ speaker.user }}
<mat-icon *ngIf="speaker.marked">star</mat-icon> <mat-icon *ngIf="speaker.marked">star</mat-icon>
<mat-icon *ngIf="speaker.point_of_order" color="warn"> warning</mat-icon> <mat-icon *ngIf="speaker.pro_speech === true" class="green-text">add_circle</mat-icon>
<mat-icon *ngIf="speaker.pro_speech === false" class="red-warning-text">remove_circle</mat-icon>
<mat-icon *ngIf="speaker.point_of_order" color="warn">warning</mat-icon>
</li> </li>
</ol> </ol>
</div> </div>

View File

@ -10,6 +10,8 @@
<li *ngFor="let speaker of nextSpeakers" class="one-line"> <li *ngFor="let speaker of nextSpeakers" class="one-line">
{{ speaker.user }} {{ speaker.user }}
<mat-icon *ngIf="speaker.marked">star</mat-icon> <mat-icon *ngIf="speaker.marked">star</mat-icon>
<mat-icon *ngIf="speaker.pro_speech===true">thumb_up</mat-icon>
<mat-icon *ngIf="speaker.pro_speech===false">thumb_down</mat-icon>
</li> </li>
</ol> </ol>
</div> </div>

View File

@ -173,15 +173,27 @@ b,
} }
.red-warning-text { .red-warning-text {
color: red; color: red !important;
.mat-icon { .mat-icon {
color: red !important; color: red !important;
} }
} }
.green-text { .green-text {
// TODO better name/theming color: #5a5 !important;
color: #5a5; }
.icon-as-block.mat-icon {
display: block;
}
/**
* Can better align icons with text. Use like:
* <mat-icon inline class="inline-icon-text-align"> ...
* <span> ...
*/
.inline-icon-text-align.mat-icon {
vertical-align: bottom;
} }
.icon-text-distance { .icon-text-distance {

View File

@ -17,7 +17,7 @@ class AgendaAppConfig(AppConfig):
listen_to_related_object_post_delete, listen_to_related_object_post_delete,
listen_to_related_object_post_save, listen_to_related_object_post_save,
) )
from .views import ItemViewSet, ListOfSpeakersViewSet from .views import ItemViewSet, ListOfSpeakersViewSet, SpeakerViewSet
# Connect signals. # Connect signals.
post_save.connect( post_save.connect(
@ -38,6 +38,10 @@ class AgendaAppConfig(AppConfig):
self.get_model("ListOfSpeakers").get_collection_string(), self.get_model("ListOfSpeakers").get_collection_string(),
ListOfSpeakersViewSet, ListOfSpeakersViewSet,
) )
router.register(
self.get_model("Speaker").get_collection_string(),
SpeakerViewSet,
)
def get_config_variables(self): def get_config_variables(self):
from .config_variables import get_config_variables from .config_variables import get_config_variables

View File

@ -220,3 +220,33 @@ def get_config_variables():
group="Agenda", group="Agenda",
subgroup="List of speakers", subgroup="List of speakers",
) )
yield ConfigVariable(
name="agenda_list_of_speakers_enable_pro_contra_speech",
default_value=False,
input_type="boolean",
label="Enable pro/contra buttons for speakers",
weight=240,
group="Agenda",
subgroup="List of speakers",
)
yield ConfigVariable(
name="agenda_list_of_speakers_can_set_mark_self",
default_value=False,
input_type="boolean",
label="Speakers can set the mark by themselfs",
weight=245,
group="Agenda",
subgroup="List of speakers",
)
yield ConfigVariable(
name="agenda_list_of_speakers_speaker_note_for_everyone",
default_value=False,
input_type="boolean",
label="Everyone can see the note of a speaker (instead of admins and the speaker only)",
weight=250,
group="Agenda",
subgroup="List of speakers",
)

View File

@ -0,0 +1,23 @@
# Generated by Django 2.2.20 on 2021-04-19 10:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("agenda", "0012_list_of_speakers_closed_default"),
]
operations = [
migrations.AddField(
model_name="speaker",
name="note",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="speaker",
name="pro_speech",
field=models.BooleanField(blank=True, default=None, null=True),
),
]

View File

@ -421,7 +421,14 @@ 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, point_of_order=False): def add(
self,
user,
list_of_speakers,
skip_autoupdate=False,
point_of_order=False,
note=None,
):
""" """
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
@ -450,6 +457,7 @@ class SpeakerManager(models.Manager):
user=user, user=user,
weight=weight, weight=weight,
point_of_order=point_of_order, point_of_order=point_of_order,
note=note,
) )
speaker.save( speaker.save(
force_insert=True, force_insert=True,
@ -534,6 +542,8 @@ class Speaker(RESTModelMixin, models.Model):
The sort order of the list of speakers. None, if he has already spoken. The sort order of the list of speakers. None, if he has already spoken.
""" """
note = models.CharField(max_length=255, null=True, blank=True)
marked = models.BooleanField(default=False) marked = models.BooleanField(default=False)
""" """
Marks a speaker. Marks a speaker.
@ -544,6 +554,8 @@ class Speaker(RESTModelMixin, models.Model):
Identifies the speaker as someone with a point of order Identifies the speaker as someone with a point of order
""" """
pro_speech = models.BooleanField(null=True, blank=True, default=None)
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

@ -16,8 +16,19 @@ class SpeakerSerializer(ModelSerializer):
"begin_time", "begin_time",
"end_time", "end_time",
"weight", "weight",
"note",
"marked", "marked",
"point_of_order", "point_of_order",
"pro_speech",
)
read_only_fields = (
"id",
"user",
"begin_time",
"end_time",
"weight",
"note",
"point_of_order",
) )

View File

@ -2,6 +2,7 @@ import jsonschema
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import transaction from django.db import transaction
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from django.http.request import QueryDict
from openslides.core.config import config from openslides.core.config import config
from openslides.utils.autoupdate import inform_changed_data from openslides.utils.autoupdate import inform_changed_data
@ -299,12 +300,11 @@ class ListOfSpeakersViewSet(UpdateModelMixin, TreeSortMixin, GenericViewSet):
""" """
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. them. Send POST {'user': <user_id>} to add a new speaker.
Send POST {'user': <user_id>, 'point_of_order': True } to add a point Send POST {'user': <user_id>, 'point_of_order': True, 'note': <optional string> }
of order to the list of speakers. to add a pointof order to the list of speakers.
Omit data to add yourself. Send DELETE {'speaker': <speaker_id>} or 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.
Checks also whether the requesting user can do this. He needs at Checks also whether the requesting user can do this. He needs at
least the permissions 'agenda.can_see_list_of_speakers' (see least the permissions 'agenda.can_see_list_of_speakers' (see
@ -323,6 +323,10 @@ class ListOfSpeakersViewSet(UpdateModelMixin, TreeSortMixin, GenericViewSet):
if not isinstance(point_of_order, bool): if not isinstance(point_of_order, bool):
raise ValidationError({"detail": "point_of_order has to be a bool."}) raise ValidationError({"detail": "point_of_order has to be a bool."})
note = request.data.get("note")
if note is not None and not isinstance(note, str):
raise ValidationError({"detail": "note must be a string"})
# 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
@ -352,7 +356,7 @@ class ListOfSpeakersViewSet(UpdateModelMixin, TreeSortMixin, GenericViewSet):
# list of coming speakers. # list of coming speakers.
try: try:
speaker = Speaker.objects.add( speaker = Speaker.objects.add(
user, list_of_speakers, point_of_order=point_of_order user, list_of_speakers, point_of_order=point_of_order, note=note
) )
except OpenSlidesError as e: except OpenSlidesError as e:
raise ValidationError({"detail": str(e)}) raise ValidationError({"detail": str(e)})
@ -361,37 +365,7 @@ class ListOfSpeakersViewSet(UpdateModelMixin, TreeSortMixin, GenericViewSet):
# 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)
# Set 'marked' for the speaker elif request.method == "DELETE":
elif request.method == "PATCH":
# Check permissions
if not has_perm(self.request.user, "agenda.can_manage_list_of_speakers"):
self.permission_denied(request)
# Retrieve user_id
user_id = request.data.get("user")
try:
user = get_user_model().objects.get(pk=int(user_id))
except (ValueError, get_user_model().DoesNotExist):
raise ValidationError({"detail": "User does not exist."})
marked = request.data.get("marked")
if not isinstance(marked, bool):
raise ValidationError({"detail": "Marked has to be a bool."})
queryset = Speaker.objects.filter(
list_of_speakers=list_of_speakers, user=user, begin_time=None
)
if not queryset.exists():
raise ValidationError(
{"detail": "The user is not in the list of speakers."}
)
for speaker in queryset.all():
speaker.marked = marked
speaker.save()
else:
# request.method == 'DELETE'
speaker_ids = request.data.get("speaker") speaker_ids = request.data.get("speaker")
# Check permissions and other conditions. Get speaker instance. # Check permissions and other conditions. Get speaker instance.
@ -439,6 +413,8 @@ class ListOfSpeakersViewSet(UpdateModelMixin, TreeSortMixin, GenericViewSet):
# send autoupdate if speakers are deleted # send autoupdate if speakers are deleted
if deleted_some_speakers: if deleted_some_speakers:
inform_changed_data(list_of_speakers) inform_changed_data(list_of_speakers)
else:
raise ValidationError({"detail": "Invalid method"})
return Response() return Response()
@ -576,3 +552,42 @@ class ListOfSpeakersViewSet(UpdateModelMixin, TreeSortMixin, GenericViewSet):
Speaker.objects.all().delete() Speaker.objects.all().delete()
inform_changed_data(ListOfSpeakers.objects.all()) inform_changed_data(ListOfSpeakers.objects.all())
return Response() return Response()
class SpeakerViewSet(UpdateModelMixin, GenericViewSet):
queryset = Speaker.objects.all()
def check_view_permissions(self):
"""
Returns True if the user has required permissions.
"""
return has_perm(self.request.user, "agenda.can_see_list_of_speakers")
def update(self, request, *args, **kwargs):
# This is a hack to make request.data mutable. Otherwise fields can not be deleted.
if isinstance(request.data, QueryDict):
request.data._mutable = True
if (
"pro_speech" in request.data
and not config["agenda_list_of_speakers_enable_pro_contra_speech"]
):
raise ValidationError({"detail": "pro/contra speech is not enabled"})
if not has_perm(request.user, "agenda.can_manage_list_of_speakers"):
# if no manage perms, only the speaker user itself can update the speaker.
speaker = self.get_object()
if speaker.user_id != request.user.id:
self.permission_denied(request)
whitelist = ["pro_speech"] # was checked above
if config["agenda_list_of_speakers_can_set_mark_self"]:
whitelist.append("marked")
for key in request.data.keys():
if key not in whitelist:
raise ValidationError(
{"detail": f"You are not allowed to set {key}"}
)
return super().update(request, *args, **kwargs)

View File

@ -13,6 +13,7 @@ from django.utils.timezone import now
from django.views import static from django.views import static
from django.views.generic.base import View from django.views.generic.base import View
from openslides.agenda.models import ListOfSpeakers
from openslides.utils.utils import split_element_id from openslides.utils.utils import split_element_id
from .. import __license__ as license, __url__ as url, __version__ as version from .. import __license__ as license, __url__ as url, __version__ as version
@ -425,6 +426,7 @@ class ConfigViewSet(ModelViewSet):
# Validate and change value. # Validate and change value.
try: try:
config[key] = value config[key] = value
self.autoupdate_for_key(key)
except ConfigNotFound: except ConfigNotFound:
raise Http404 raise Http404
except ConfigError as err: except ConfigError as err:
@ -461,6 +463,7 @@ class ConfigViewSet(ModelViewSet):
for entry in request.data: for entry in request.data:
try: try:
config[entry["key"]] = entry["value"] config[entry["key"]] = entry["value"]
self.autoupdate_for_key(entry["key"])
except ConfigError as err: except ConfigError as err:
errors[entry["key"]] = str(err) errors[entry["key"]] = str(err)
@ -484,9 +487,14 @@ class ConfigViewSet(ModelViewSet):
and config[key] != config_variable.default_value and config[key] != config_variable.default_value
): ):
config[key] = config_variable.default_value config[key] = config_variable.default_value
self.autoupdate_for_key(key)
return Response() return Response()
def autoupdate_for_key(self, key):
if key == "agenda_list_of_speakers_speaker_note_for_everyone":
inform_changed_data(ListOfSpeakers.objects.all())
class ProjectorMessageViewSet(ModelViewSet): class ProjectorMessageViewSet(ModelViewSet):
""" """

View File

@ -529,27 +529,6 @@ class ManageSpeaker(TestCase):
) )
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
def test_mark_speaker(self):
Speaker.objects.add(self.user, self.list_of_speakers)
response = self.client.patch(
reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]),
{"user": self.user.pk, "marked": True},
)
self.assertEqual(response.status_code, 200)
self.assertTrue(Speaker.objects.get().marked)
def test_mark_speaker_non_admin(self):
self.make_admin_delegate()
Speaker.objects.add(self.user, self.list_of_speakers)
response = self.client.patch(
reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]),
{"user": self.user.pk},
)
self.assertEqual(response.status_code, 403)
# re-add last speaker # re-add last speaker
def util_add_user_as_last_speaker(self): def util_add_user_as_last_speaker(self):
speaker = Speaker.objects.add(self.user, self.list_of_speakers) speaker = Speaker.objects.add(self.user, self.list_of_speakers)
@ -649,6 +628,109 @@ class ManageSpeaker(TestCase):
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
class UpdateSpeaker(TestCase):
def advancedSetUp(self):
self.list_of_speakers = Topic.objects.create(
title="test_title_aZaedij4gohn5eeQu8fe"
).list_of_speakers
self.user, _ = self.create_user()
self.admin = get_user_model().objects.get(username="admin")
def test_mark_speaker(self):
speaker = Speaker.objects.add(self.user, self.list_of_speakers)
response = self.client.patch(
reverse("speaker-detail", args=[speaker.pk]),
{"marked": True},
)
self.assertEqual(response.status_code, 200)
self.assertTrue(Speaker.objects.get().marked)
def test_mark_speaker_non_admin(self):
self.make_admin_delegate()
speaker = Speaker.objects.add(self.user, self.list_of_speakers)
response = self.client.patch(
reverse("speaker-detail", args=[speaker.pk]),
{"marked": True},
)
self.assertEqual(response.status_code, 403)
self.assertFalse(Speaker.objects.get().marked)
def test_mark_speaker_self(self):
config["agenda_list_of_speakers_can_set_mark_self"] = True
self.make_admin_delegate()
speaker = Speaker.objects.add(self.admin, self.list_of_speakers)
response = self.client.patch(
reverse("speaker-detail", args=[speaker.pk]),
{"marked": True},
)
self.assertEqual(response.status_code, 200)
self.assertTrue(Speaker.objects.get().marked)
def test_mark_speaker_self_not_allowed(self):
self.make_admin_delegate()
speaker = Speaker.objects.add(self.admin, self.list_of_speakers)
response = self.client.patch(
reverse("speaker-detail", args=[speaker.pk]),
{"marked": True},
)
self.assertEqual(response.status_code, 400)
self.assertFalse(Speaker.objects.get().marked)
def test_pro_speech_admin(self):
config["agenda_list_of_speakers_enable_pro_contra_speech"] = True
speaker = Speaker.objects.add(self.user, self.list_of_speakers)
response = self.client.patch(
reverse("speaker-detail", args=[speaker.pk]),
{"pro_speech": True},
)
self.assertEqual(response.status_code, 200)
self.assertTrue(Speaker.objects.get().pro_speech)
def test_pro_speech_disabled(self):
speaker = Speaker.objects.add(self.user, self.list_of_speakers)
response = self.client.patch(
reverse("speaker-detail", args=[speaker.pk]),
{"pro_speech": True},
)
self.assertEqual(response.status_code, 400)
self.assertFalse(Speaker.objects.get().pro_speech)
def test_pro_speech_non_admin(self):
config["agenda_list_of_speakers_enable_pro_contra_speech"] = True
self.make_admin_delegate()
speaker = Speaker.objects.add(self.user, self.list_of_speakers)
response = self.client.patch(
reverse("speaker-detail", args=[speaker.pk]),
{"pro_speech": True},
)
self.assertEqual(response.status_code, 403)
self.assertFalse(Speaker.objects.get().pro_speech)
def test_pro_speech_self(self):
config["agenda_list_of_speakers_enable_pro_contra_speech"] = True
self.make_admin_delegate()
speaker = Speaker.objects.add(self.admin, self.list_of_speakers)
response = self.client.patch(
reverse("speaker-detail", args=[speaker.pk]),
{"pro_speech": True},
)
self.assertEqual(response.status_code, 200)
self.assertTrue(Speaker.objects.get().pro_speech)
class Speak(TestCase): class Speak(TestCase):
""" """
Tests view to begin or end speech. Tests view to begin or end speech.

View File

@ -32,7 +32,10 @@ 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, point_of_order=False self.request.user,
self.mock_list_of_speakers,
point_of_order=False,
note=None,
) )
@patch("openslides.agenda.views.inform_changed_data") @patch("openslides.agenda.views.inform_changed_data")
@ -56,7 +59,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, point_of_order=False mock_user, self.mock_list_of_speakers, point_of_order=False, note=None
) )
@patch("openslides.agenda.views.Speaker") @patch("openslides.agenda.views.Speaker")