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;
}
/**
* Returns 0 for the anonymous
*/
public get userId(): number {
return this.user?.id || 0;
}
public get isAnonymous(): boolean {
return !this.user || this.user.id === 0;
}

View File

@ -161,10 +161,11 @@ export class ListOfSpeakersRepositoryService extends BaseHasContentObjectReposit
public async createSpeaker(
listOfSpeakers: ViewListOfSpeakers,
userId: number,
pointOfOrder?: boolean
pointOfOrder?: boolean,
note?: string
): Promise<Identifiable> {
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
*
* @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
* Toggles the mark for a given speaker.
*/
public async markSpeaker(listOfSpeakers: ViewListOfSpeakers, speaker: ViewSpeaker, marked: boolean): Promise<void> {
const restUrl = this.getRestUrl(listOfSpeakers.id, 'manage_speaker');
await this.httpService.patch(restUrl, { user: speaker.user.id, marked: marked });
public async toggleMarked(speaker: ViewSpeaker): Promise<void> {
await this.httpService.put(`/rest/agenda/speaker/${speaker.id}/`, { marked: !speaker.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="number">{{ number + 1 }}.</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">
{{ durationString(speaker) }} ({{ 'Start time' | translate }}: {{ startTimeToString(speaker) }})
</div>
@ -49,10 +51,24 @@
<mat-icon>mic</mat-icon>
</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">
<!-- point of order visible for everyone -->
<button
mat-icon-button
@ -84,7 +100,56 @@
>
<!-- implicit speaker references into the component using ng-template slot -->
<ng-template let-speaker>
<span *osPerms="'agenda.can_manage_list_of_speakers'; and: !speaker.point_of_order">
<div class="single-speaker-line">
<div class="speaker-name">
<!-- Speaker name -->
<div>
{{ 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>
<ng-container *ngIf="speaker.pro_speech === false">
<mat-icon inline class="inline-icon-text-align red-warning-text">
remove_circle
</mat-icon>
<i class="user-subtitle">
{{ 'Contra speech' | translate }}
</i>
</ng-container>
<ng-container *ngIf="speaker.marked">
<mat-icon inline class="inline-icon-text-align icon">star</mat-icon>
<i class="user-subtitle">
{{ 'Contribution' | translate }}
</i>
</ng-container>
<!-- Point Of Order and note -->
<ng-container>
<mat-icon
inline
color="warn"
class="inline-icon-text-align"
*ngIf="speaker.point_of_order"
>warning</mat-icon
>
<i *ngIf="showSpeakersOrderNote" class="red-warning-text">
{{ 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>
@ -102,52 +167,69 @@
<span *ngIf="speaker.gender">({{ speaker.gender | translate }})</span>
</span>
<!-- Start, start and delete buttons -->
<span>
<!-- For normal users -->
<span *osPerms="permission.agendaCanManageListOfSpeakers; complement: true">
<ng-container *ngIf="speakerIsOperator(speaker)">
<!-- pro -->
<button
mat-icon-button
(click)="onProContraButtons(speaker, true)"
matTooltip="{{ 'Pro speech' | translate }}"
*ngIf="enableProContraSpeech && !speaker.point_of_order"
>
<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>
<!-- contra -->
<button
mat-icon-button
(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)"
*osPerms="'agenda.can_manage_list_of_speakers'"
>
<mat-icon>play_arrow</mat-icon>
</button>
<!-- star button -->
<!-- more menu button -->
<button
mat-icon-button
matTooltip="{{ 'Mark speaker' | translate }}"
(click)="onMarkButton(speaker)"
*osPerms="'agenda.can_manage_list_of_speakers'; and: !speaker.point_of_order"
[matMenuTriggerFor]="manageSpeakerMenu"
[matMenuTriggerData]="{ speaker: speaker }"
>
<mat-icon>{{ speaker.marked ? 'star' : 'star_border' }}</mat-icon>
</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 -->
<button
mat-icon-button
matTooltip="{{ 'Remove' | translate }}"
(click)="removeSpeaker(speaker)"
*osPerms="'agenda.can_manage_list_of_speakers'"
>
<mat-icon>close</mat-icon>
<mat-icon>more_vert</mat-icon>
</button>
</span>
<!-- For thouse without LOS -->
<span *osPerms="'agenda.can_manage_list_of_speakers'; complement: true">
<mat-icon *ngIf="speaker.marked"> star </mat-icon>
</span>
</div>
</div>
</ng-template>
</os-sorting-list>
</div>
@ -188,24 +270,63 @@
<mat-icon>add</mat-icon>
<span>{{ 'Add me' | translate }}</span>
</button>
<button
*ngIf="isOpInWaitlist()"
mat-stroked-button
[disabled]="closed"
(click)="removeSpeaker()"
>
<button *ngIf="isOpInWaitlist()" mat-stroked-button [disabled]="closed" (click)="removeSpeaker()">
<mat-icon>remove</mat-icon>
<span>{{ 'Remove me' | translate }}</span>
</button>
<!-- 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;
<span>{{ 'Point of order' | translate }}</span>
</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>
<span>{{ 'Withdraw point of order' | translate }}</span>
</button>
</div>
</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;
margin: 50px 25px 20px 25px;
box-shadow: 0px 3px 10px 0px rgba(0, 0, 0, 0.25);
line-height: 1;
.prefix {
display: table-cell;
@ -99,6 +100,13 @@
font-weight: bold;
padding-left: 10px;
width: 100%;
.active-speaker-subtitle {
* {
font-weight: initial;
font-size: 95%;
}
}
}
.suffix {
@ -111,6 +119,34 @@
.waiting-list {
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 {
@ -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
} from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
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 { PromptService } from 'app/core/ui-services/prompt.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 { SpeakerState, ViewSpeaker } from 'app/site/agenda/models/view-speaker';
import { BaseViewComponentDirective } from 'app/site/base/base-view';
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 { SortingListComponent } from '../sorting-list/sorting-list.component';
@ -63,6 +67,9 @@ export class ListOfSpeakersContentComponent extends BaseViewComponentDirective i
}
private pointOfOrderEnabled: boolean;
public enableProContraSpeech: boolean;
private canSetMarkSelf: boolean;
private noteForAll: boolean;
public get title(): string {
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;
}
public get showSpeakersOrderNote(): boolean {
return this.noteForAll || this.opCanManage;
}
@Input()
public set speakers(los: ViewListOfSpeakers) {
this.setListOfSpeakers(los);
@ -113,7 +124,8 @@ export class ListOfSpeakersContentComponent extends BaseViewComponentDirective i
private userRepository: UserRepositoryService,
private config: ConfigService,
private viewport: ViewportService,
private cd: ChangeDetectorRef
private cd: ChangeDetectorRef,
private dialog: MatDialog
) {
super(title, translate, snackBar);
this.addSpeakerForm = new FormGroup({ user_id: new FormControl() });
@ -150,6 +162,15 @@ export class ListOfSpeakersContentComponent extends BaseViewComponentDirective i
// observe point of order settings
this.config.get<boolean>('agenda_enable_point_of_order_speakers').subscribe(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,15 +230,20 @@ export class ListOfSpeakersContentComponent extends BaseViewComponentDirective i
}
public async addPointOfOrder(): Promise<void> {
const title = this.translate.instant('Are you sure you want to submit a point of order?');
if (await this.promptService.open(title)) {
const dialogRef = this.dialog.open<PointOfOrderDialogComponent, ViewListOfSpeakers, Promise<Identifiable>>(
PointOfOrderDialogComponent,
{
data: this.viewListOfSpeakers,
...infoDialogSettings
}
);
try {
await this.listOfSpeakersRepo.createSpeaker(this.viewListOfSpeakers, undefined, true);
await dialogRef.afterClosed().toPromise();
} catch (e) {
this.raiseError(e);
}
}
}
public removePointOfOrder(): void {
this.listOfSpeakersRepo.deleteSpeaker(this.viewListOfSpeakers, undefined, true).catch(this.raiseError);
@ -235,7 +261,7 @@ export class ListOfSpeakersContentComponent extends BaseViewComponentDirective i
/**
* 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> {
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.
*
* @param speaker The speaker clicked on.
*/
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)"
(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>
</div>
<div class="section-two">
<!-- {number}. {item.getTitle()} -->
<span *ngIf="count">{{ i + 1 }}.&nbsp;</span>
<span>{{ item?.getTitle() }}</span>
<!-- Count number -->
<div class="count" *ngIf="count">
<span>{{ i + 1 }}.</span>
</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>
</div>
<!-- Drag prev -->
<div class="line" *cdkDragPreview>
<div class="spacer.left-10" *ngIf="multiSelectedIndex.length > 0">
{{ multiSelectedIndex.length }}&nbsp;<span>{{ 'items selected' | translate }}</span>

View File

@ -9,31 +9,26 @@
min-height: 50px;
margin-bottom: 5px;
.section-one {
.drag-indicator {
display: table-cell;
padding: 0 10px;
line-height: 0px;
vertical-align: middle;
padding: 0 0.5em;
line-height: 0px;
width: 50px;
color: slategrey;
cursor: move;
}
.section-two {
.count {
display: table-cell;
vertical-align: middle;
min-width: 2em;
padding-left: 1.25em;
}
.content {
display: table-cell;
vertical-align: middle;
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 user_id: number;
public weight: number;
public note?: string;
public marked: boolean;
public item_id: number;
public point_of_order: boolean;
public pro_speech?: boolean;
/**
* 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 { NgParticlesModule } from 'ng-particles';
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.
@ -305,7 +306,8 @@ import { ApplauseParticleDisplayComponent } from './components/applause-particle
AssignmentPollDetailContentComponent,
JitsiComponent,
VideoPlayerComponent,
ListOfSpeakersContentComponent
ListOfSpeakersContentComponent,
PointOfOrderDialogComponent
],
declarations: [
PermsDirective,
@ -372,7 +374,8 @@ import { ApplauseParticleDisplayComponent } from './components/applause-particle
ListOfSpeakersContentComponent,
ApplauseBarDisplayComponent,
ProgressComponent,
ApplauseParticleDisplayComponent
ApplauseParticleDisplayComponent,
PointOfOrderDialogComponent
],
providers: [
{

View File

@ -155,6 +155,9 @@
>
<!-- implicit item references into the component using ng-template slot -->
<ng-template let-item>
<div class="single-candidate-line">
<span *ngIf="hasPerms('manage')"> {{ item.getTitle() }} </span>
<span *ngIf="hasPerms('manage')">
<button
mat-icon-button
@ -164,6 +167,7 @@
<mat-icon>clear</mat-icon>
</button>
</span>
</div>
</ng-template>
</os-sorting-list>
</div>

View File

@ -40,6 +40,16 @@
.candidates-list {
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 {

View File

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

View File

@ -27,9 +27,22 @@
>
<!-- implicit user references into the component using ng-template slot -->
<ng-template let-user>
<button type="button" mat-icon-button matTooltip="{{ 'Remove' | translate }}" (click)="onRemove(user)">
<div class="single-submitter-line">
<span class="ellipsis-overflow">
{{ 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>
</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 {
border-radius: 16px !important;
padding: 5px 15px !important;

View File

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

View File

@ -17,6 +17,8 @@
<div *ngFor="let speaker of data.data.finished">
{{ speaker.user }}
<mat-icon *ngIf="speaker.marked">star</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>
@ -26,6 +28,8 @@
<mat-icon class="micicon">mic</mat-icon>
{{ data.data.current.user }}
<mat-icon *ngIf="data.data.current.marked">star</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>
@ -35,6 +39,8 @@
<li *ngFor="let speaker of data.data.waiting">
{{ speaker.user }}
<mat-icon *ngIf="speaker.marked">star</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>
</ol>

View File

@ -10,6 +10,8 @@
<li *ngFor="let speaker of nextSpeakers" class="one-line">
{{ speaker.user }}
<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>
</ol>
</div>

View File

@ -173,15 +173,27 @@ b,
}
.red-warning-text {
color: red;
color: red !important;
.mat-icon {
color: red !important;
}
}
.green-text {
// TODO better name/theming
color: #5a5;
color: #5a5 !important;
}
.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 {

View File

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

View File

@ -220,3 +220,33 @@ def get_config_variables():
group="Agenda",
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.
"""
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
list of speakers and that someone is twice on one list (off coming
@ -450,6 +457,7 @@ class SpeakerManager(models.Manager):
user=user,
weight=weight,
point_of_order=point_of_order,
note=note,
)
speaker.save(
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.
"""
note = models.CharField(max_length=255, null=True, blank=True)
marked = models.BooleanField(default=False)
"""
Marks a speaker.
@ -544,6 +554,8 @@ class Speaker(RESTModelMixin, models.Model):
Identifies the speaker as someone with a point of order
"""
pro_speech = models.BooleanField(null=True, blank=True, default=None)
class Meta:
default_permissions = ()
permissions = (("can_be_speaker", "Can put oneself on the list of speakers"),)

View File

@ -16,8 +16,19 @@ class SpeakerSerializer(ModelSerializer):
"begin_time",
"end_time",
"weight",
"note",
"marked",
"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.db import transaction
from django.db.utils import IntegrityError
from django.http.request import QueryDict
from openslides.core.config import config
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
them. Send POST {'user': <user_id>} to add a new speaker.
Send POST {'user': <user_id>, 'point_of_order': True } to add a point
of order to the list of speakers.
Send POST {'user': <user_id>, 'point_of_order': True, 'note': <optional string> }
to add a pointof 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
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
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):
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.
if user_id is None:
# Add oneself
@ -352,7 +356,7 @@ class ListOfSpeakersViewSet(UpdateModelMixin, TreeSortMixin, GenericViewSet):
# list of coming speakers.
try:
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:
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.
inform_changed_data(user, disable_history=True)
# Set 'marked' for the speaker
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'
elif request.method == "DELETE":
speaker_ids = request.data.get("speaker")
# Check permissions and other conditions. Get speaker instance.
@ -439,6 +413,8 @@ class ListOfSpeakersViewSet(UpdateModelMixin, TreeSortMixin, GenericViewSet):
# send autoupdate if speakers are deleted
if deleted_some_speakers:
inform_changed_data(list_of_speakers)
else:
raise ValidationError({"detail": "Invalid method"})
return Response()
@ -576,3 +552,42 @@ class ListOfSpeakersViewSet(UpdateModelMixin, TreeSortMixin, GenericViewSet):
Speaker.objects.all().delete()
inform_changed_data(ListOfSpeakers.objects.all())
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.generic.base import View
from openslides.agenda.models import ListOfSpeakers
from openslides.utils.utils import split_element_id
from .. import __license__ as license, __url__ as url, __version__ as version
@ -425,6 +426,7 @@ class ConfigViewSet(ModelViewSet):
# Validate and change value.
try:
config[key] = value
self.autoupdate_for_key(key)
except ConfigNotFound:
raise Http404
except ConfigError as err:
@ -461,6 +463,7 @@ class ConfigViewSet(ModelViewSet):
for entry in request.data:
try:
config[entry["key"]] = entry["value"]
self.autoupdate_for_key(entry["key"])
except ConfigError as err:
errors[entry["key"]] = str(err)
@ -484,9 +487,14 @@ class ConfigViewSet(ModelViewSet):
and config[key] != config_variable.default_value
):
config[key] = config_variable.default_value
self.autoupdate_for_key(key)
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):
"""

View File

@ -529,27 +529,6 @@ class ManageSpeaker(TestCase):
)
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
def util_add_user_as_last_speaker(self):
speaker = Speaker.objects.add(self.user, self.list_of_speakers)
@ -649,6 +628,109 @@ class ManageSpeaker(TestCase):
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):
"""
Tests view to begin or end speech.

View File

@ -32,7 +32,10 @@ class ListOfSpeakersViewSetManageSpeaker(TestCase):
self.view_instance.manage_speaker(self.request)
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")
@ -56,7 +59,7 @@ class ListOfSpeakersViewSetManageSpeaker(TestCase):
MockUser.objects.get.assert_called_with(pk=2)
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")