Merge pull request #5515 from tsiegleauq/lazy-user-mode

Implement cinema mode (autopilot)
This commit is contained in:
Emanuel Schütze 2020-09-23 08:37:33 +02:00 committed by GitHub
commit cf4573cb54
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
70 changed files with 1238 additions and 739 deletions

View File

@ -3,6 +3,7 @@ import { Injectable, Injector } from '@angular/core';
import { AgendaAppConfig } from '../../site/agenda/agenda.config'; import { AgendaAppConfig } from '../../site/agenda/agenda.config';
import { AppConfig, ModelEntry, SearchableModelEntry } from '../definitions/app-config'; import { AppConfig, ModelEntry, SearchableModelEntry } from '../definitions/app-config';
import { BaseRepository } from 'app/core/repositories/base-repository'; import { BaseRepository } from 'app/core/repositories/base-repository';
import { CinemaAppConfig } from 'app/site/cinema/cinema.config';
import { HistoryAppConfig } from 'app/site/history/history.config'; import { HistoryAppConfig } from 'app/site/history/history.config';
import { ProjectorAppConfig } from 'app/site/projector/projector.config'; import { ProjectorAppConfig } from 'app/site/projector/projector.config';
import { TopicsAppConfig } from 'app/site/topics/topics.config'; import { TopicsAppConfig } from 'app/site/topics/topics.config';
@ -35,7 +36,8 @@ const appConfigs: AppConfig[] = [
UsersAppConfig, UsersAppConfig,
HistoryAppConfig, HistoryAppConfig,
ProjectorAppConfig, ProjectorAppConfig,
TopicsAppConfig TopicsAppConfig,
CinemaAppConfig
]; ];
/** /**

View File

@ -40,6 +40,7 @@ export enum Permission {
coreCanSeeProjector = 'core.can_see_projector', coreCanSeeProjector = 'core.can_see_projector',
coreCanManageTags = 'core.can_manage_tags', coreCanManageTags = 'core.can_manage_tags',
coreCanSeeLiveStream = 'core.can_see_livestream', coreCanSeeLiveStream = 'core.can_see_livestream',
coreCanSeeAutopilot = 'core.can_see_autopilot',
mediafilesCanManage = 'mediafiles.can_manage', mediafilesCanManage = 'mediafiles.can_manage',
mediafilesCanSee = 'mediafiles.can_see', mediafilesCanSee = 'mediafiles.can_see',
motionsCanCreate = 'motions.can_create', motionsCanCreate = 'motions.can_create',

View File

@ -317,7 +317,7 @@ export class ProjectorService {
* @param element The projector element * @param element The projector element
* @returns the view model from the projector element * @returns the view model from the projector element
*/ */
public getViewModelFromProjectorElement<T extends BaseProjectableViewModel>( public getViewModelFromIdentifiableProjectorElement<T extends BaseProjectableViewModel>(
element: IdentifiableProjectorElement element: IdentifiableProjectorElement
): T { ): T {
this.assertElementIsMappable(element); this.assertElementIsMappable(element);
@ -328,12 +328,16 @@ export class ProjectorService {
return viewModel; return viewModel;
} }
public getViewModelFromProjectorElement<T extends BaseProjectableViewModel>(element: ProjectorElement): T {
const idElement = this.slideManager.getIdentifiableProjectorElement(element);
return this.getViewModelFromIdentifiableProjectorElement(idElement);
}
/** /**
*/ */
public getSlideTitle(element: ProjectorElement): ProjectorTitle { public getSlideTitle(element: ProjectorElement): ProjectorTitle {
if (this.slideManager.canSlideBeMappedToModel(element.name)) { if (this.slideManager.canSlideBeMappedToModel(element.name)) {
const idElement = this.slideManager.getIdentifiableProjectorElement(element); const viewModel = this.getViewModelFromProjectorElement(element);
const viewModel = this.getViewModelFromProjectorElement(idElement);
if (viewModel) { if (viewModel) {
return viewModel.getProjectorTitle(); return viewModel.getProjectorTitle();
} }

View File

@ -138,6 +138,14 @@ export class ProjectorRepositoryService extends BaseRepository<ViewProjector, Pr
await this.http.post<void>(`/rest/core/projector/${projector_id}/set_reference_projector/`); await this.http.post<void>(`/rest/core/projector/${projector_id}/set_reference_projector/`);
} }
public getReferenceProjectorObservable(): Observable<ViewProjector> {
return this.getViewModelListObservable().pipe(
map(projectors => {
return projectors.find(projector => projector.isReferenceProjector);
})
);
}
/** /**
* return the id of the current reference projector * return the id of the current reference projector
* prefer the observable whenever possible * prefer the observable whenever possible
@ -146,15 +154,4 @@ export class ProjectorRepositoryService extends BaseRepository<ViewProjector, Pr
// TODO: After logging in, this is null this.getViewModelList() is null // TODO: After logging in, this is null this.getViewModelList() is null
return this.getViewModelList().find(projector => projector.isReferenceProjector).id; return this.getViewModelList().find(projector => projector.isReferenceProjector).id;
} }
public getReferenceProjectorIdObservable(): Observable<number> {
return this.getViewModelListObservable().pipe(
map(projectors => {
const refProjector = projectors.find(projector => projector.isReferenceProjector);
if (refProjector) {
return refProjector.id;
}
})
);
}
} }

View File

@ -2,7 +2,7 @@ import { Component, Input } from '@angular/core';
import { AssignmentPollMethod } from 'app/shared/models/assignments/assignment-poll'; import { AssignmentPollMethod } from 'app/shared/models/assignments/assignment-poll';
import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll'; import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll';
import { AssignmentPollService } from 'app/site/assignments/services/assignment-poll.service'; import { AssignmentPollService } from 'app/site/assignments/modules/assignment-poll/services/assignment-poll.service';
import { PollData, PollTableData, VotingResult } from 'app/site/polls/services/poll.service'; import { PollData, PollTableData, VotingResult } from 'app/site/polls/services/poll.service';
@Component({ @Component({

View File

@ -0,0 +1,171 @@
<mat-card class="os-card speaker-card">
<!-- Title -->
<h1 class="los-title" *ngIf="!customTitle">
<span>
{{ title }}
</span>
<mat-icon *ngIf="closed" matTooltip="{{ 'The list of speakers is closed.' | translate }}">
lock
</mat-icon>
</h1>
<span *ngIf="customTitle">
<ng-content></ng-content>
</span>
<!-- List of finished speakers -->
<mat-expansion-panel *ngIf="finishedSpeakers?.length" class="finished-list">
<mat-expansion-panel-header>
<mat-panel-title>{{ 'Last speakers' | translate }}</mat-panel-title>
</mat-expansion-panel-header>
<mat-list>
<mat-list-item *ngFor="let speaker of finishedSpeakers; let number = index">
<div class="finished-speaker-grid">
<div class="number">{{ number + 1 }}.</div>
<div class="name">{{ speaker.getTitle() }}</div>
<div class="time">
{{ durationString(speaker) }} ({{ 'Start time' | translate }}: {{ startTimeToString(speaker) }})
</div>
<div class="controls">
<button
mat-icon-button
matTooltip="{{ 'Remove' | translate }}"
*osPerms="'agenda.can_manage_list_of_speakers'"
(click)="onDeleteButton(speaker)"
>
<mat-icon>close</mat-icon>
</button>
</div>
</div>
</mat-list-item>
</mat-list>
</mat-expansion-panel>
<!-- horizontal separation line -->
<mat-divider *ngIf="finishedSpeakers && finishedSpeakers.length"></mat-divider>
<div *ngIf="finishedSpeakers && finishedSpeakers.length" class="spacer-bottom-40"></div>
<!-- Current Speaker -->
<div class="current-speaker" *ngIf="activeSpeaker">
<span class="prefix">
<mat-icon>mic</mat-icon>
</span>
<span class="name">{{ activeSpeaker.getListTitle() }}</span>
<span class="suffix">
<!-- Stop speaker button -->
<button
mat-icon-button
matTooltip="{{ 'End speech' | translate }}"
*osPerms="'agenda.can_manage_list_of_speakers'"
(click)="onStopButton()"
>
<mat-icon>stop</mat-icon>
</button>
</span>
</div>
<!-- Waiting speakers -->
<div class="waiting-list" *ngIf="waitingSpeakers?.length">
<os-sorting-list
[live]="true"
[input]="waitingSpeakers"
[count]="true"
[enable]="opCanManage && (isSortMode || !isMobile)"
(sortEvent)="onSortingChanged($event)"
>
<!-- implicit speaker references into the component using ng-template slot -->
<ng-template let-speaker>
<span *osPerms="'agenda.can_manage_list_of_speakers'">
<!-- Speaker count -->
<span *ngIf="hasSpokenCount(speaker)" class="red-warning-text speaker-warning">
{{ hasSpokenCount(speaker) + 1 }}. <span>{{ 'contribution' | translate }}</span>
</span>
<!-- First contribution -->
<span
*ngIf="showFistContributionHint && isFirstContribution(speaker)"
class="red-warning-text speaker-warning"
>
{{ 'First speech' | translate }}
</span>
<!-- Speaker gender -->
<span *ngIf="speaker.gender">({{ speaker.gender | translate }})</span>
</span>
<!-- Start, start and delete buttons -->
<span *osPerms="'agenda.can_manage_list_of_speakers'">
<!-- start button -->
<button
mat-icon-button
matTooltip="{{ 'Begin speech' | translate }}"
(click)="onStartButton(speaker)"
>
<mat-icon>play_arrow</mat-icon>
</button>
<!-- star button -->
<button
mat-icon-button
matTooltip="{{ 'Mark speaker' | translate }}"
(click)="onMarkButton(speaker)"
>
<mat-icon>{{ speaker.marked ? 'star' : 'star_border' }}</mat-icon>
</button>
<!-- delete button -->
<button mat-icon-button matTooltip="{{ 'Remove' | translate }}" (click)="onDeleteButton(speaker)">
<mat-icon>close</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>
</ng-template>
</os-sorting-list>
</div>
<!-- Search for speakers -->
<div *osPerms="'agenda.can_manage_list_of_speakers'">
<form
*ngIf="waitingSpeakers && filteredUsers?.value?.length"
[formGroup]="addSpeakerForm"
class="search-new-speaker-form"
>
<mat-form-field class="search-users-field">
<os-search-value-selector
class="search-users"
formControlName="user_id"
placeholder="{{ 'Select or search new speaker ...' | translate }}"
[inputListValues]="filteredUsers"
[showNotFoundButton]="true"
(clickNotFound)="onCreateUser($event)"
>
<ng-container notFoundDescription>
<mat-icon>add</mat-icon>
{{ 'Create user' | translate }}
</ng-container>
</os-search-value-selector>
</mat-form-field>
</form>
</div>
<!-- Add me and remove me if OP has correct permission -->
<div *osPerms="'agenda.can_be_speaker'" class="add-self-buttons">
<div *ngIf="waitingSpeakers && !closed">
<button mat-stroked-button [disabled]="closed" (click)="addNewSpeaker()" *ngIf="!isOpInList && canAddSelf">
<mat-icon>add</mat-icon>
<span>{{ 'Add me' | translate }}</span>
</button>
<button mat-stroked-button (click)="onDeleteButton()" *ngIf="isOpInList">
<mat-icon>remove</mat-icon>
<span>{{ 'Remove me' | translate }}</span>
</button>
</div>
</div>
</mat-card>

View File

@ -0,0 +1,125 @@
@import '~assets/styles/variables.scss';
.los-title {
margin-left: 25px;
}
.finished-list {
box-shadow: none !important;
margin-bottom: 15px;
.mat-list-item {
height: auto;
margin-bottom: 10px;
}
.finished-speaker-grid {
display: grid;
width: 100%;
grid-gap: 5px;
grid-template-areas:
'number name controls'
'number time controls';
grid-template-columns: 30px 1fr min-content;
}
@include desktop {
.finished-speaker-grid {
grid-template-areas: 'number name time controls';
grid-template-columns: 30px 1fr min-content min-content;
}
}
.number {
grid-area: number;
margin: 0;
}
.name {
grid-area: name;
margin: 0;
}
.time {
grid-area: time;
margin: 0;
//allows pushing this grid area as small as possible and aligns the end to the same level
white-space: nowrap;
font-size: 80%;
}
.controls {
grid-area: controls;
margin: 0;
opacity: 0.7;
.mat-icon-button {
height: 20px;
line-height: 1;
.mat-icon {
line-height: 19px;
}
}
}
}
.current-speaker {
display: table;
width: -webkit-fill-available;
height: 50px;
margin: 50px 25px 20px 25px;
box-shadow: 0px 3px 10px 0px rgba(0, 0, 0, 0.25);
.prefix {
display: table-cell;
padding: 0 15px;
vertical-align: middle;
.mat-icon {
vertical-align: middle;
}
}
.name {
display: table-cell;
vertical-align: middle;
font-weight: bold;
padding-left: 10px;
width: 100%;
}
.suffix {
display: table-cell;
vertical-align: middle;
white-space: nowrap;
padding-right: 10px;
}
}
.waiting-list {
padding: 10px 25px 0 25px;
}
.search-new-speaker-form {
padding: 15px 25px 10px 25px;
width: auto;
.search-users-field {
width: 100%;
.search-users {
display: grid;
.mat-form-field {
width: 100%;
}
}
}
}
.add-self-buttons {
padding: 15px 0 20px 25px;
}
.speaker-warning {
margin-right: 5px;
}

View File

@ -0,0 +1,26 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { ListOfSpeakersContentComponent } from './list-of-speakers-content.component';
describe('ListOfSpeakersContentComponent', () => {
let component: ListOfSpeakersContentComponent;
let fixture: ComponentFixture<ListOfSpeakersContentComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ListOfSpeakersContentComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,344 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
EventEmitter,
Input,
OnInit,
Output,
ViewChild
} from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
import { BehaviorSubject } from 'rxjs';
import { OperatorService } from 'app/core/core-services/operator.service';
import { ListOfSpeakersRepositoryService } from 'app/core/repositories/agenda/list-of-speakers-repository.service';
import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
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 { ViewListOfSpeakers } from 'app/site/agenda/models/view-list-of-speakers';
import { SpeakerState, ViewSpeaker } from 'app/site/agenda/models/view-speaker';
import { BaseViewComponent } from 'app/site/base/base-view';
import { ViewUser } from 'app/site/users/models/view-user';
import { Selectable } from '../selectable';
import { SortingListComponent } from '../sorting-list/sorting-list.component';
@Component({
selector: 'os-list-of-speakers-content',
templateUrl: './list-of-speakers-content.component.html',
styleUrls: ['./list-of-speakers-content.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ListOfSpeakersContentComponent extends BaseViewComponent implements OnInit {
@ViewChild(SortingListComponent)
public listElement: SortingListComponent;
private viewListOfSpeakers: ViewListOfSpeakers;
public finishedSpeakers: ViewSpeaker[];
public waitingSpeakers: ViewSpeaker[];
public activeSpeaker: ViewSpeaker;
/**
* Required for the user search selector
*/
public addSpeakerForm: FormGroup;
public users = new BehaviorSubject<ViewUser[]>([]);
public filteredUsers = new BehaviorSubject<ViewUser[]>([]);
public isSortMode: boolean;
public isMobile: boolean;
public showFistContributionHint: boolean;
public get title(): string {
return this.viewListOfSpeakers?.getTitle();
}
public get closed(): boolean {
return this.viewListOfSpeakers?.closed;
}
public get opCanManage(): boolean {
return this.operator.hasPerms(this.permission.agendaCanManageListOfSpeakers);
}
public get isOpInList(): boolean {
return this.waitingSpeakers.some(speaker => speaker.user_id === this.operator.user.id);
}
public get canAddSelf(): boolean {
return !this.config.instant('agenda_present_speakers_only') || this.operator.user.is_present;
}
@Input()
public set speakers(los: ViewListOfSpeakers) {
this.setListOfSpeakers(los);
}
@Input()
public customTitle: boolean;
@Input()
public set sortMode(isActive: boolean) {
if (this.isSortMode) {
this.listElement.restore();
}
this.isSortMode = isActive;
}
@Output()
private isListOfSpeakersEmptyEvent = new EventEmitter<boolean>();
@Output()
private hasFinishesSpeakersEvent = new EventEmitter<boolean>();
public constructor(
title: Title,
protected translate: TranslateService,
snackBar: MatSnackBar,
private listOfSpeakersRepo: ListOfSpeakersRepositoryService,
private operator: OperatorService,
private promptService: PromptService,
private durationService: DurationService,
private userRepository: UserRepositoryService,
private config: ConfigService,
private viewport: ViewportService,
private cd: ChangeDetectorRef
) {
super(title, translate, snackBar);
this.addSpeakerForm = new FormGroup({ user_id: new FormControl() });
}
public ngOnInit(): void {
this.subscriptions.push(
// Observe the user list
this.userRepository.getViewModelListObservable().subscribe(users => {
this.users.next(users);
this.filterUsers();
this.cd.markForCheck();
}),
// ovserve changes to the add-speaker form
this.addSpeakerForm.valueChanges.subscribe(formResult => {
// resetting a form triggers a form.next(null) - check if user_id
if (formResult && formResult.user_id) {
this.addNewSpeaker(formResult.user_id);
}
}),
// observe changes to the viewport
this.viewport.isMobileSubject.subscribe(isMobile => {
this.isMobile = isMobile;
this.cd.markForCheck();
}),
// observe changes the agenda_present_speakers_only config
this.config.get('agenda_present_speakers_only').subscribe(() => {
this.filterUsers();
}),
// observe changes to the agenda_show_first_contribution config
this.config.get<boolean>('agenda_show_first_contribution').subscribe(show => {
this.showFistContributionHint = show;
})
);
}
private isListOfSpeakersEmpty(): void {
if (this.waitingSpeakers && this.waitingSpeakers.length) {
this.isListOfSpeakersEmptyEvent.emit(false);
} else if (this.finishedSpeakers && this.finishedSpeakers.length) {
this.isListOfSpeakersEmptyEvent.emit(false);
}
return this.isListOfSpeakersEmptyEvent.emit(!this.activeSpeaker);
}
private hasFinishesSpeakers(): void {
this.hasFinishesSpeakersEvent.emit(this.finishedSpeakers?.length > 0);
}
/**
* Create a speaker out of an id
*
* @param userId the user id to add to the list. No parameter adds the operators user as speaker.
*/
public addNewSpeaker(userId?: number): void {
this.listOfSpeakersRepo
.createSpeaker(this.viewListOfSpeakers, userId)
.then(() => this.addSpeakerForm.reset(), this.raiseError);
}
/**
* Click on the X button - removes the speaker from the list of speakers
*
* @param speaker optional speaker to remove. If none is given,
* the operator themself is removed
*/
public async onDeleteButton(speaker?: ViewSpeaker): Promise<void> {
const title = this.translate.instant(
'Are you sure you want to delete this speaker from this list of speakers?'
);
if (await this.promptService.open(title)) {
try {
await this.listOfSpeakersRepo.delete(this.viewListOfSpeakers, speaker ? speaker.id : null);
this.filterUsers();
} catch (e) {
this.raiseError(e);
}
}
}
/**
* Click on the mic button to mark a speaker as speaking
*
* @param speaker the speaker marked in the list
*/
public async onStartButton(speaker: ViewSpeaker): Promise<void> {
try {
await this.listOfSpeakersRepo.startSpeaker(this.viewListOfSpeakers, speaker);
this.filterUsers();
} catch (e) {
this.raiseError(e);
}
}
/**
* Click on the mic-cross button to stop the current speaker
*/
public async onStopButton(): Promise<void> {
try {
await this.listOfSpeakersRepo.stopCurrentSpeaker(this.viewListOfSpeakers);
this.filterUsers();
} catch (e) {
this.raiseError(e);
}
}
/**
* 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);
}
/**
* Receives an updated list from sorting-event.
*
* @param sortedSpeakerList The updated list.
*/
public onSortingChanged(sortedSpeakerList: Selectable[]): void {
if (!this.isMobile) {
this.onSaveSorting(sortedSpeakerList);
}
}
/**
* send the current order of the sorting list to the server
*
* @param sortedSpeakerList The list to save.
*/
public async onSaveSorting(sortedSpeakerList: Selectable[] = this.listElement.sortedItems): Promise<void> {
return await this.listOfSpeakersRepo
.sortSpeakers(
this.viewListOfSpeakers,
sortedSpeakerList.map(el => el.id)
)
.catch(this.raiseError);
}
private setListOfSpeakers(viewListOfSpeakers: ViewListOfSpeakers | undefined): void {
this.viewListOfSpeakers = viewListOfSpeakers;
const allSpeakers = viewListOfSpeakers?.speakers.sort((a, b) => a.weight - b.weight);
this.waitingSpeakers = allSpeakers?.filter(speaker => speaker.state === SpeakerState.WAITING);
this.waitingSpeakers?.sort((a: ViewSpeaker, b: ViewSpeaker) => a.weight - b.weight);
this.filterUsers();
this.finishedSpeakers = allSpeakers?.filter(speaker => speaker.state === SpeakerState.FINISHED);
// convert begin time to date and sort
this.finishedSpeakers?.sort((a: ViewSpeaker, b: ViewSpeaker) => {
const aTime = new Date(a.begin_time).getTime();
const bTime = new Date(b.begin_time).getTime();
return aTime - bTime;
});
this.activeSpeaker = allSpeakers?.find(speaker => speaker.state === SpeakerState.CURRENT);
this.hasFinishesSpeakers();
this.isListOfSpeakersEmpty();
}
/**
* Triggers an update of the filter for the list of available potential speakers
* (triggered on an update of users or config)
*/
private filterUsers(): void {
const presentUsersOnly = this.config.instant('agenda_present_speakers_only');
const users = presentUsersOnly ? this.users.getValue().filter(u => u.is_present) : this.users.getValue();
if (!this.waitingSpeakers || !this.waitingSpeakers.length) {
this.filteredUsers.next(users);
} else {
this.filteredUsers.next(users.filter(u => !this.waitingSpeakers.some(speaker => speaker.user_id === u.id)));
}
}
/**
* Imports a new user by the given username.
*
* @param username The name of the new user.
*/
public async onCreateUser(username: string): Promise<void> {
const newUser = await this.userRepository.createFromString(username);
this.addNewSpeaker(newUser.id);
}
/**
* Checks how often a speaker has already finished speaking
*
* @param speaker
* @returns 0 or the number of times a speaker occurs in finishedSpeakers
*/
public hasSpokenCount(speaker: ViewSpeaker): number {
return this.finishedSpeakers.filter(finishedSpeaker => {
if (finishedSpeaker && finishedSpeaker.user) {
return finishedSpeaker.user.id === speaker.user.id;
}
}).length;
}
/**
* Returns true if the speaker did never appear on any list of speakers
*
* @param speaker
*/
public isFirstContribution(speaker: ViewSpeaker): boolean {
return this.listOfSpeakersRepo.isFirstContribution(speaker);
}
/**
* get the duration of a speech
*
* @param speaker
* @returns string representation of the duration in `[MM]M:SS minutes` format
*/
public durationString(speaker: ViewSpeaker): string {
const duration = Math.floor(
(new Date(speaker.end_time).valueOf() - new Date(speaker.begin_time).valueOf()) / 1000
);
return `${this.durationService.durationToString(duration, 'm')}`;
}
/**
* returns a locale-specific version of the starting time for the given speaker item
*
* @param speaker
* @returns a time string using the current language setting of the client
*/
public startTimeToString(speaker: ViewSpeaker): string {
return new Date(speaker.begin_time).toLocaleString(this.translate.currentLang);
}
}

View File

@ -2,7 +2,7 @@ import { inject, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module'; import { E2EImportsModule } from 'e2e-imports.module';
import { AssignmentPollService } from 'app/site/assignments/services/assignment-poll.service'; import { AssignmentPollService } from 'app/site/assignments/modules/assignment-poll/services/assignment-poll.service';
import { MotionPollService } from 'app/site/motions/services/motion-poll.service'; import { MotionPollService } from 'app/site/motions/services/motion-poll.service';
import { PollPercentBasePipe } from './poll-percent-base.pipe'; import { PollPercentBasePipe } from './poll-percent-base.pipe';

View File

@ -1,6 +1,6 @@
import { Pipe, PipeTransform } from '@angular/core'; import { Pipe, PipeTransform } from '@angular/core';
import { AssignmentPollService } from 'app/site/assignments/services/assignment-poll.service'; import { AssignmentPollService } from 'app/site/assignments/modules/assignment-poll/services/assignment-poll.service';
import { MotionPollService } from 'app/site/motions/services/motion-poll.service'; import { MotionPollService } from 'app/site/motions/services/motion-poll.service';
import { PollData } from 'app/site/polls/services/poll.service'; import { PollData } from 'app/site/polls/services/poll.service';

View File

@ -129,6 +129,7 @@ import { UserMenuComponent } from './components/user-menu/user-menu.component';
import { JitsiComponent } from './components/jitsi/jitsi.component'; import { JitsiComponent } from './components/jitsi/jitsi.component';
import { VjsPlayerComponent } from './components/vjs-player/vjs-player.component'; import { VjsPlayerComponent } from './components/vjs-player/vjs-player.component';
import { LiveStreamComponent } from './components/live-stream/live-stream.component'; import { LiveStreamComponent } from './components/live-stream/live-stream.component';
import { ListOfSpeakersContentComponent } from './components/list-of-speakers-content/list-of-speakers-content.component';
/** /**
* Share Module for all "dumb" components and pipes. * Share Module for all "dumb" components and pipes.
@ -298,7 +299,8 @@ import { LiveStreamComponent } from './components/live-stream/live-stream.compon
AssignmentPollDetailContentComponent, AssignmentPollDetailContentComponent,
JitsiComponent, JitsiComponent,
VjsPlayerComponent, VjsPlayerComponent,
LiveStreamComponent LiveStreamComponent,
ListOfSpeakersContentComponent
], ],
declarations: [ declarations: [
PermsDirective, PermsDirective,
@ -361,7 +363,8 @@ import { LiveStreamComponent } from './components/live-stream/live-stream.compon
AssignmentPollDetailContentComponent, AssignmentPollDetailContentComponent,
JitsiComponent, JitsiComponent,
VjsPlayerComponent, VjsPlayerComponent,
LiveStreamComponent LiveStreamComponent,
ListOfSpeakersContentComponent
], ],
providers: [ providers: [
{ {

View File

@ -1,8 +1,8 @@
<os-head-bar <os-head-bar
[nav]="false" [nav]="false"
[goBack]="true" [goBack]="true"
[editMode]="isMobile && isSortMode" [editMode]="isMobile && manualSortMode"
(cancelEditEvent)="onCancelSorting()" (cancelEditEvent)="setManualSortMode(false)"
(saveEvent)="onMobileSaveSorting()" (saveEvent)="onMobileSaveSorting()"
> >
<!-- Title --> <!-- Title -->
@ -18,7 +18,7 @@
mat-icon-button mat-icon-button
matTooltip="{{ 'Re-add last speaker' | translate }}" matTooltip="{{ 'Re-add last speaker' | translate }}"
(click)="readdLastSpeaker()" (click)="readdLastSpeaker()"
[disabled]="!finishedSpeakers || !finishedSpeakers.length" [disabled]="!hasFinishedSpeakers"
> >
<mat-icon>undo</mat-icon> <mat-icon>undo</mat-icon>
</button> </button>
@ -26,167 +26,16 @@
</div> </div>
</os-head-bar> </os-head-bar>
<mat-card class="os-card speaker-card" *ngIf="viewListOfSpeakers"> <os-list-of-speakers-content
<!-- Title --> #content
<h1 class="los-title"> [speakers]="viewListOfSpeakers"
{{ viewListOfSpeakers.getTitle() }} [sortMode]="manualSortMode"
<mat-icon *ngIf="viewListOfSpeakers.closed"> (isListOfSpeakersEmptyEvent)="isListOfSpeakersEmpty = $event"
lock (hasFinishesSpeakersEvent)="hasFinishedSpeakers = $event"
</mat-icon> ></os-list-of-speakers-content>
</h1>
<!-- List of finished speakers -->
<mat-expansion-panel *ngIf="finishedSpeakers && finishedSpeakers.length > 0" class="finished-list">
<mat-expansion-panel-header>
<mat-panel-title>{{ 'Last speakers' | translate }}</mat-panel-title>
</mat-expansion-panel-header>
<mat-list>
<mat-list-item *ngFor="let speaker of finishedSpeakers; let number = index">
<div class="finished-speaker-grid">
<div class="number">{{ number + 1 }}.</div>
<div class="name">{{ speaker.getTitle() }}</div>
<div class="time">
{{ durationString(speaker) }} ({{ 'Start time' | translate }}: {{ startTimeToString(speaker) }})
</div>
<div class="controls">
<button
mat-icon-button
matTooltip="{{ 'Remove' | translate }}"
*osPerms="'agenda.can_manage_list_of_speakers'"
(click)="onDeleteButton(speaker)"
>
<mat-icon>close</mat-icon>
</button>
</div>
</div>
</mat-list-item>
</mat-list>
</mat-expansion-panel>
<!-- horizontal separation line -->
<mat-divider *ngIf="finishedSpeakers && finishedSpeakers.length"></mat-divider>
<div *ngIf="finishedSpeakers && finishedSpeakers.length" class="spacer-bottom-40"></div>
<!-- Current Speaker -->
<div class="current-speaker" *ngIf="activeSpeaker">
<span class="prefix">
<mat-icon>mic</mat-icon>
</span>
<span class="name">{{ activeSpeaker.getListTitle() }}</span>
<span class="suffix">
<!-- Stop speaker button -->
<button
mat-icon-button
matTooltip="{{ 'End speech' | translate }}"
*osPerms="'agenda.can_manage_list_of_speakers'"
(click)="onStopButton()"
>
<mat-icon>stop</mat-icon>
</button>
</span>
</div>
<!-- Waiting speakers -->
<div class="waiting-list" *ngIf="speakers && speakers.length > 0">
<os-sorting-list
[live]="true"
[input]="speakers"
[count]="true"
[enable]="opCanManage() && isSortMode"
(sortEvent)="onSortingChanged($event)"
>
<!-- implicit speaker references into the component using ng-template slot -->
<ng-template let-speaker>
<span *osPerms="'agenda.can_manage_list_of_speakers'">
<!-- Speaker count -->
<span *ngIf="hasSpokenCount(speaker)" class="red-warning-text speaker-warning">
{{ hasSpokenCount(speaker) + 1 }}. <span>{{ 'contribution' | translate }}</span>
</span>
<!-- First contribution -->
<span *ngIf="showFistContributionHint && isFirstContribution(speaker)" class="red-warning-text speaker-warning">
{{ 'First speech' | translate }}
</span>
<!-- Speaker gender -->
<span *ngIf="speaker.gender">({{ speaker.gender | translate }})</span>
</span>
<!-- Start, start and delete buttons -->
<span *osPerms="'agenda.can_manage_list_of_speakers'">
<!-- start button -->
<button
mat-icon-button
matTooltip="{{ 'Begin speech' | translate }}"
(click)="onStartButton(speaker)"
>
<mat-icon>play_arrow</mat-icon>
</button>
<!-- star button -->
<button
mat-icon-button
matTooltip="{{ 'Mark speaker' | translate }}"
(click)="onMarkButton(speaker)"
>
<mat-icon>{{ speaker.marked ? 'star' : 'star_border' }}</mat-icon>
</button>
<!-- delete button -->
<button mat-icon-button matTooltip="{{ 'Remove' | translate }}" (click)="onDeleteButton(speaker)">
<mat-icon>close</mat-icon>
</button>
</span>
</ng-template>
</os-sorting-list>
</div>
<!-- Search for speakers -->
<div *osPerms="'agenda.can_manage_list_of_speakers'">
<form *ngIf="filteredUsers && filteredUsers.value.length > 0" [formGroup]="addSpeakerForm">
<mat-form-field class="search-users-field">
<os-search-value-selector
class="search-users"
formControlName="user_id"
placeholder="{{ 'Select or search new speaker ...' | translate }}"
[inputListValues]="filteredUsers"
[showNotFoundButton]="true"
(clickNotFound)="onCreateUser($event)"
>
<ng-container notFoundDescription>
<mat-icon>add</mat-icon>
{{ 'Create user' | translate }}
</ng-container>
</os-search-value-selector>
</mat-form-field>
</form>
</div>
<!-- Add me and remove me if OP has correct permission -->
<div *osPerms="'agenda.can_be_speaker'" class="add-self-buttons">
<div *ngIf="speakers && !closedList">
<button
mat-stroked-button
[disabled]="viewListOfSpeakers.closed"
(click)="addNewSpeaker()"
*ngIf="!isOpInList() && canAddSelf"
>
<mat-icon>add</mat-icon>
<span>{{ 'Add me' | translate }}</span>
</button>
<button mat-stroked-button (click)="onDeleteButton()" *ngIf="isOpInList()">
<mat-icon>remove</mat-icon>
<span>{{ 'Remove me' | translate }}</span>
</button>
</div>
</div>
</mat-card>
<mat-menu #speakerMenu="matMenu"> <mat-menu #speakerMenu="matMenu">
<button *ngIf="isMobile" mat-menu-item (click)="isSortMode = true"> <button *ngIf="isMobile" mat-menu-item (click)="setManualSortMode(true)">
<mat-icon>sort</mat-icon> <mat-icon>sort</mat-icon>
<span>{{ 'Sort' | translate }}</span> <span>{{ 'Sort' | translate }}</span>
</button> </button>
@ -222,9 +71,9 @@
<span>{{ 'Close list of speakers' | translate }}</span> <span>{{ 'Close list of speakers' | translate }}</span>
</button> </button>
<mat-divider *ngIf="!isListOfSpeakersEmpty"></mat-divider> <mat-divider></mat-divider>
<button mat-menu-item (click)="clearSpeakerList()" *ngIf="!isListOfSpeakersEmpty" class="red-warning-text"> <button mat-menu-item (click)="clearSpeakerList()" [disabled]="isListOfSpeakersEmpty" class="red-warning-text">
<mat-icon>delete</mat-icon> <mat-icon>delete</mat-icon>
<span>{{ 'Remove all speakers' | translate }}</span> <span>{{ 'Remove all speakers' | translate }}</span>
</button> </button>

View File

@ -1,125 +0,0 @@
@import '~assets/styles/variables.scss';
.los-title {
margin-left: 25px;
}
.finished-list {
box-shadow: none !important;
margin-bottom: 15px;
.mat-list-item {
height: auto;
margin-bottom: 10px;
}
.finished-speaker-grid {
display: grid;
width: 100%;
grid-gap: 5px;
grid-template-areas:
'number name controls'
'number time controls';
grid-template-columns: 30px 1fr min-content;
}
@include desktop {
.finished-speaker-grid {
grid-template-areas: 'number name time controls';
grid-template-columns: 30px 1fr min-content min-content;
}
}
.number {
grid-area: number;
margin: 0;
}
.name {
grid-area: name;
margin: 0;
}
.time {
grid-area: time;
margin: 0;
//allows pushing this grid area as small as possible and aligns the end to the same level
white-space: nowrap;
font-size: 80%;
}
.controls {
grid-area: controls;
margin: 0;
opacity: 0.7;
.mat-icon-button {
height: 20px;
line-height: 1;
.mat-icon {
line-height: 19px;
}
}
}
}
.current-speaker {
display: table;
width: -webkit-fill-available;
height: 50px;
margin: 50px 25px 20px 25px;
box-shadow: 0px 3px 10px 0px rgba(0, 0, 0, 0.25);
.prefix {
display: table-cell;
padding: 0 15px;
vertical-align: middle;
.mat-icon {
vertical-align: middle;
}
}
.name {
display: table-cell;
vertical-align: middle;
font-weight: bold;
padding-left: 10px;
width: 100%;
}
.suffix {
display: table-cell;
vertical-align: middle;
white-space: nowrap;
padding-right: 10px;
}
}
.waiting-list {
padding: 10px 25px 0 25px;
}
form {
padding: 15px 25px 10px 25px;
width: auto;
.search-users-field {
width: 100%;
.search-users {
display: grid;
.mat-form-field {
width: 100%;
}
}
}
}
.add-self-buttons {
padding: 15px 0 20px 25px;
}
.speaker-warning {
margin-right: 5px;
}

View File

@ -1,31 +1,21 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, ViewChild } from '@angular/core'; import { Component, OnInit, ViewChild } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { BehaviorSubject, Subscription } from 'rxjs';
import { CollectionStringMapperService } from 'app/core/core-services/collection-string-mapper.service'; import { CollectionStringMapperService } from 'app/core/core-services/collection-string-mapper.service';
import { OperatorService, Permission } from 'app/core/core-services/operator.service';
import { ListOfSpeakersRepositoryService } from 'app/core/repositories/agenda/list-of-speakers-repository.service'; import { ListOfSpeakersRepositoryService } from 'app/core/repositories/agenda/list-of-speakers-repository.service';
import { ProjectorRepositoryService } from 'app/core/repositories/projector/projector-repository.service';
import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
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 { 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 { Selectable } from 'app/shared/components/selectable'; import { ListOfSpeakersContentComponent } from 'app/shared/components/list-of-speakers-content/list-of-speakers-content.component';
import { SortingListComponent } from 'app/shared/components/sorting-list/sorting-list.component';
import { BaseViewComponent } from 'app/site/base/base-view'; import { BaseViewComponent } from 'app/site/base/base-view';
import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable'; import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable';
import { ViewProjector } from 'app/site/projector/models/view-projector'; import { ViewProjector } from 'app/site/projector/models/view-projector';
import { CurrentListOfSpeakersSlideService } from 'app/site/projector/services/current-list-of-speakers-slide.service'; import { CurrentListOfSpeakersSlideService } from 'app/site/projector/services/current-list-of-speakers-slide.service';
import { CurrentListOfSpeakersService } from 'app/site/projector/services/current-list-of-speakers.service'; import { CurrentListOfSpeakersService } from 'app/site/projector/services/current-list-of-speakers.service';
import { ViewUser } from 'app/site/users/models/view-user';
import { ViewListOfSpeakers } from '../../models/view-list-of-speakers'; import { ViewListOfSpeakers } from '../../models/view-list-of-speakers';
import { SpeakerState, ViewSpeaker } from '../../models/view-speaker';
/** /**
* The list of speakers for agenda items. * The list of speakers for agenda items.
@ -33,73 +23,26 @@ import { SpeakerState, ViewSpeaker } from '../../models/view-speaker';
@Component({ @Component({
selector: 'os-list-of-speakers', selector: 'os-list-of-speakers',
templateUrl: './list-of-speakers.component.html', templateUrl: './list-of-speakers.component.html',
styleUrls: ['./list-of-speakers.component.scss'], styleUrls: ['./list-of-speakers.component.scss']
changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit { export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit {
@ViewChild(SortingListComponent) @ViewChild('content')
public listElement: SortingListComponent; private listOfSpeakersContentComponent: ListOfSpeakersContentComponent;
/** /**
* Determine if the user is viewing the current list if speakers * Determine if the user is viewing the current list if speakers
*/ */
public isCurrentListOfSpeakers = false; public isCurrentListOfSpeakers = false;
/**
* Holds whether the list is in sort mode or not
*/
public isSortMode = false;
/** /**
* Holds the view item to the given topic * Holds the view item to the given topic
*/ */
public viewListOfSpeakers: ViewListOfSpeakers; public viewListOfSpeakers: ViewListOfSpeakers;
/**
* Holds the speakers
*/
public speakers: ViewSpeaker[];
/** /**
* Holds a list of projectors. Only in CurrentListOfSpeakers mode * Holds a list of projectors. Only in CurrentListOfSpeakers mode
*/ */
public projectors: ViewProjector[]; public projectors: ViewProjector[];
/**
* Holds the subscription to the current projector (if any)
*/
private projectorSubscription: Subscription;
/**
* Holds the active speaker
*/
public activeSpeaker: ViewSpeaker;
/**
* Holds the speakers who were marked done
*/
public finishedSpeakers: ViewSpeaker[];
/**
* Hold the users
*/
public users = new BehaviorSubject<ViewUser[]>([]);
/**
* A filtered list of users, excluding those not available to be added to the list
*/
public filteredUsers = new BehaviorSubject<ViewUser[]>([]);
/**
* Required for the user search selector
*/
public addSpeakerForm: FormGroup;
/**
* Check, if list-view is seen on mobile-device.
*/
public isMobile = false;
/** /**
* @returns true if the list of speakers list is currently closed * @returns true if the list of speakers list is currently closed
*/ */
@ -107,35 +50,19 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit
return this.viewListOfSpeakers && this.viewListOfSpeakers.closed; return this.viewListOfSpeakers && this.viewListOfSpeakers.closed;
} }
public get isListOfSpeakersEmpty(): boolean { public isMobile: boolean;
if (this.speakers && this.speakers.length) {
return false; public manualSortMode = false;
} else if (this.finishedSpeakers && this.finishedSpeakers.length) {
return false;
}
return !this.activeSpeaker;
}
/** /**
* @returns true if the current user can be added to the list of speakers * filled by child component
*/ */
public get canAddSelf(): boolean { public isListOfSpeakersEmpty: boolean;
return !this.config.instant('agenda_present_speakers_only') || this.operator.user.is_present;
}
/** /**
* Used to detect changes in the projector reference. * filled by child component
*/ */
private closReferenceProjectorId: number | null; public hasFinishedSpeakers: boolean;
private closSubscription: Subscription | null;
public showFistContributionHint: boolean;
/**
* List of speakers to save temporarily changes made by sorting-list.
*/
private speakerListAsSelectable: Selectable[] = [];
/** /**
* Constructor for speaker list component. Generates the forms. * Constructor for speaker list component. Generates the forms.
@ -143,7 +70,6 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit
* @param title * @param title
* @param translate * @param translate
* @param snackBar * @param snackBar
* @param projectorRepo
* @param route Angulars ActivatedRoute * @param route Angulars ActivatedRoute
* @param DS the DataStore * @param DS the DataStore
* @param listOfSpeakersRepo Repository for list of speakers * @param listOfSpeakersRepo Repository for list of speakers
@ -156,30 +82,17 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit
title: Title, title: Title,
protected translate: TranslateService, // protected required for ng-translate-extract protected translate: TranslateService, // protected required for ng-translate-extract
snackBar: MatSnackBar, snackBar: MatSnackBar,
private projectorRepo: ProjectorRepositoryService,
private route: ActivatedRoute, private route: ActivatedRoute,
private listOfSpeakersRepo: ListOfSpeakersRepositoryService, private listOfSpeakersRepo: ListOfSpeakersRepositoryService,
private operator: OperatorService,
private promptService: PromptService, private promptService: PromptService,
private currentListOfSpeakersService: CurrentListOfSpeakersService, private currentListOfSpeakersService: CurrentListOfSpeakersService,
private durationService: DurationService,
private userRepository: UserRepositoryService,
private collectionStringMapper: CollectionStringMapperService, private collectionStringMapper: CollectionStringMapperService,
private currentListOfSpeakersSlideService: CurrentListOfSpeakersSlideService, private currentListOfSpeakersSlideService: CurrentListOfSpeakersSlideService,
private config: ConfigService, private viewport: ViewportService
private viewport: ViewportService,
private cd: ChangeDetectorRef
) { ) {
super(title, translate, snackBar); super(title, translate, snackBar);
this.addSpeakerForm = new FormGroup({ user_id: new FormControl() });
} }
/**
* Init.
*
* Observe users,
* React to form changes
*/
public ngOnInit(): void { public ngOnInit(): void {
// Check, if we are on the current list of speakers. // Check, if we are on the current list of speakers.
this.isCurrentListOfSpeakers = this.isCurrentListOfSpeakers =
@ -188,76 +101,17 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit
: true; : true;
if (this.isCurrentListOfSpeakers) { if (this.isCurrentListOfSpeakers) {
this.projectors = this.projectorRepo.getViewModelList();
this.updateClosProjector();
this.subscriptions.push( this.subscriptions.push(
this.projectorRepo.getViewModelListObservable().subscribe(newProjectors => { this.currentListOfSpeakersService.currentListOfSpeakersObservable.subscribe(clos => {
this.projectors = newProjectors; this.setListOfSpeakers(clos);
this.updateClosProjector();
}) })
); );
} else { } else {
const id = +this.route.snapshot.url[this.route.snapshot.url.length - 1].path; const id = +this.route.snapshot.url[this.route.snapshot.url.length - 1].path;
this.setListOfSpeakersId(id); this.setListOfSpeakersById(id);
} }
this.subscriptions.push( this.subscriptions.push(this.viewport.isMobileSubject.subscribe(isMobile => (this.isMobile = isMobile)));
// Observe the user list
this.userRepository.getViewModelListObservable().subscribe(users => {
this.users.next(users);
this.filterUsers();
this.cd.markForCheck();
}),
// ovserve changes to the add-speaker form
this.addSpeakerForm.valueChanges.subscribe(formResult => {
// resetting a form triggers a form.next(null) - check if user_id
if (formResult && formResult.user_id) {
this.addNewSpeaker(formResult.user_id);
}
}),
// observe changes to the viewport
this.viewport.isMobileSubject.subscribe(isMobile => this.checkSortMode(isMobile)),
// observe changes the agenda_present_speakers_only config
this.config.get('agenda_present_speakers_only').subscribe(() => {
this.filterUsers();
}),
// observe changes to the agenda_show_first_contribution config
this.config.get<boolean>('agenda_show_first_contribution').subscribe(show => {
this.showFistContributionHint = show;
})
);
}
public opCanManage(): boolean {
return this.operator.hasPerms(Permission.agendaCanManageListOfSpeakers);
}
/**
* Shows the current list of speakers (CLOS) of a given projector.
*/
private updateClosProjector(): void {
if (!this.projectors.length) {
return;
}
const referenceProjector = this.projectors[0].referenceProjector;
if (!referenceProjector || referenceProjector.id === this.closReferenceProjectorId) {
return;
}
this.closReferenceProjectorId = referenceProjector.id;
if (this.projectorSubscription) {
this.projectorSubscription.unsubscribe();
this.viewListOfSpeakers = null;
}
this.projectorSubscription = this.currentListOfSpeakersService
.getListOfSpeakersObservable(referenceProjector)
.subscribe(listOfSpeakers => {
if (listOfSpeakers) {
this.setListOfSpeakers(listOfSpeakers);
}
});
this.subscriptions.push(this.projectorSubscription);
} }
/** /**
@ -272,16 +126,14 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit
* *
* @param id the list of speakers id * @param id the list of speakers id
*/ */
private setListOfSpeakersId(id: number): void { private setListOfSpeakersById(id: number): void {
if (this.closSubscription) { this.subscriptions.push(
this.closSubscription.unsubscribe(); this.listOfSpeakersRepo.getViewModelObservable(id).subscribe(listOfSpeakers => {
}
this.closSubscription = this.listOfSpeakersRepo.getViewModelObservable(id).subscribe(listOfSpeakers => {
if (listOfSpeakers) { if (listOfSpeakers) {
this.setListOfSpeakers(listOfSpeakers); this.setListOfSpeakers(listOfSpeakers);
} }
}); })
);
} }
private setListOfSpeakers(listOfSpeakers: ViewListOfSpeakers): void { private setListOfSpeakers(listOfSpeakers: ViewListOfSpeakers): void {
@ -290,21 +142,6 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit
: listOfSpeakers.getTitle() + ` - ${this.translate.instant('List of speakers')}`; : listOfSpeakers.getTitle() + ` - ${this.translate.instant('List of speakers')}`;
super.setTitle(title); super.setTitle(title);
this.viewListOfSpeakers = listOfSpeakers; this.viewListOfSpeakers = listOfSpeakers;
const allSpeakers = this.viewListOfSpeakers.speakers.sort((a, b) => a.weight - b.weight);
this.speakers = allSpeakers.filter(speaker => speaker.state === SpeakerState.WAITING);
// Since the speaker repository is not a normal repository, sorting cannot be handled there
this.speakers.sort((a: ViewSpeaker, b: ViewSpeaker) => a.weight - b.weight);
this.filterUsers();
this.finishedSpeakers = allSpeakers.filter(speaker => speaker.state === SpeakerState.FINISHED);
// convert begin time to date and sort
this.finishedSpeakers.sort((a: ViewSpeaker, b: ViewSpeaker) => {
const aTime = new Date(a.begin_time).getTime();
const bTime = new Date(b.begin_time).getTime();
return aTime - bTime;
});
this.activeSpeaker = allSpeakers.find(speaker => speaker.state === SpeakerState.CURRENT);
} }
/** /**
@ -318,80 +155,16 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit
return verboseName; return verboseName;
} }
/** public setManualSortMode(active: boolean): void {
* Create a speaker out of an id this.manualSortMode = active;
*
* @param userId the user id to add to the list. No parameter adds the operators user as speaker.
*/
public addNewSpeaker(userId?: number): void {
this.listOfSpeakersRepo
.createSpeaker(this.viewListOfSpeakers, userId)
.then(() => this.addSpeakerForm.reset(), this.raiseError);
} }
/** /**
* Saves sorting on mobile devices. * Saves sorting on mobile devices.
*/ */
public onMobileSaveSorting(): void { public async onMobileSaveSorting(): Promise<void> {
this.onSaveSorting(this.speakerListAsSelectable); await this.listOfSpeakersContentComponent.onSaveSorting();
this.isSortMode = false; this.manualSortMode = false;
}
/**
* Receives an updated list from sorting-event.
*
* @param sortedSpeakerList The updated list.
*/
public onSortingChanged(sortedSpeakerList: Selectable[]): void {
this.speakerListAsSelectable = sortedSpeakerList;
if (!this.isMobile) {
this.onSaveSorting(sortedSpeakerList);
}
}
/**
* Restore old order on cancel
*/
public onCancelSorting(): void {
if (this.isSortMode) {
this.isSortMode = false;
this.listElement.restore();
}
}
/**
* Click on the mic button to mark a speaker as speaking
*
* @param speaker the speaker marked in the list
*/
public async onStartButton(speaker: ViewSpeaker): Promise<void> {
try {
await this.listOfSpeakersRepo.startSpeaker(this.viewListOfSpeakers, speaker);
this.filterUsers();
} catch (e) {
this.raiseError(e);
}
}
/**
* Click on the mic-cross button
*/
public async onStopButton(): Promise<void> {
try {
await this.listOfSpeakersRepo.stopCurrentSpeaker(this.viewListOfSpeakers);
this.filterUsers();
} catch (e) {
this.raiseError(e);
}
}
/**
* 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);
} }
/** /**
@ -401,58 +174,6 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit
this.listOfSpeakersRepo.readdLastSpeaker(this.viewListOfSpeakers).catch(this.raiseError); this.listOfSpeakersRepo.readdLastSpeaker(this.viewListOfSpeakers).catch(this.raiseError);
} }
/**
* Click on the X button - removes the speaker from the list of speakers
*
* @param speaker optional speaker to remove. If none is given,
* the operator themself is removed
*/
public async onDeleteButton(speaker?: ViewSpeaker): Promise<void> {
const title = this.translate.instant(
'Are you sure you want to delete this speaker from this list of speakers?'
);
if (await this.promptService.open(title)) {
try {
await this.listOfSpeakersRepo.delete(this.viewListOfSpeakers, speaker ? speaker.id : null);
this.filterUsers();
} catch (e) {
this.raiseError(e);
}
}
}
/**
* Returns true if the operator is in the list of (waiting) speakers
*
* @returns whether or not the current operator is in the list
*/
public isOpInList(): boolean {
return this.speakers.some(speaker => speaker.user_id === this.operator.user.id);
}
/**
* Checks how often a speaker has already finished speaking
*
* @param speaker
* @returns 0 or the number of times a speaker occurs in finishedSpeakers
*/
public hasSpokenCount(speaker: ViewSpeaker): number {
return this.finishedSpeakers.filter(finishedSpeaker => {
if (finishedSpeaker && finishedSpeaker.user) {
return finishedSpeaker.user.id === speaker.user.id;
}
}).length;
}
/**
* Returns true if the speaker did never appear on any list of speakers
*
* @param speaker
*/
public isFirstContribution(speaker: ViewSpeaker): boolean {
return this.listOfSpeakersRepo.isFirstContribution(speaker);
}
/** /**
* Closes the current list of speakers * Closes the current list of speakers
*/ */
@ -483,77 +204,4 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit
this.listOfSpeakersRepo.deleteAllSpeakers(this.viewListOfSpeakers); this.listOfSpeakersRepo.deleteAllSpeakers(this.viewListOfSpeakers);
} }
} }
/**
* returns a locale-specific version of the starting time for the given speaker item
*
* @param speaker
* @returns a time string using the current language setting of the client
*/
public startTimeToString(speaker: ViewSpeaker): string {
return new Date(speaker.begin_time).toLocaleString(this.translate.currentLang);
}
/**
* get the duration of a speech
*
* @param speaker
* @returns string representation of the duration in `[MM]M:SS minutes` format
*/
public durationString(speaker: ViewSpeaker): string {
const duration = Math.floor(
(new Date(speaker.end_time).valueOf() - new Date(speaker.begin_time).valueOf()) / 1000
);
return `${this.durationService.durationToString(duration, 'm')}`;
}
/**
* Imports a new user by the given username.
*
* @param username The name of the new user.
*/
public async onCreateUser(username: string): Promise<void> {
const newUser = await this.userRepository.createFromString(username);
this.addNewSpeaker(newUser.id);
}
/**
* Triggers an update of the filter for the list of available potential speakers
* (triggered on an update of users or config)
*/
private filterUsers(): void {
const presentUsersOnly = this.config.instant('agenda_present_speakers_only');
const users = presentUsersOnly ? this.users.getValue().filter(u => u.is_present) : this.users.getValue();
if (!this.speakers || !this.speakers.length) {
this.filteredUsers.next(users);
} else {
this.filteredUsers.next(users.filter(u => !this.speakers.some(speaker => speaker.user_id === u.id)));
}
}
/**
* send the current order of the sorting list to the server
*
* @param sortedSpeakerList The list to save.
*/
private onSaveSorting(sortedSpeakerList: Selectable[]): void {
if (this.isSortMode) {
this.listOfSpeakersRepo
.sortSpeakers(
this.viewListOfSpeakers,
sortedSpeakerList.map(el => el.id)
)
.catch(this.raiseError);
}
}
/**
* Check, that the sorting mode is immediately active, if not in mobile-view.
*
* @param isMobile If currently a mobile device is used.
*/
private checkSortMode(isMobile: boolean): void {
this.isMobile = isMobile;
this.isSortMode = !isMobile;
}
} }

View File

@ -4,13 +4,15 @@ import { RouterModule, Routes } from '@angular/router';
import { Permission } from 'app/core/core-services/operator.service'; import { Permission } from 'app/core/core-services/operator.service';
import { AssignmentDetailComponent } from './components/assignment-detail/assignment-detail.component'; import { AssignmentDetailComponent } from './components/assignment-detail/assignment-detail.component';
import { AssignmentListComponent } from './components/assignment-list/assignment-list.component'; import { AssignmentListComponent } from './components/assignment-list/assignment-list.component';
import { AssignmentPollDetailComponent } from './components/assignment-poll-detail/assignment-poll-detail.component';
const routes: Routes = [ const routes: Routes = [
{ path: '', component: AssignmentListComponent, pathMatch: 'full' }, { path: '', component: AssignmentListComponent, pathMatch: 'full' },
{ path: 'new', component: AssignmentDetailComponent, data: { basePerm: Permission.assignmentsCanManage } }, { path: 'new', component: AssignmentDetailComponent, data: { basePerm: Permission.assignmentsCanManage } },
{ path: ':id', component: AssignmentDetailComponent, data: { basePerm: Permission.assignmentsCanSee } }, { path: ':id', component: AssignmentDetailComponent, data: { basePerm: Permission.assignmentsCanSee } },
{ path: 'polls', children: [{ path: ':id', component: AssignmentPollDetailComponent }] } {
path: 'polls',
loadChildren: () => import('./modules/assignment-poll/assignment-poll.module').then(m => m.AssignmentPollModule)
}
]; ];
@NgModule({ @NgModule({

View File

@ -3,23 +3,12 @@ import { NgModule } from '@angular/core';
import { AssignmentDetailComponent } from './components/assignment-detail/assignment-detail.component'; import { AssignmentDetailComponent } from './components/assignment-detail/assignment-detail.component';
import { AssignmentListComponent } from './components/assignment-list/assignment-list.component'; import { AssignmentListComponent } from './components/assignment-list/assignment-list.component';
import { AssignmentPollDetailComponent } from './components/assignment-poll-detail/assignment-poll-detail.component'; import { AssignmentPollModule } from './modules/assignment-poll/assignment-poll.module';
import { AssignmentPollDialogComponent } from './components/assignment-poll-dialog/assignment-poll-dialog.component';
import { AssignmentPollVoteComponent } from './components/assignment-poll-vote/assignment-poll-vote.component';
import { AssignmentPollComponent } from './components/assignment-poll/assignment-poll.component';
import { AssignmentsRoutingModule } from './assignments-routing.module'; import { AssignmentsRoutingModule } from './assignments-routing.module';
import { PollsModule } from '../polls/polls.module';
import { SharedModule } from '../../shared/shared.module'; import { SharedModule } from '../../shared/shared.module';
@NgModule({ @NgModule({
imports: [CommonModule, AssignmentsRoutingModule, SharedModule, PollsModule], imports: [CommonModule, AssignmentsRoutingModule, AssignmentPollModule, SharedModule],
declarations: [ declarations: [AssignmentDetailComponent, AssignmentListComponent]
AssignmentDetailComponent,
AssignmentListComponent,
AssignmentPollComponent,
AssignmentPollDetailComponent,
AssignmentPollVoteComponent,
AssignmentPollDialogComponent
]
}) })
export class AssignmentsModule {} export class AssignmentsModule {}

View File

@ -4,8 +4,8 @@ import { E2EImportsModule } from 'e2e-imports.module';
import { PollProgressComponent } from 'app/site/polls/components/poll-progress/poll-progress.component'; import { PollProgressComponent } from 'app/site/polls/components/poll-progress/poll-progress.component';
import { AssignmentDetailComponent } from './assignment-detail.component'; import { AssignmentDetailComponent } from './assignment-detail.component';
import { AssignmentPollVoteComponent } from '../assignment-poll-vote/assignment-poll-vote.component'; import { AssignmentPollVoteComponent } from '../../modules/assignment-poll/components/assignment-poll-vote/assignment-poll-vote.component';
import { AssignmentPollComponent } from '../assignment-poll/assignment-poll.component'; import { AssignmentPollComponent } from '../../modules/assignment-poll/components/assignment-poll/assignment-poll.component';
describe('AssignmentDetailComponent', () => { describe('AssignmentDetailComponent', () => {
let component: AssignmentDetailComponent; let component: AssignmentDetailComponent;

View File

@ -22,8 +22,8 @@ import { LocalPermissionsService } from 'app/site/motions/services/local-permiss
import { ViewTag } from 'app/site/tags/models/view-tag'; import { ViewTag } from 'app/site/tags/models/view-tag';
import { ViewUser } from 'app/site/users/models/view-user'; import { ViewUser } from 'app/site/users/models/view-user';
import { AssignmentPdfExportService } from '../../services/assignment-pdf-export.service'; import { AssignmentPdfExportService } from '../../services/assignment-pdf-export.service';
import { AssignmentPollDialogService } from '../../services/assignment-poll-dialog.service'; import { AssignmentPollDialogService } from '../../modules/assignment-poll/services/assignment-poll-dialog.service';
import { AssignmentPollService } from '../../services/assignment-poll.service'; import { AssignmentPollService } from '../../modules/assignment-poll/services/assignment-poll.service';
import { AssignmentPhases, ViewAssignment } from '../../models/view-assignment'; import { AssignmentPhases, ViewAssignment } from '../../models/view-assignment';
import { ViewAssignmentPoll } from '../../models/view-assignment-poll'; import { ViewAssignmentPoll } from '../../models/view-assignment-poll';
import { ViewAssignmentRelatedUser } from '../../models/view-assignment-related-user'; import { ViewAssignmentRelatedUser } from '../../models/view-assignment-related-user';

View File

@ -0,0 +1,12 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AssignmentPollDetailComponent } from './components/assignment-poll-detail/assignment-poll-detail.component';
const routes: Routes = [{ path: ':id', component: AssignmentPollDetailComponent }];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class AssignmentPollRoutingModule {}

View File

@ -0,0 +1,22 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { SharedModule } from 'app/shared/shared.module';
import { PollsModule } from 'app/site/polls/polls.module';
import { AssignmentPollDetailComponent } from './components/assignment-poll-detail/assignment-poll-detail.component';
import { AssignmentPollDialogComponent } from './components/assignment-poll-dialog/assignment-poll-dialog.component';
import { AssignmentPollRoutingModule } from './assignment-poll-routing.module';
import { AssignmentPollVoteComponent } from './components/assignment-poll-vote/assignment-poll-vote.component';
import { AssignmentPollComponent } from './components/assignment-poll/assignment-poll.component';
@NgModule({
declarations: [
AssignmentPollComponent,
AssignmentPollDetailComponent,
AssignmentPollVoteComponent,
AssignmentPollDialogComponent
],
exports: [AssignmentPollComponent, AssignmentPollVoteComponent],
imports: [CommonModule, AssignmentPollRoutingModule, SharedModule, PollsModule]
})
export class AssignmentPollModule {}

View File

@ -13,10 +13,10 @@ import { GroupRepositoryService } from 'app/core/repositories/users/group-reposi
import { ConfigService } from 'app/core/ui-services/config.service'; import { ConfigService } from 'app/core/ui-services/config.service';
import { PromptService } from 'app/core/ui-services/prompt.service'; import { PromptService } from 'app/core/ui-services/prompt.service';
import { VoteValue } from 'app/shared/models/poll/base-vote'; import { VoteValue } from 'app/shared/models/poll/base-vote';
import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll';
import { BasePollDetailComponent } from 'app/site/polls/components/base-poll-detail.component'; import { BasePollDetailComponent } from 'app/site/polls/components/base-poll-detail.component';
import { AssignmentPollDialogService } from '../../services/assignment-poll-dialog.service'; import { AssignmentPollDialogService } from '../../services/assignment-poll-dialog.service';
import { AssignmentPollService } from '../../services/assignment-poll.service'; import { AssignmentPollService } from '../../services/assignment-poll.service';
import { ViewAssignmentPoll } from '../../models/view-assignment-poll';
@Component({ @Component({
selector: 'os-assignment-poll-detail', selector: 'os-assignment-poll-detail',

View File

@ -12,13 +12,13 @@ import { LOWEST_VOTE_VALUE, PollType } from 'app/shared/models/poll/base-poll';
import { GeneralValueVerbose, VoteValue, VoteValueVerbose } from 'app/shared/models/poll/base-vote'; import { GeneralValueVerbose, VoteValue, VoteValueVerbose } from 'app/shared/models/poll/base-vote';
import { import {
AssignmentPollMethodVerbose, AssignmentPollMethodVerbose,
AssignmentPollPercentBaseVerbose AssignmentPollPercentBaseVerbose,
ViewAssignmentPoll
} from 'app/site/assignments/models/view-assignment-poll'; } from 'app/site/assignments/models/view-assignment-poll';
import { BasePollDialogComponent } from 'app/site/polls/components/base-poll-dialog.component'; import { BasePollDialogComponent } from 'app/site/polls/components/base-poll-dialog.component';
import { PollFormComponent } from 'app/site/polls/components/poll-form/poll-form.component'; import { PollFormComponent } from 'app/site/polls/components/poll-form/poll-form.component';
import { ViewUser } from 'app/site/users/models/view-user'; import { ViewUser } from 'app/site/users/models/view-user';
import { AssignmentPollService } from '../../services/assignment-poll.service'; import { AssignmentPollService } from '../../services/assignment-poll.service';
import { ViewAssignmentPoll } from '../../models/view-assignment-poll';
type OptionsObject = { user_id: number; user: ViewUser }[]; type OptionsObject = { user_id: number; user: ViewUser }[];

View File

@ -15,8 +15,8 @@ import { VotingService } from 'app/core/ui-services/voting.service';
import { AssignmentPollMethod } from 'app/shared/models/assignments/assignment-poll'; import { AssignmentPollMethod } from 'app/shared/models/assignments/assignment-poll';
import { PollType } from 'app/shared/models/poll/base-poll'; import { PollType } from 'app/shared/models/poll/base-poll';
import { VoteValue } from 'app/shared/models/poll/base-vote'; import { VoteValue } from 'app/shared/models/poll/base-vote';
import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll';
import { BasePollVoteComponent } from 'app/site/polls/components/base-poll-vote.component'; import { BasePollVoteComponent } from 'app/site/polls/components/base-poll-vote.component';
import { ViewAssignmentPoll } from '../../models/view-assignment-poll';
// TODO: Duplicate // TODO: Duplicate
interface VoteActions { interface VoteActions {

View File

@ -10,11 +10,11 @@ import { AssignmentPollRepositoryService } from 'app/core/repositories/assignmen
import { PromptService } from 'app/core/ui-services/prompt.service'; import { PromptService } from 'app/core/ui-services/prompt.service';
import { VotingPrivacyWarningComponent } from 'app/shared/components/voting-privacy-warning/voting-privacy-warning.component'; import { VotingPrivacyWarningComponent } from 'app/shared/components/voting-privacy-warning/voting-privacy-warning.component';
import { infoDialogSettings } from 'app/shared/utils/dialog-settings'; import { infoDialogSettings } from 'app/shared/utils/dialog-settings';
import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll';
import { BasePollComponent } from 'app/site/polls/components/base-poll.component'; import { BasePollComponent } from 'app/site/polls/components/base-poll.component';
import { AssignmentPollDialogService } from '../../services/assignment-poll-dialog.service'; import { AssignmentPollDialogService } from '../../services/assignment-poll-dialog.service';
import { AssignmentPollPdfService } from '../../services/assignment-poll-pdf.service'; import { AssignmentPollPdfService } from '../../services/assignment-poll-pdf.service';
import { AssignmentPollService } from '../../services/assignment-poll.service'; import { AssignmentPollService } from '../../services/assignment-poll.service';
import { ViewAssignmentPoll } from '../../models/view-assignment-poll';
/** /**
* Component for a single assignment poll. Used in assignment detail view * Component for a single assignment poll. Used in assignment detail view

View File

@ -3,9 +3,9 @@ import { MatDialog } from '@angular/material/dialog';
import { CollectionStringMapperService } from 'app/core/core-services/collection-string-mapper.service'; import { CollectionStringMapperService } from 'app/core/core-services/collection-string-mapper.service';
import { BasePollDialogService } from 'app/core/ui-services/base-poll-dialog.service'; import { BasePollDialogService } from 'app/core/ui-services/base-poll-dialog.service';
import { AssignmentPollDialogComponent } from 'app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component'; import { AssignmentPollDialogComponent } from '../components/assignment-poll-dialog/assignment-poll-dialog.component';
import { AssignmentPollService } from './assignment-poll.service'; import { AssignmentPollService } from './assignment-poll.service';
import { ViewAssignmentPoll } from '../models/view-assignment-poll'; import { ViewAssignmentPoll } from '../../../models/view-assignment-poll';
/** /**
* Subclassed to provide the right `PollService` and `DialogComponent` * Subclassed to provide the right `PollService` and `DialogComponent`

View File

@ -8,7 +8,7 @@ import { AssignmentRepositoryService } from 'app/core/repositories/assignments/a
import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service'; import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
import { ConfigService } from 'app/core/ui-services/config.service'; import { ConfigService } from 'app/core/ui-services/config.service';
import { AssignmentPollMethod } from 'app/shared/models/assignments/assignment-poll'; import { AssignmentPollMethod } from 'app/shared/models/assignments/assignment-poll';
import { ViewAssignmentPoll } from '../models/view-assignment-poll'; import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll';
/** /**
* Creates a pdf for a motion poll. Takes as input any motionPoll * Creates a pdf for a motion poll. Takes as input any motionPoll

View File

@ -13,6 +13,8 @@ import {
import { MajorityMethod, PollType, VOTE_UNDOCUMENTED } from 'app/shared/models/poll/base-poll'; import { MajorityMethod, PollType, VOTE_UNDOCUMENTED } from 'app/shared/models/poll/base-poll';
import { ParsePollNumberPipe } from 'app/shared/pipes/parse-poll-number.pipe'; import { ParsePollNumberPipe } from 'app/shared/pipes/parse-poll-number.pipe';
import { PollKeyVerbosePipe } from 'app/shared/pipes/poll-key-verbose.pipe'; import { PollKeyVerbosePipe } from 'app/shared/pipes/poll-key-verbose.pipe';
import { ViewAssignmentOption } from 'app/site/assignments/models/view-assignment-option';
import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll';
import { import {
PollData, PollData,
PollDataOption, PollDataOption,
@ -20,8 +22,6 @@ import {
PollTableData, PollTableData,
VotingResult VotingResult
} from 'app/site/polls/services/poll.service'; } from 'app/site/polls/services/poll.service';
import { ViewAssignmentOption } from '../models/view-assignment-option';
import { ViewAssignmentPoll } from '../models/view-assignment-poll';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'

View File

@ -8,7 +8,7 @@ import { ParsePollNumberPipe } from 'app/shared/pipes/parse-poll-number.pipe';
import { PollKeyVerbosePipe } from 'app/shared/pipes/poll-key-verbose.pipe'; import { PollKeyVerbosePipe } from 'app/shared/pipes/poll-key-verbose.pipe';
import { PollPercentBasePipe } from 'app/shared/pipes/poll-percent-base.pipe'; import { PollPercentBasePipe } from 'app/shared/pipes/poll-percent-base.pipe';
import { PollTableData, VotingResult } from 'app/site/polls/services/poll.service'; import { PollTableData, VotingResult } from 'app/site/polls/services/poll.service';
import { AssignmentPollService } from './assignment-poll.service'; import { AssignmentPollService } from '../modules/assignment-poll/services/assignment-poll.service';
import { ViewAssignment } from '../models/view-assignment'; import { ViewAssignment } from '../models/view-assignment';
import { ViewAssignmentPoll } from '../models/view-assignment-poll'; import { ViewAssignmentPoll } from '../models/view-assignment-poll';

View File

@ -0,0 +1,12 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { CinemaComponent } from './components/cinema/cinema.component';
const routes: Routes = [{ path: '', component: CinemaComponent, pathMatch: 'full' }];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class CinemaRoutingModule {}

View File

@ -0,0 +1,16 @@
import { Permission } from 'app/core/core-services/operator.service';
import { AppConfig } from 'app/core/definitions/app-config';
export const CinemaAppConfig: AppConfig = {
name: 'cinema',
models: [],
mainMenuEntries: [
{
route: '/autopilot',
displayName: 'Autopilot',
icon: 'sync',
weight: 150,
permission: Permission.coreCanSeeAutopilot
}
]
};

View File

@ -0,0 +1,15 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { SharedModule } from 'app/shared/shared.module';
import { AssignmentPollModule } from '../assignments/modules/assignment-poll/assignment-poll.module';
import { CinemaRoutingModule } from './cinema-routing.module';
import { CinemaComponent } from './components/cinema/cinema.component';
import { MotionPollModule } from '../motions/modules/motion-poll/motion-poll.module';
import { PollCollectionComponent } from './components/poll-collection/poll-collection.component';
@NgModule({
imports: [CommonModule, CinemaRoutingModule, MotionPollModule, AssignmentPollModule, SharedModule],
declarations: [CinemaComponent, PollCollectionComponent]
})
export class CinemaModule {}

View File

@ -0,0 +1,45 @@
<os-head-bar>
<div class="title-slot">
<h2>{{ 'Autopilot' | translate }}</h2>
</div>
</os-head-bar>
<!-- Title Card -->
<mat-card class="os-card">
<h1 class="line-and-icon">
{{ title }}
<a
mat-icon-button
[disabled]="!viewModelUrl"
[class.disabled]="!viewModelUrl"
[routerLink]="viewModelUrl"
[state]="{ back: 'true' }"
>
<mat-icon>open_in_new</mat-icon>
</a>
</h1>
</mat-card>
<!-- List of speakers -->
<os-list-of-speakers-content [customTitle]="true" [speakers]="listOfSpeakers">
<p class="subtitle-text line-and-icon">
<a [routerLink]="closUrl" [class.disabled]="!closUrl">
{{ 'List of speakers' | translate }}
</a>
<mat-icon *ngIf="isLosClosed" matTooltip="{{ 'The list of speakers is closed.' | translate }}">
lock
</mat-icon>
</p>
</os-list-of-speakers-content>
<os-poll-collection [currentProjection]="projectedViewModel"></os-poll-collection>
<!-- Projector -->
<mat-card class="os-card spacer-bottom-60">
<p class="subtitle-text">{{ projectorTitle | translate }}</p>
<a [routerLink]="projectorUrl" [target]="projectionTarget">
<div class="projector">
<os-projector *ngIf="projector" [projector]="projector"></os-projector>
</div>
</a>
</mat-card>

View File

@ -0,0 +1,3 @@
.projector {
border: 1px solid lightgray;
}

View File

@ -0,0 +1,27 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { CinemaComponent } from './cinema.component';
describe('CinemaComponent', () => {
let component: CinemaComponent;
let fixture: ComponentFixture<CinemaComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
declarations: [CinemaComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CinemaComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,113 @@
import { Component, OnInit } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
import { OperatorService } from 'app/core/core-services/operator.service';
import { ProjectorService } from 'app/core/core-services/projector.service';
import { ProjectorRepositoryService } from 'app/core/repositories/projector/projector-repository.service';
import { DetailNavigable, isDetailNavigable } from 'app/shared/models/base/detail-navigable';
import { ProjectorElement } from 'app/shared/models/core/projector';
import { ViewListOfSpeakers } from 'app/site/agenda/models/view-list-of-speakers';
import { BaseProjectableViewModel } from 'app/site/base/base-projectable-view-model';
import { BaseViewComponent } from 'app/site/base/base-view';
import { ViewProjector } from 'app/site/projector/models/view-projector';
import { CurrentListOfSpeakersService } from 'app/site/projector/services/current-list-of-speakers.service';
@Component({
selector: 'os-cinema',
templateUrl: './cinema.component.html',
styleUrls: ['./cinema.component.scss']
})
export class CinemaComponent extends BaseViewComponent implements OnInit {
public listOfSpeakers: ViewListOfSpeakers;
public projector: ViewProjector;
private currentProjectorElement: ProjectorElement;
public projectedViewModel: BaseProjectableViewModel;
public get title(): string {
if (this.projectedViewModel) {
return this.projectedViewModel.getListTitle();
} else if (this.currentProjectorElement) {
return this.projectorService.getSlideTitle(this.currentProjectorElement)?.title;
} else {
return '';
}
}
public get projectorTitle(): string {
return this.projector?.getTitle() || '';
}
public get closUrl(): string {
if (this.listOfSpeakers && this.operator.hasPerms(this.permission.agendaCanManageListOfSpeakers)) {
return this.listOfSpeakers?.listOfSpeakersUrl;
} else {
return '';
}
}
public get isLosClosed(): boolean {
return this.listOfSpeakers?.closed;
}
public get viewModelUrl(): string {
if (this.projectedViewModel && isDetailNavigable(this.projectedViewModel)) {
return (this.projectedViewModel as DetailNavigable).getDetailStateURL();
} else {
return '';
}
}
public get projectorUrl(): string {
if (this.projector) {
if (this.operator.hasPerms(this.permission.coreCanManageProjector)) {
return `/projectors/detail/${this.projector.id}`;
} else {
return `/projector/${this.projector.id}`;
}
} else {
return '';
}
}
public get projectionTarget(): '_blank' | '_self' {
if (this.operator.hasPerms(this.permission.coreCanManageProjector)) {
return '_self';
} else {
return '_blank';
}
}
public constructor(
title: Title,
translate: TranslateService,
snackBar: MatSnackBar,
private operator: OperatorService,
private projectorService: ProjectorService,
private projectorRepo: ProjectorRepositoryService,
private closService: CurrentListOfSpeakersService
) {
super(title, translate, snackBar);
}
public ngOnInit(): void {
this.subscriptions.push(
this.projectorRepo.getReferenceProjectorObservable().subscribe(refProjector => {
this.projector = refProjector;
this.currentProjectorElement = refProjector?.elements[0] || null;
if (this.currentProjectorElement) {
this.projectedViewModel = this.projectorService.getViewModelFromProjectorElement(
this.currentProjectorElement
);
} else {
this.projectedViewModel = null;
}
}),
this.closService.currentListOfSpeakersObservable.subscribe(clos => {
this.listOfSpeakers = clos;
})
);
}
}

View File

@ -0,0 +1,13 @@
<mat-card class="os-card" *ngFor="let poll of polls">
<p class="subtitle-text">
<a [routerLink]="getPollDetailLink(poll)" [state]="{ back: 'true' }">{{ getPollVoteTitle(poll) }}</a>
</p>
<div *ngIf="poll.pollClassType === 'motion'">
<os-motion-poll-vote [poll]="poll"></os-motion-poll-vote>
</div>
<div *ngIf="poll.pollClassType === 'assignment'">
<os-assignment-poll-vote [poll]="poll"></os-assignment-poll-vote>
</div>
</mat-card>

View File

@ -0,0 +1,27 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { PollCollectionComponent } from './poll-collection.component';
describe('PollCollectionComponent', () => {
let component: PollCollectionComponent;
let fixture: ComponentFixture<PollCollectionComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
declarations: [PollCollectionComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(PollCollectionComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,72 @@
import { Component, Input, OnInit } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
import { map } from 'rxjs/operators';
import { BaseViewComponent } from 'app/site/base/base-view';
import { BaseViewModel } from 'app/site/base/base-view-model';
import { ViewBasePoll } from 'app/site/polls/models/view-base-poll';
import { PollListObservableService } from 'app/site/polls/services/poll-list-observable.service';
@Component({
selector: 'os-poll-collection',
templateUrl: './poll-collection.component.html',
styleUrls: ['./poll-collection.component.scss']
})
export class PollCollectionComponent extends BaseViewComponent implements OnInit {
public polls: ViewBasePoll[];
@Input()
private currentProjection: BaseViewModel<any>;
private get showExtendedTitle(): boolean {
const areAllPollsSameModel = this.polls.every(
poll => this.polls[0].getContentObject() === poll.getContentObject()
);
if (this.currentProjection && areAllPollsSameModel) {
return this.polls[0].getContentObject() !== this.currentProjection;
} else {
return !areAllPollsSameModel;
}
}
public constructor(
title: Title,
translate: TranslateService,
snackBar: MatSnackBar,
private pollService: PollListObservableService
) {
super(title, translate, snackBar);
}
public ngOnInit(): void {
this.subscriptions.push(
this.pollService
.getViewModelListObservable()
.pipe(map(polls => polls.filter(poll => poll.canBeVotedFor())))
.subscribe(polls => {
this.polls = polls;
})
);
}
public getPollVoteTitle(poll: ViewBasePoll): string {
const contentObject = poll.getContentObject();
const listTitle = contentObject.getListTitle();
const model = contentObject.getVerboseName();
const pollTitle = poll.getTitle();
if (this.showExtendedTitle) {
return `(${model}) ${listTitle} - ${pollTitle}`;
} else {
return pollTitle;
}
}
public getPollDetailLink(poll: ViewBasePoll): string {
return poll.parentLink;
}
}

View File

@ -1,6 +1,3 @@
<div *osPerms="'motions.can_manage_polls'; and: poll && poll.isStarted">
<os-poll-progress [poll]="poll"></os-poll-progress>
</div>
<ng-container *ngIf="poll && !poll.user_has_voted; else userHasVotes"> <ng-container *ngIf="poll && !poll.user_has_voted; else userHasVotes">
<div *ngIf="vmanager.canVote(poll) && !deliveringVote" class="vote-button-grid"> <div *ngIf="vmanager.canVote(poll) && !deliveringVote" class="vote-button-grid">
<!-- Voting --> <!-- Voting -->

View File

@ -10,7 +10,7 @@ import { MotionPollComponent } from './motion-poll/motion-poll.component';
@NgModule({ @NgModule({
imports: [CommonModule, SharedModule, MotionPollRoutingModule, PollsModule], imports: [CommonModule, SharedModule, MotionPollRoutingModule, PollsModule],
exports: [MotionPollComponent], exports: [MotionPollComponent, MotionPollVoteComponent],
declarations: [MotionPollComponent, MotionPollDetailComponent, MotionPollVoteComponent] declarations: [MotionPollComponent, MotionPollDetailComponent, MotionPollVoteComponent]
}) })
export class MotionPollModule {} export class MotionPollModule {}

View File

@ -57,6 +57,9 @@
<!-- Results --> <!-- Results -->
<ng-container *ngIf="poll && !poll.stateHasVotes && poll.type !== 'analog'; else votingResult"> <ng-container *ngIf="poll && !poll.stateHasVotes && poll.type !== 'analog'; else votingResult">
<div *osPerms="'motions.can_manage_polls'; and: poll && poll.isStarted">
<os-poll-progress [poll]="poll"></os-poll-progress>
</div>
<os-motion-poll-vote [poll]="poll"></os-motion-poll-vote> <os-motion-poll-vote [poll]="poll"></os-motion-poll-vote>
</ng-container> </ng-container>

View File

@ -19,9 +19,9 @@ import { ViewProjector } from '../models/view-projector';
}) })
export class CurrentListOfSpeakersService { export class CurrentListOfSpeakersService {
/** /**
* Id of the current lost of speakers projector. Filled through observer * Current clos reference projector
*/ */
private closId: number; private closRefProjector: ViewProjector;
/** /**
* This map holds the current (number or null) los-id for the the projector. * This map holds the current (number or null) los-id for the the projector.
@ -56,9 +56,9 @@ export class CurrentListOfSpeakersService {
} }
}); });
this.projectorRepo.getReferenceProjectorIdObservable().subscribe(closId => { this.projectorRepo.getReferenceProjectorObservable().subscribe(clos => {
if (closId) { if (clos) {
this.closId = closId; this.closRefProjector = clos;
this.currentListOfSpeakerSubject.next(this.getCurrentListOfSpeakers()); this.currentListOfSpeakerSubject.next(this.getCurrentListOfSpeakers());
} }
}); });
@ -68,8 +68,7 @@ export class CurrentListOfSpeakersService {
* Use the subject to get it * Use the subject to get it
*/ */
private getCurrentListOfSpeakers(): ViewListOfSpeakers | null { private getCurrentListOfSpeakers(): ViewListOfSpeakers | null {
const refProjector = this.projectorRepo.getViewModel(this.closId); return this.getCurrentListOfSpeakersForProjector(this.closRefProjector);
return this.getCurrentListOfSpeakersForProjector(refProjector);
} }
/** /**
@ -135,7 +134,9 @@ export class CurrentListOfSpeakersService {
for (const nonStableElement of nonStableElements) { for (const nonStableElement of nonStableElements) {
const identifiableNonStableElement = this.slideManager.getIdentifiableProjectorElement(nonStableElement); const identifiableNonStableElement = this.slideManager.getIdentifiableProjectorElement(nonStableElement);
try { try {
const viewModel = this.projectorService.getViewModelFromProjectorElement(identifiableNonStableElement); const viewModel = this.projectorService.getViewModelFromIdentifiableProjectorElement(
identifiableNonStableElement
);
if (isBaseViewModelWithListOfSpeakers(viewModel)) { if (isBaseViewModelWithListOfSpeakers(viewModel)) {
return viewModel.listOfSpeakers; return viewModel.listOfSpeakers;
} }

View File

@ -75,6 +75,11 @@ const routes: Routes = [
loadChildren: () => import('./polls/polls.module').then(m => m.PollsModule), loadChildren: () => import('./polls/polls.module').then(m => m.PollsModule),
// one of them is sufficient // one of them is sufficient
data: { basePerm: [Permission.motionsCanSee, Permission.assignmentsCanSee] } data: { basePerm: [Permission.motionsCanSee, Permission.assignmentsCanSee] }
},
{
path: 'autopilot',
loadChildren: () => import('./cinema/cinema.module').then(m => m.CinemaModule),
data: { basePerm: Permission.coreCanSeeAutopilot }
} }
], ],
canActivateChild: [AuthGuard] canActivateChild: [AuthGuard]

View File

@ -25,7 +25,7 @@
<!-- navigation --> <!-- navigation -->
<mat-nav-list class="main-nav"> <mat-nav-list class="main-nav">
<span *ngFor="let entry of mainMenuService.entries"> <span *ngFor="let entry of mainMenuEntries">
<a <a
[@navItemAnim] [@navItemAnim]
*osPerms="entry.permission" *osPerms="entry.permission"

View File

@ -14,7 +14,7 @@ import { OfflineService } from 'app/core/core-services/offline.service';
import { OverlayService } from 'app/core/ui-services/overlay.service'; import { OverlayService } from 'app/core/ui-services/overlay.service';
import { UpdateService } from 'app/core/ui-services/update.service'; import { UpdateService } from 'app/core/ui-services/update.service';
import { BaseComponent } from '../base.component'; import { BaseComponent } from '../base.component';
import { MainMenuService } from '../core/core-services/main-menu.service'; import { MainMenuEntry, MainMenuService } from '../core/core-services/main-menu.service';
import { OpenSlidesStatusService } from '../core/core-services/openslides-status.service'; import { OpenSlidesStatusService } from '../core/core-services/openslides-status.service';
import { OperatorService } from '../core/core-services/operator.service'; import { OperatorService } from '../core/core-services/operator.service';
import { TimeTravelService } from '../core/core-services/time-travel.service'; import { TimeTravelService } from '../core/core-services/time-travel.service';
@ -66,6 +66,10 @@ export class SiteComponent extends BaseComponent implements OnInit {
*/ */
private delayedUpdateAvailable = false; private delayedUpdateAvailable = false;
public get mainMenuEntries(): MainMenuEntry[] {
return this.mainMenuService.entries;
}
/** /**
* Constructor * Constructor
* @param route * @param route
@ -86,7 +90,7 @@ export class SiteComponent extends BaseComponent implements OnInit {
public operator: OperatorService, public operator: OperatorService,
public vp: ViewportService, public vp: ViewportService,
public dialog: MatDialog, public dialog: MatDialog,
public mainMenuService: MainMenuService, private mainMenuService: MainMenuService,
public OSStatus: OpenSlidesStatusService, public OSStatus: OpenSlidesStatusService,
public timeTravel: TimeTravelService, public timeTravel: TimeTravelService,
private matSnackBar: MatSnackBar, private matSnackBar: MatSnackBar,

View File

@ -1,7 +1,7 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { PollState } from 'app/shared/models/poll/base-poll'; import { PollState } from 'app/shared/models/poll/base-poll';
import { AssignmentPollService } from 'app/site/assignments/services/assignment-poll.service'; import { AssignmentPollService } from 'app/site/assignments/modules/assignment-poll/services/assignment-poll.service';
import { BasePollSlideComponent } from 'app/slides/polls/base-poll-slide.component'; import { BasePollSlideComponent } from 'app/slides/polls/base-poll-slide.component';
import { AssignmentPollSlideData } from './assignment-poll-slide-data'; import { AssignmentPollSlideData } from './assignment-poll-slide-data';

View File

@ -40,7 +40,7 @@ $narrow-spacing: (
@import './app/shared/components/banner/banner.component.scss-theme.scss'; @import './app/shared/components/banner/banner.component.scss-theme.scss';
@import './app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.scss-theme.scss'; @import './app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.scss-theme.scss';
@import './app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.scss-theme.scss'; @import './app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.scss-theme.scss';
@import './app/site/assignments/components/assignment-poll-detail/assignment-poll-detail-component.scss-theme.scss'; @import './app/site/assignments/modules/assignment-poll/components/assignment-poll-detail/assignment-poll-detail-component.scss-theme.scss';
@import './app/shared/components/progress-snack-bar/progress-snack-bar.component.scss-theme.scss'; @import './app/shared/components/progress-snack-bar/progress-snack-bar.component.scss-theme.scss';
@import './app/shared/components/jitsi/jitsi.component.scss-theme.scss'; @import './app/shared/components/jitsi/jitsi.component.scss-theme.scss';
@import './app/shared/components/list-view-table/list-view-table.component.scss-theme.scss'; @import './app/shared/components/list-view-table/list-view-table.component.scss-theme.scss';

View File

@ -81,6 +81,13 @@
color: mat-color($foreground, text); color: mat-color($foreground, text);
} }
.subtitle-text {
font-size: 125%;
margin-top: 0;
margin-bottom: 15px;
color: mat-color(if($is-dark-theme, $accent, $primary));
}
.tile-color { .tile-color {
background-color: mat-color($background, selected-button); background-color: mat-color($background, selected-button);
} }

View File

@ -155,6 +155,15 @@ b,
text-transform: uppercase; text-transform: uppercase;
} }
// for aligning text lines with an icon and or link
.line-and-icon {
display: flex;
.mat-icon-button,
.mat-icon {
margin: auto 0;
}
}
.red-warning-text { .red-warning-text {
color: red; color: red;
.mat-icon { .mat-icon {

View File

@ -0,0 +1,57 @@
# Generated by Finn Stutzenstein on 2020-08-25 10:22
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.db import migrations
from openslides.users.models import Group
def add_permission_to_delegates(apps, schema_editor):
"""
Adds the permissions `can_see_autopilot` to the delegate group.
"""
Projector = apps.get_model("core", "Projector")
try:
delegate = Group.objects.get(name="Delegates")
except Group.DoesNotExist:
return
content_type = ContentType.objects.get_for_model(Projector)
try:
perm = Permission.objects.get(
content_type=content_type, codename="can_see_autopilot"
)
except Permission.DoesNotExist:
perm = Permission.objects.create(
codename="can_see_autopilot",
name="Can see the autopilot",
content_type=content_type,
)
delegate.permissions.add(perm)
delegate.save()
class Migration(migrations.Migration):
dependencies = [
("core", "0034_amendment_projection_defaults"),
]
operations = [
migrations.AlterModelOptions(
name="projector",
options={
"default_permissions": (),
"permissions": (
("can_see_projector", "Can see the projector"),
("can_manage_projector", "Can manage the projector"),
("can_see_frontpage", "Can see the front page"),
("can_see_livestream", "Can see the live stream"),
("can_see_autopilot", "Can see the autopilot"),
),
},
),
migrations.RunPython(add_permission_to_delegates),
]

View File

@ -119,6 +119,7 @@ class Projector(RESTModelMixin, models.Model):
("can_manage_projector", "Can manage the projector"), ("can_manage_projector", "Can manage the projector"),
("can_see_frontpage", "Can see the front page"), ("can_see_frontpage", "Can see the front page"),
("can_see_livestream", "Can see the live stream"), ("can_see_livestream", "Can see the live stream"),
("can_see_autopilot", "Can see the autopilot"),
) )

View File

@ -51,6 +51,7 @@ def create_builtin_groups_and_admin(**kwargs):
"core.can_see_frontpage", "core.can_see_frontpage",
"core.can_see_history", "core.can_see_history",
"core.can_see_projector", "core.can_see_projector",
"core.can_see_autopilot",
"mediafiles.can_manage", "mediafiles.can_manage",
"mediafiles.can_see", "mediafiles.can_see",
"motions.can_create", "motions.can_create",
@ -113,6 +114,7 @@ def create_builtin_groups_and_admin(**kwargs):
permission_dict["assignments.can_nominate_self"], permission_dict["assignments.can_nominate_self"],
permission_dict["core.can_see_frontpage"], permission_dict["core.can_see_frontpage"],
permission_dict["core.can_see_projector"], permission_dict["core.can_see_projector"],
permission_dict["core.can_see_autopilot"],
permission_dict["mediafiles.can_see"], permission_dict["mediafiles.can_see"],
permission_dict["motions.can_see"], permission_dict["motions.can_see"],
permission_dict["motions.can_create"], permission_dict["motions.can_create"],