From c9c90cd4a3ad3346beaa1c9d14bd2c662eae977f Mon Sep 17 00:00:00 2001 From: Finn Stutzenstein Date: Thu, 22 Apr 2021 11:25:27 +0200 Subject: [PATCH] pro/contra speakers and notes for point of order speakers Enhance Pro Contra UI/UX by Sean --- autoupdate | 2 +- .../core/core-services/operator.service.ts | 7 + .../list-of-speakers-repository.service.ts | 20 +- .../list-of-speakers-content.component.html | 257 +++++++++++++----- .../list-of-speakers-content.component.scss | 50 +++- .../list-of-speakers-content.component.ts | 57 +++- .../point-of-order-dialog.component.html | 19 ++ .../point-of-order-dialog.component.scss | 6 + .../point-of-order-dialog.component.ts | 41 +++ .../sorting-list/sorting-list.component.html | 18 +- .../sorting-list/sorting-list.component.scss | 27 +- .../src/app/shared/models/agenda/speaker.ts | 2 + client/src/app/shared/shared.module.ts | 7 +- .../assignment-detail.component.html | 22 +- .../assignment-detail.component.scss | 10 + .../category-motions-sort.component.html | 13 +- .../category-motions-sort.component.scss | 10 + ...motion-comment-section-sort.component.html | 8 +- .../manage-submitters.component.html | 19 +- .../manage-submitters.component.scss | 11 + .../common-list-of-speakers-slide-data.ts | 1 + ...mmon-list-of-speakers-slide.component.html | 12 +- ...t-of-speakers-overlay-slide.component.html | 2 + client/src/styles.scss | 18 +- server/openslides/agenda/apps.py | 6 +- server/openslides/agenda/config_variables.py | 30 ++ .../0013_speaker_note_and_pro_speech.py | 23 ++ server/openslides/agenda/models.py | 14 +- server/openslides/agenda/serializers.py | 11 + server/openslides/agenda/views.py | 85 +++--- server/openslides/core/views.py | 8 + .../tests/integration/agenda/test_viewset.py | 124 +++++++-- server/tests/unit/agenda/test_views.py | 7 +- 33 files changed, 738 insertions(+), 209 deletions(-) create mode 100644 client/src/app/shared/components/point-of-order-dialog/point-of-order-dialog.component.html create mode 100644 client/src/app/shared/components/point-of-order-dialog/point-of-order-dialog.component.scss create mode 100644 client/src/app/shared/components/point-of-order-dialog/point-of-order-dialog.component.ts create mode 100644 server/openslides/agenda/migrations/0013_speaker_note_and_pro_speech.py diff --git a/autoupdate b/autoupdate index f17ad9db2..d3e251aa0 160000 --- a/autoupdate +++ b/autoupdate @@ -1 +1 @@ -Subproject commit f17ad9db22e87c1b642cc5796ed6d07cd4e0f95b +Subproject commit d3e251aa0ca1b01b2fed3c0f2b4f5e2db87f5684 diff --git a/client/src/app/core/core-services/operator.service.ts b/client/src/app/core/core-services/operator.service.ts index 824405870..c76d54073 100644 --- a/client/src/app/core/core-services/operator.service.ts +++ b/client/src/app/core/core-services/operator.service.ts @@ -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; } diff --git a/client/src/app/core/repositories/agenda/list-of-speakers-repository.service.ts b/client/src/app/core/repositories/agenda/list-of-speakers-repository.service.ts index fd49a388c..386ceb28a 100644 --- a/client/src/app/core/repositories/agenda/list-of-speakers-repository.service.ts +++ b/client/src/app/core/repositories/agenda/list-of-speakers-repository.service.ts @@ -161,10 +161,11 @@ export class ListOfSpeakersRepositoryService extends BaseHasContentObjectReposit public async createSpeaker( listOfSpeakers: ViewListOfSpeakers, userId: number, - pointOfOrder?: boolean + pointOfOrder?: boolean, + note?: string ): Promise { const restUrl = this.getRestUrl(listOfSpeakers.id, 'manage_speaker'); - return await this.httpService.post(restUrl, { user: userId, point_of_order: pointOfOrder }); + return await this.httpService.post(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 { - 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 { + await this.httpService.put(`/rest/agenda/speaker/${speaker.id}/`, { marked: !speaker.marked }); + } + + public async setProContraSpeech(speaker: ViewSpeaker, proSpeech: boolean | null): Promise { + await this.httpService.put(`/rest/agenda/speaker/${speaker.id}/`, { pro_speech: proSpeech }); } /** diff --git a/client/src/app/shared/components/list-of-speakers-content/list-of-speakers-content.component.html b/client/src/app/shared/components/list-of-speakers-content/list-of-speakers-content.component.html index 122a10d80..89de117a0 100644 --- a/client/src/app/shared/components/list-of-speakers-content/list-of-speakers-content.component.html +++ b/client/src/app/shared/components/list-of-speakers-content/list-of-speakers-content.component.html @@ -20,7 +20,9 @@
{{ number + 1 }}.
{{ speaker.getTitle() }}
-
warning
+
+ warning +
{{ durationString(speaker) }} ({{ 'Start time' | translate }}: {{ startTimeToString(speaker) }})
@@ -49,10 +51,24 @@ mic - {{ activeSpeaker.getListTitle() }} + + {{ activeSpeaker.getListTitle() }} +
+ + {{ 'Pro speech' | translate }} + + + + {{ 'Contra speech' | translate }} + + + + {{ 'Contribution' | translate }} + +
+
- + + + warning + + {{ speaker.note }} + + + +
+
+ + + + + {{ hasSpokenCount(speaker) + 1 }}. {{ 'contribution' | translate }} + - - + + + {{ 'First speech' | translate }} + - - + + ({{ speaker.gender | translate }}) + - - - + + + + + - - - star - + + + + + + + + + + + + + + + + +
+ @@ -188,24 +270,63 @@ add {{ 'Add me' | translate }} - - - + + + + + + + + + + + + + + + + + + + + diff --git a/client/src/app/shared/components/list-of-speakers-content/list-of-speakers-content.component.scss b/client/src/app/shared/components/list-of-speakers-content/list-of-speakers-content.component.scss index 28bd0cd8b..f007253b2 100644 --- a/client/src/app/shared/components/list-of-speakers-content/list-of-speakers-content.component.scss +++ b/client/src/app/shared/components/list-of-speakers-content/list-of-speakers-content.component.scss @@ -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; -} diff --git a/client/src/app/shared/components/list-of-speakers-content/list-of-speakers-content.component.ts b/client/src/app/shared/components/list-of-speakers-content/list-of-speakers-content.component.ts index dfc497eaa..8842f9fc0 100644 --- a/client/src/app/shared/components/list-of-speakers-content/list-of-speakers-content.component.ts +++ b/client/src/app/shared/components/list-of-speakers-content/list-of-speakers-content.component.ts @@ -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('agenda_enable_point_of_order_speakers').subscribe(enabled => { this.pointOfOrderEnabled = enabled; + }), + this.config.get('agenda_list_of_speakers_enable_pro_contra_speech').subscribe(enabled => { + this.enableProContraSpeech = enabled; + }), + this.config.get('agenda_list_of_speakers_can_set_mark_self').subscribe(canMark => { + this.canSetMarkSelf = canMark; + }), + this.config.get('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 { - const title = this.translate.instant('Are you sure you want to submit a point of order?'); - if (await this.promptService.open(title)) { - try { - await this.listOfSpeakersRepo.createSpeaker(this.viewListOfSpeakers, undefined, true); - } catch (e) { - this.raiseError(e); + const dialogRef = this.dialog.open>( + PointOfOrderDialogComponent, + { + data: this.viewListOfSpeakers, + ...infoDialogSettings } + ); + + 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 * - * @param speaker the speaker marked in the list + * @param speaker the speaker selected in the list */ public async onStartButton(speaker: ViewSpeaker): Promise { 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); } /** diff --git a/client/src/app/shared/components/point-of-order-dialog/point-of-order-dialog.component.html b/client/src/app/shared/components/point-of-order-dialog/point-of-order-dialog.component.html new file mode 100644 index 000000000..4485ca862 --- /dev/null +++ b/client/src/app/shared/components/point-of-order-dialog/point-of-order-dialog.component.html @@ -0,0 +1,19 @@ +

+ {{ 'Are you sure you want to submit a point of order?' | translate }} +

+ + + {{ 'Request' | translate }} +
+ + + +
+ {{ editForm.value.note.length }} / {{ MAX_LENGTH }} +
+ + + + diff --git a/client/src/app/shared/components/point-of-order-dialog/point-of-order-dialog.component.scss b/client/src/app/shared/components/point-of-order-dialog/point-of-order-dialog.component.scss new file mode 100644 index 000000000..79fabdec3 --- /dev/null +++ b/client/src/app/shared/components/point-of-order-dialog/point-of-order-dialog.component.scss @@ -0,0 +1,6 @@ +.edit-form { + width: 100%; + .mat-form-field { + width: 100%; + } +} diff --git a/client/src/app/shared/components/point-of-order-dialog/point-of-order-dialog.component.ts b/client/src/app/shared/components/point-of-order-dialog/point-of-order-dialog.component.ts new file mode 100644 index 000000000..50f64f3d2 --- /dev/null +++ b/client/src/app/shared/components/point-of-order-dialog/point-of-order-dialog.component.ts @@ -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>, + @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(); + } +} diff --git a/client/src/app/shared/components/sorting-list/sorting-list.component.html b/client/src/app/shared/components/sorting-list/sorting-list.component.html index 93d277810..b2cd6c030 100644 --- a/client/src/app/shared/components/sorting-list/sorting-list.component.html +++ b/client/src/app/shared/components/sorting-list/sorting-list.component.html @@ -9,18 +9,22 @@ (click)="onItemClick($event, i)" (cdkDragStarted)="dragStarted(i)" > -
+ +
drag_indicator
-
- - {{ i + 1 }}.  - {{ item?.getTitle() }} + + +
+ {{ i + 1 }}.
-
- + + +
+ +
{{ multiSelectedIndex.length }} {{ 'items selected' | translate }} diff --git a/client/src/app/shared/components/sorting-list/sorting-list.component.scss b/client/src/app/shared/components/sorting-list/sorting-list.component.scss index 94a71d4aa..faef8a76f 100644 --- a/client/src/app/shared/components/sorting-list/sorting-list.component.scss +++ b/client/src/app/shared/components/sorting-list/sorting-list.component.scss @@ -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; } } diff --git a/client/src/app/shared/models/agenda/speaker.ts b/client/src/app/shared/models/agenda/speaker.ts index d4ee00d42..62b484b61 100644 --- a/client/src/app/shared/models/agenda/speaker.ts +++ b/client/src/app/shared/models/agenda/speaker.ts @@ -13,9 +13,11 @@ export class Speaker extends BaseModel { 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 diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index 6a754384f..daa4d480c 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -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: [ { diff --git a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.html b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.html index 6ac71094f..2edf9d689 100644 --- a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.html +++ b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.html @@ -155,15 +155,19 @@ > - - - +
+ {{ item.getTitle() }} + + + + +
diff --git a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.scss b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.scss index 1f87518de..2ba3ccf4a 100644 --- a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.scss +++ b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.scss @@ -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 { diff --git a/client/src/app/site/motions/modules/category/components/category-motions-sort/category-motions-sort.component.html b/client/src/app/site/motions/modules/category/components/category-motions-sort/category-motions-sort.component.html index ae307d02a..199d4eda3 100644 --- a/client/src/app/site/motions/modules/category/components/category-motions-sort/category-motions-sort.component.html +++ b/client/src/app/site/motions/modules/category/components/category-motions-sort/category-motions-sort.component.html @@ -15,9 +15,16 @@
- - {{ motion.id }} - +
+ + {{ motion.getTitle() }} + + + + {{ motion.id }} + + +
diff --git a/client/src/app/site/motions/modules/category/components/category-motions-sort/category-motions-sort.component.scss b/client/src/app/site/motions/modules/category/components/category-motions-sort/category-motions-sort.component.scss index e69de29bb..13dc0b0ff 100644 --- a/client/src/app/site/motions/modules/category/components/category-motions-sort/category-motions-sort.component.scss +++ b/client/src/app/site/motions/modules/category/components/category-motions-sort/category-motions-sort.component.scss @@ -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; + } +} diff --git a/client/src/app/site/motions/modules/motion-comment-section/components/motion-comment-section-sort/motion-comment-section-sort.component.html b/client/src/app/site/motions/modules/motion-comment-section/components/motion-comment-section-sort/motion-comment-section-sort.component.html index 0af55917b..ce1b330b7 100644 --- a/client/src/app/site/motions/modules/motion-comment-section/components/motion-comment-section-sort/motion-comment-section-sort.component.html +++ b/client/src/app/site/motions/modules/motion-comment-section/components/motion-comment-section-sort/motion-comment-section-sort.component.html @@ -8,5 +8,11 @@ - + + + + {{ comment.getTitle() }} + + + diff --git a/client/src/app/site/motions/modules/motion-detail/components/manage-submitters/manage-submitters.component.html b/client/src/app/site/motions/modules/motion-detail/components/manage-submitters/manage-submitters.component.html index fd09472e8..51ef63615 100644 --- a/client/src/app/site/motions/modules/motion-detail/components/manage-submitters/manage-submitters.component.html +++ b/client/src/app/site/motions/modules/motion-detail/components/manage-submitters/manage-submitters.component.html @@ -27,9 +27,22 @@ > - +
+ + {{ user.getTitle() }} + + + + + +
diff --git a/client/src/app/site/motions/modules/motion-detail/components/manage-submitters/manage-submitters.component.scss b/client/src/app/site/motions/modules/motion-detail/components/manage-submitters/manage-submitters.component.scss index 3447bfc73..0db1e05e5 100644 --- a/client/src/app/site/motions/modules/motion-detail/components/manage-submitters/manage-submitters.component.scss +++ b/client/src/app/site/motions/modules/motion-detail/components/manage-submitters/manage-submitters.component.scss @@ -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; diff --git a/client/src/app/slides/agenda/common/common-list-of-speakers-slide-data.ts b/client/src/app/slides/agenda/common/common-list-of-speakers-slide-data.ts index 2961c4170..9dc2c0668 100644 --- a/client/src/app/slides/agenda/common/common-list-of-speakers-slide-data.ts +++ b/client/src/app/slides/agenda/common/common-list-of-speakers-slide-data.ts @@ -2,6 +2,7 @@ export interface SlideSpeaker { user: string; marked: boolean; point_of_order: boolean; + pro_speech?: boolean; } export interface CommonListOfSpeakersSlideData { diff --git a/client/src/app/slides/agenda/common/common-list-of-speakers-slide.component.html b/client/src/app/slides/agenda/common/common-list-of-speakers-slide.component.html index 5688067fd..c4490b23a 100644 --- a/client/src/app/slides/agenda/common/common-list-of-speakers-slide.component.html +++ b/client/src/app/slides/agenda/common/common-list-of-speakers-slide.component.html @@ -17,7 +17,9 @@
{{ speaker.user }} star - warning + add_circle + remove_circle + warning
@@ -26,7 +28,9 @@ mic {{ data.data.current.user }} star - warning + add_circle + remove_circle + warning
@@ -35,7 +39,9 @@
  • {{ speaker.user }} star - warning + add_circle + remove_circle + warning
  • diff --git a/client/src/app/slides/agenda/current-list-of-speakers-overlay/current-list-of-speakers-overlay-slide.component.html b/client/src/app/slides/agenda/current-list-of-speakers-overlay/current-list-of-speakers-overlay-slide.component.html index 83d80fa33..4b9edca41 100644 --- a/client/src/app/slides/agenda/current-list-of-speakers-overlay/current-list-of-speakers-overlay-slide.component.html +++ b/client/src/app/slides/agenda/current-list-of-speakers-overlay/current-list-of-speakers-overlay-slide.component.html @@ -10,6 +10,8 @@
  • {{ speaker.user }} star + thumb_up + thumb_down
  • diff --git a/client/src/styles.scss b/client/src/styles.scss index 2c906d5be..492b6fd77 100644 --- a/client/src/styles.scss +++ b/client/src/styles.scss @@ -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: + * ... + * ... + */ +.inline-icon-text-align.mat-icon { + vertical-align: bottom; } .icon-text-distance { diff --git a/server/openslides/agenda/apps.py b/server/openslides/agenda/apps.py index d339f873e..1b8c60b41 100644 --- a/server/openslides/agenda/apps.py +++ b/server/openslides/agenda/apps.py @@ -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 diff --git a/server/openslides/agenda/config_variables.py b/server/openslides/agenda/config_variables.py index a62d99434..5e4b04379 100644 --- a/server/openslides/agenda/config_variables.py +++ b/server/openslides/agenda/config_variables.py @@ -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", + ) diff --git a/server/openslides/agenda/migrations/0013_speaker_note_and_pro_speech.py b/server/openslides/agenda/migrations/0013_speaker_note_and_pro_speech.py new file mode 100644 index 000000000..e4702f533 --- /dev/null +++ b/server/openslides/agenda/migrations/0013_speaker_note_and_pro_speech.py @@ -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), + ), + ] diff --git a/server/openslides/agenda/models.py b/server/openslides/agenda/models.py index cb81575e9..bb114e945 100644 --- a/server/openslides/agenda/models.py +++ b/server/openslides/agenda/models.py @@ -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"),) diff --git a/server/openslides/agenda/serializers.py b/server/openslides/agenda/serializers.py index b05ae295b..78233d8bb 100644 --- a/server/openslides/agenda/serializers.py +++ b/server/openslides/agenda/serializers.py @@ -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", ) diff --git a/server/openslides/agenda/views.py b/server/openslides/agenda/views.py index f0210aaf3..72516b17f 100644 --- a/server/openslides/agenda/views.py +++ b/server/openslides/agenda/views.py @@ -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': } to add a new speaker. - Send POST {'user': , 'point_of_order': True } to add a point - of order to the list of speakers. + Send POST {'user': , 'point_of_order': True, 'note': } + to add a pointof order to the list of speakers. Omit data to add yourself. Send DELETE {'speaker': } or DELETE {'speaker': [, , ...]} to remove one or more speakers from the list of speakers. Omit data to remove yourself. - Send PATCH {'user': , 'marked': } 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) diff --git a/server/openslides/core/views.py b/server/openslides/core/views.py index 57fe2a68d..e80c7c01a 100644 --- a/server/openslides/core/views.py +++ b/server/openslides/core/views.py @@ -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): """ diff --git a/server/tests/integration/agenda/test_viewset.py b/server/tests/integration/agenda/test_viewset.py index 1b6b81eea..b923f156f 100644 --- a/server/tests/integration/agenda/test_viewset.py +++ b/server/tests/integration/agenda/test_viewset.py @@ -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. diff --git a/server/tests/unit/agenda/test_views.py b/server/tests/unit/agenda/test_views.py index 8f3bb8310..0219c7655 100644 --- a/server/tests/unit/agenda/test_views.py +++ b/server/tests/unit/agenda/test_views.py @@ -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")