added vote per user table and progress for polls

added update for options after stopping a poll
This commit is contained in:
Joshua Sangmeister 2020-01-16 17:22:12 +01:00 committed by FinnStutzenstein
parent 604df9d48b
commit 682db96b7c
21 changed files with 244 additions and 85 deletions

View File

@ -9,6 +9,7 @@ import { ViewModelStoreService } from 'app/core/core-services/view-model-store.s
import { RelationDefinition } from 'app/core/definitions/relations'; import { RelationDefinition } from 'app/core/definitions/relations';
import { VotingService } from 'app/core/ui-services/voting.service'; import { VotingService } from 'app/core/ui-services/voting.service';
import { MotionPoll } from 'app/shared/models/motions/motion-poll'; import { MotionPoll } from 'app/shared/models/motions/motion-poll';
import { ViewMotion } from 'app/site/motions/models/view-motion';
import { ViewMotionOption } from 'app/site/motions/models/view-motion-option'; import { ViewMotionOption } from 'app/site/motions/models/view-motion-option';
import { MotionPollTitleInformation, ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; import { MotionPollTitleInformation, ViewMotionPoll } from 'app/site/motions/models/view-motion-poll';
import { BasePollRepositoryService } from 'app/site/polls/services/base-poll-repository.service'; import { BasePollRepositoryService } from 'app/site/polls/services/base-poll-repository.service';
@ -35,6 +36,12 @@ const MotionPollRelations: RelationDefinition[] = [
ownIdKey: 'options_id', ownIdKey: 'options_id',
ownKey: 'options', ownKey: 'options',
foreignViewModel: ViewMotionOption foreignViewModel: ViewMotionOption
},
{
type: 'M2O',
ownIdKey: 'motion_id',
ownKey: 'motion',
foreignViewModel: ViewMotion
} }
]; ];

View File

@ -35,7 +35,8 @@ export class VotingService {
* checks whether the operator can vote on the given poll * checks whether the operator can vote on the given poll
*/ */
public canVote(poll: ViewBasePoll): boolean { public canVote(poll: ViewBasePoll): boolean {
return !this.getVotePermissionError(poll); const error = this.getVotePermissionError(poll);
return !error;
} }
/** /**

View File

@ -7,10 +7,11 @@ import { AssignmentPollDetailComponent } from './components/assignment-poll-deta
import { AssignmentPollVoteComponent } from './components/assignment-poll-vote/assignment-poll-vote.component'; import { AssignmentPollVoteComponent } from './components/assignment-poll-vote/assignment-poll-vote.component';
import { AssignmentPollComponent } from './components/assignment-poll/assignment-poll.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], imports: [CommonModule, AssignmentsRoutingModule, SharedModule, PollsModule],
declarations: [ declarations: [
AssignmentDetailComponent, AssignmentDetailComponent,
AssignmentListComponent, AssignmentListComponent,

View File

@ -38,30 +38,13 @@
<div>{{ 'Majority method' | translate }}: {{ poll.majorityMethodVerbose | translate }}</div> <div>{{ 'Majority method' | translate }}: {{ poll.majorityMethodVerbose | translate }}</div>
<div>{{ '100% base' | translate }}: {{ poll.percentBaseVerbose | translate }}</div> <div>{{ '100% base' | translate }}: {{ poll.percentBaseVerbose | translate }}</div>
</div> </div>
<div *ngIf="poll.state === 2">
<os-poll-progress [poll]="poll"></os-poll-progress>
</div>
<div *ngIf="poll.state === 3 || poll.state === 4"> <div *ngIf="poll.state === 3 || poll.state === 4">
<h2 translate>Result</h2> <h2 translate>Result</h2>
<div
*ngIf="poll.type === 'named'"
style="display: grid; grid-template-columns: auto repeat({{ poll.options.length }}, max-content);"
>
<!-- top left cell is empty -->
<div></div>
<!-- header (the assignment related users) -->
<ng-container *ngFor="let option of poll.options">
<div *ngIf="option.user">{{ option.user.full_name }}</div>
<div *ngIf="!option.user">{{ 'Unknown user' | translate }}</div>
</ng-container>
<!-- rows -->
<ng-container *ngFor="let obj of votesByUser | keyvalue">
<div *ngIf="obj.value.user">{{ obj.value.user.full_name }}</div>
<div *ngIf="!obj.value.user">{{ 'Unknown user' | translate }}</div>
<ng-container *ngFor="let option of poll.options">
<div>{{ obj.value.votes[option.user_id] }}</div>
</ng-container>
</ng-container>
</div>
<div class="chart-wrapper"></div> <div class="chart-wrapper"></div>
<mat-table [dataSource]="poll.tableData"> <mat-table [dataSource]="poll.tableData">
<ng-container matColumnDef="user" sticky> <ng-container matColumnDef="user" sticky>
@ -88,9 +71,10 @@
<mat-cell *matCellDef="let row"></mat-cell> <mat-cell *matCellDef="let row"></mat-cell>
</ng-container> </ng-container>
<mat-header-row *matHeaderRowDef="columnDefinition"></mat-header-row> <mat-header-row *matHeaderRowDef="columnDefinitionOverview"></mat-header-row>
<mat-row *matRowDef="let row; columns: columnDefinition"></mat-row> <mat-row *matRowDef="let row; columns: columnDefinitionOverview"></mat-row>
</mat-table> </mat-table>
<os-charts <os-charts
*ngIf="chartDataSubject.value" *ngIf="chartDataSubject.value"
[type]="chartType" [type]="chartType"
@ -98,6 +82,31 @@
[showLegend]="true" [showLegend]="true"
[data]="chartDataSubject" [data]="chartDataSubject"
></os-charts> ></os-charts>
<ng-container *ngIf="poll.type === 'named' && votesDataSource.data">
<input matInput [(ngModel)]="votesDataSource.filter" placeholder="Filter"/>
<mat-table [dataSource]="votesDataSource">
<ng-container matColumnDef="users" sticky>
<mat-header-cell *matHeaderCellDef>{{ "User" | translate }}</mat-header-cell>
<mat-cell *matCellDef="let row">
<div *ngIf="row.user">{{ row.user.getFullName() }}</div>
<div *ngIf="!row.user">{{ "Unknown user" | translate }}</div>
</mat-cell>
</ng-container>
<ng-container [matColumnDef]="'votes-' + option.user_id" *ngFor="let option of poll.options" sticky>
<mat-header-cell *matHeaderCellDef>
<div *ngIf="option.user">{{ option.user.getFullName() }}</div>
<div *ngIf="!option.user">{{ "Unknown user" | translate }}</div>
</mat-header-cell>
<mat-cell *matCellDef="let row">
{{ row.votes[option.user_id] }}
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="columnDefinitionPerName"></mat-header-row>
<mat-row *matRowDef="let row; columns: columnDefinitionPerName"></mat-row>
</mat-table>
</ng-container>
</div> </div>
</ng-container> </ng-container>
</ng-template> </ng-template>
@ -106,7 +115,7 @@
<mat-menu #pollDetailMenu="matMenu"> <mat-menu #pollDetailMenu="matMenu">
<os-projector-button [menuItem]="true" [object]="poll" *osPerms="'core.can_manage_projector'"></os-projector-button> <os-projector-button [menuItem]="true" [object]="poll" *osPerms="'core.can_manage_projector'"></os-projector-button>
<button mat-menu-item *ngIf="poll && poll.type === 'named'" (click)="pseudoanonymizePoll()"> <button mat-menu-item *ngIf="poll && poll.type === 'named'" (click)="pseudoanonymizePoll()">
<mat-icon>questionmark</mat-icon> <mat-icon>polymer</mat-icon>
<span translate>Pseudoanonymize</span> <span translate>Pseudoanonymize</span>
</button> </button>
<mat-divider></mat-divider> <mat-divider></mat-divider>

View File

@ -12,10 +12,8 @@ import { PromptService } from 'app/core/ui-services/prompt.service';
import { ChartType } from 'app/shared/components/charts/charts.component'; import { ChartType } from 'app/shared/components/charts/charts.component';
import { AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll'; import { AssignmentPollMethods } from 'app/shared/models/assignments/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 { ViewUser } from 'app/site/users/models/view-user';
import { AssignmentPollDialogService } from '../../services/assignment-poll-dialog.service'; import { AssignmentPollDialogService } from '../../services/assignment-poll-dialog.service';
import { ViewAssignmentPoll } from '../../models/view-assignment-poll'; import { ViewAssignmentPoll } from '../../models/view-assignment-poll';
import { ViewAssignmentVote } from '../../models/view-assignment-vote';
@Component({ @Component({
selector: 'os-assignment-poll-detail', selector: 'os-assignment-poll-detail',
@ -27,20 +25,20 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewA
public candidatesLabels: string[] = []; public candidatesLabels: string[] = [];
public votesByUser: { [key: number]: { user: ViewUser; votes: { [key: number]: ViewAssignmentVote } } };
public get chartType(): ChartType { public get chartType(): ChartType {
return 'horizontalBar'; return 'horizontalBar';
} }
public get columnDefinition(): string[] { public get columnDefinitionOverview(): string[] {
const columns = ['user', 'yes', 'no', 'quorum']; const columns = ['user', 'yes', 'no', 'quorum'];
if ((<ViewAssignmentPoll>this.poll).pollmethod === AssignmentPollMethods.YNA) { if (this.poll.pollmethod === AssignmentPollMethods.YNA) {
columns.splice(3, 0, 'abstain'); columns.splice(3, 0, 'abstain');
} }
return columns; return columns;
} }
public columnDefinitionPerName: string[];
public constructor( public constructor(
title: Title, title: Title,
translate: TranslateService, translate: TranslateService,
@ -55,27 +53,32 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewA
super(title, translate, matSnackbar, repo, route, groupRepo, prompt, pollDialog); super(title, translate, matSnackbar, repo, route, groupRepo, prompt, pollDialog);
} }
public onPollLoaded(): void { public onPollWithOptionsLoaded(): void {
const votes = {}; this.columnDefinitionPerName = ['users'].concat(this.poll.options.map(option => 'votes-' + option.user_id));
setTimeout(() => { const votes = {};
for (const option of this.poll.options) { let i = -1;
for (const vote of option.votes) { for (const option of this.poll.options) {
if (!votes[vote.user_id]) { for (const vote of option.votes) {
votes[vote.user_id] = { // if poll was pseudoanonymized, use a negative index to not interfere with
user: vote.user, // possible named votes (although this should never happen)
votes: {} const userId = vote.user_id || i--;
}; if (!votes[userId]) {
} votes[userId] = {
votes[vote.user_id].votes[option.user_id] = user: vote.user,
this.poll.pollmethod === AssignmentPollMethods.Votes ? vote.weight : vote.valueVerbose; votes: {}
};
} }
votes[userId].votes[option.user_id] =
this.poll.pollmethod === AssignmentPollMethods.Votes ? vote.weight : vote.valueVerbose;
} }
console.log(votes, this.poll, this.poll.options); }
this.votesByUser = votes;
this.candidatesLabels = this.poll.initChartLabels(); this.setVotesData(Object.values(votes));
this.isReady = true;
}); this.candidatesLabels = this.poll.initChartLabels();
this.isReady = true;
} }
protected hasPerms(): boolean { protected hasPerms(): boolean {

View File

@ -4,6 +4,7 @@ import { PollColor } from 'app/shared/models/poll/base-poll';
import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable'; import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable';
import { ViewMotionOption } from 'app/site/motions/models/view-motion-option'; import { ViewMotionOption } from 'app/site/motions/models/view-motion-option';
import { ViewBasePoll } from 'app/site/polls/models/view-base-poll'; import { ViewBasePoll } from 'app/site/polls/models/view-base-poll';
import { ViewMotion } from './view-motion';
export interface MotionPollTitleInformation { export interface MotionPollTitleInformation {
title: string; title: string;
@ -72,5 +73,6 @@ export class ViewMotionPoll extends ViewBasePoll<MotionPoll> implements MotionPo
} }
export interface ViewMotionPoll extends MotionPoll { export interface ViewMotionPoll extends MotionPoll {
motion: ViewMotion;
options: ViewMotionOption[]; options: ViewMotionOption[];
} }

View File

@ -1,6 +1,6 @@
<os-head-bar [goBack]="true" [nav]="false"> <os-head-bar [goBack]="true" [nav]="false">
<div class="title-slot"> <div class="title-slot">
<h2 *ngIf="motion">{{ 'Motion' | translate }} {{ motion.id }}</h2> <h2 *ngIf="poll">{{ 'Motion' | translate }} {{ poll.motion.id }}</h2>
</div> </div>
<div class="menu-slot" *osPerms="'agenda.can_manage'; or: 'agenda.can_see_list_of_speakers'"> <div class="menu-slot" *osPerms="'agenda.can_manage'; or: 'agenda.can_see_list_of_speakers'">
@ -51,17 +51,26 @@
<mat-row *matRowDef="let row; columns: columnDefinition"></mat-row> <mat-row *matRowDef="let row; columns: columnDefinition"></mat-row>
</mat-table> </mat-table>
<!-- Named table --> <!-- Named table: only show if votes are present -->
<!-- The table was created in another PR --> <ng-container *ngIf="poll.type === 'named' && votesDataSource.data">
<div class="named-result-table" *ngIf="poll.type === 'named'"> <input matInput [(ngModel)]="votesDataSource.filter" placeholder="Filter"/>
<h3>{{ 'Singe votes' | translate }}</h3> <mat-table [dataSource]="votesDataSource">
<ng-container matColumnDef="key" sticky>
<div *ngFor="let vote of poll.options[0].votes"> <mat-header-cell *matHeaderCellDef>{{ "User" | translate }}</mat-header-cell>
<div *ngIf="vote.user">{{ vote.user.full_name }}</div> <mat-cell *matCellDef="let vote">
<div *ngIf="!vote.user">{{ 'Unknown user' | translate }}</div> <div *ngIf="vote.user">{{ vote.user.getFullName() }}</div>
<div>{{ vote.valueVerbose }}</div> <div *ngIf="!vote.user">{{ 'Unknown user' | translate }}</div>
</div> </mat-cell>
</div> </ng-container>
<ng-container matColumnDef="value" sticky>
<mat-header-cell *matHeaderCellDef>{{ "Vote" | translate }}</mat-header-cell>
<mat-cell *matCellDef="let vote">{{ vote.valueVerbose }}</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="columnDefinition"></mat-header-row>
<mat-row *matRowDef="let vote; columns: columnDefinition"></mat-row>
</mat-table>
</ng-container>
</div> </div>
</div> </div>
@ -76,6 +85,10 @@
<div>{{ 'Majority method' | translate }}: {{ poll.majorityMethodVerbose | translate }}</div> <div>{{ 'Majority method' | translate }}: {{ poll.majorityMethodVerbose | translate }}</div>
<div>{{ '100% base' | translate }}: {{ poll.percentBaseVerbose | translate }}</div> <div>{{ '100% base' | translate }}: {{ poll.percentBaseVerbose | translate }}</div>
</div> </div>
<div *ngIf="poll.state === 2">
<os-poll-progress [poll]="poll"></os-poll-progress>
</div>
</ng-container> </ng-container>
</ng-template> </ng-template>
@ -87,7 +100,7 @@
<span translate>Edit</span> <span translate>Edit</span>
</button> </button>
<button mat-menu-item *ngIf="poll && poll.type === 'named'" (click)="pseudoanonymizePoll()"> <button mat-menu-item *ngIf="poll && poll.type === 'named'" (click)="pseudoanonymizePoll()">
<mat-icon>questionmark</mat-icon> <mat-icon>polymer</mat-icon>
<span translate>Pseudoanonymize</span> <span translate>Pseudoanonymize</span>
</button> </button>
<mat-divider></mat-divider> <mat-divider></mat-divider>

View File

@ -7,7 +7,6 @@ import { TranslateService } from '@ngx-translate/core';
import { OperatorService } from 'app/core/core-services/operator.service'; import { OperatorService } from 'app/core/core-services/operator.service';
import { MotionPollRepositoryService } from 'app/core/repositories/motions/motion-poll-repository.service'; import { MotionPollRepositoryService } from 'app/core/repositories/motions/motion-poll-repository.service';
import { MotionRepositoryService } from 'app/core/repositories/motions/motion-repository.service';
import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service'; import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service';
import { PromptService } from 'app/core/ui-services/prompt.service'; import { PromptService } from 'app/core/ui-services/prompt.service';
import { ChartType } from 'app/shared/components/charts/charts.component'; import { ChartType } from 'app/shared/components/charts/charts.component';
@ -45,14 +44,13 @@ export class MotionPollDetailComponent extends BasePollDetailComponent<ViewMotio
prompt: PromptService, prompt: PromptService,
pollDialog: MotionPollDialogService, pollDialog: MotionPollDialogService,
private operator: OperatorService, private operator: OperatorService,
private router: Router, private router: Router
private motionRepo: MotionRepositoryService
) { ) {
super(title, translate, matSnackbar, repo, route, groupRepo, prompt, pollDialog); super(title, translate, matSnackbar, repo, route, groupRepo, prompt, pollDialog);
} }
protected onPollLoaded(): void { protected onPollWithOptionsLoaded(): void {
this.motion = this.motionRepo.getViewModel((<ViewMotionPoll>this.poll).motion_id); this.setVotesData(this.poll.options[0].votes);
} }
public openDialog(): void { public openDialog(): void {
@ -61,7 +59,7 @@ export class MotionPollDetailComponent extends BasePollDetailComponent<ViewMotio
} }
protected onDeleted(): void { protected onDeleted(): void {
this.router.navigate(['motions', (<ViewMotionPoll>this.poll).motion_id]); this.router.navigate(['motions', this.poll.motion_id]);
} }
protected hasPerms(): boolean { protected hasPerms(): boolean {

View File

@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { SharedModule } from 'app/shared/shared.module'; import { SharedModule } from 'app/shared/shared.module';
import { PollsModule } from 'app/site/polls/polls.module';
import { MotionPollDetailComponent } from './motion-poll-detail/motion-poll-detail.component'; import { MotionPollDetailComponent } from './motion-poll-detail/motion-poll-detail.component';
import { MotionPollListComponent } from './motion-poll-list/motion-poll-list.component'; import { MotionPollListComponent } from './motion-poll-list/motion-poll-list.component';
import { MotionPollRoutingModule } from './motion-poll-routing.module'; import { MotionPollRoutingModule } from './motion-poll-routing.module';
@ -9,7 +10,7 @@ import { MotionPollVoteComponent } from './motion-poll-vote/motion-poll-vote.com
import { MotionPollComponent } from './motion-poll/motion-poll.component'; import { MotionPollComponent } from './motion-poll/motion-poll.component';
@NgModule({ @NgModule({
imports: [CommonModule, SharedModule, MotionPollRoutingModule], imports: [CommonModule, SharedModule, MotionPollRoutingModule, PollsModule],
exports: [MotionPollComponent], exports: [MotionPollComponent],
declarations: [MotionPollComponent, MotionPollDetailComponent, MotionPollListComponent, MotionPollVoteComponent] declarations: [MotionPollComponent, MotionPollDetailComponent, MotionPollListComponent, MotionPollVoteComponent]
}) })

View File

@ -1,5 +1,5 @@
import { OnInit } from '@angular/core'; import { OnInit } from '@angular/core';
import { MatSnackBar } from '@angular/material'; import { MatSnackBar, MatTableDataSource } from '@angular/material';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
@ -15,9 +15,14 @@ import { ChartData, ChartType } from 'app/shared/components/charts/charts.compon
import { PollState, PollType } from 'app/shared/models/poll/base-poll'; import { PollState, PollType } from 'app/shared/models/poll/base-poll';
import { BaseViewComponent } from 'app/site/base/base-view'; import { BaseViewComponent } from 'app/site/base/base-view';
import { ViewGroup } from 'app/site/users/models/view-group'; import { ViewGroup } from 'app/site/users/models/view-group';
import { ViewUser } from 'app/site/users/models/view-user';
import { BasePollRepositoryService } from '../services/base-poll-repository.service'; import { BasePollRepositoryService } from '../services/base-poll-repository.service';
import { ViewBasePoll } from '../models/view-base-poll'; import { ViewBasePoll } from '../models/view-base-poll';
export interface BaseVoteData {
user?: ViewUser;
}
export abstract class BasePollDetailComponent<V extends ViewBasePoll> extends BaseViewComponent implements OnInit { export abstract class BasePollDetailComponent<V extends ViewBasePoll> extends BaseViewComponent implements OnInit {
/** /**
* All the groups of users. * All the groups of users.
@ -55,6 +60,9 @@ export abstract class BasePollDetailComponent<V extends ViewBasePoll> extends Ba
*/ */
public chartDataSubject: BehaviorSubject<ChartData> = new BehaviorSubject(null); public chartDataSubject: BehaviorSubject<ChartData> = new BehaviorSubject(null);
// The datasource for the votes-per-user table
public votesDataSource: MatTableDataSource<BaseVoteData> = new MatTableDataSource();
/** /**
* Constructor * Constructor
* *
@ -81,6 +89,7 @@ export abstract class BasePollDetailComponent<V extends ViewBasePoll> extends Ba
protected pollDialog: BasePollDialogService<V> protected pollDialog: BasePollDialogService<V>
) { ) {
super(title, translate, matSnackbar); super(title, translate, matSnackbar);
this.votesDataSource.filterPredicate = this.dataSourceFilterPredicate;
} }
/** /**
@ -100,7 +109,7 @@ export abstract class BasePollDetailComponent<V extends ViewBasePoll> extends Ba
const text = 'Do you really want to delete the selected poll?'; const text = 'Do you really want to delete the selected poll?';
if (await this.promptDialog.open(title, text)) { if (await this.promptDialog.open(title, text)) {
this.repo.delete(this.poll).then(() => this.onDeleted()); this.repo.delete(this.poll).then(() => this.onDeleted(), this.raiseError);
} }
} }
@ -109,7 +118,7 @@ export abstract class BasePollDetailComponent<V extends ViewBasePoll> extends Ba
const text = 'Do you really want to pseudoanonymize the selected poll?'; const text = 'Do you really want to pseudoanonymize the selected poll?';
if (await this.promptDialog.open(title, text)) { if (await this.promptDialog.open(title, text)) {
await this.repo.pseudoanonymize(this.poll); this.repo.pseudoanonymize(this.poll).then(() => this.onPollLoaded(), this.raiseError); // votes have changed, but not the poll, so the components have to be informed about the update
} }
} }
@ -129,10 +138,35 @@ export abstract class BasePollDetailComponent<V extends ViewBasePoll> extends Ba
*/ */
protected onPollLoaded(): void {} protected onPollLoaded(): void {}
protected onPollWithOptionsLoaded(): void {}
protected onStateChanged(): void {} protected onStateChanged(): void {}
protected abstract hasPerms(): boolean; protected abstract hasPerms(): boolean;
// custom filter for the data source: only search in usernames
protected dataSourceFilterPredicate(data: BaseVoteData, filter: string): boolean {
return (
data.user &&
data.user
.getFullName()
.trim()
.toLowerCase()
.indexOf(filter.trim().toLowerCase()) !== -1
);
}
/**
* sets the votes data only if the poll wasn't pseudoanonymized
*/
protected setVotesData(data: BaseVoteData[]): void {
if (data.every(voteDate => !voteDate.user)) {
this.votesDataSource.data = null;
} else {
this.votesDataSource.data = data;
}
}
/** /**
* This checks, if the poll has votes. * This checks, if the poll has votes.
*/ */
@ -155,6 +189,15 @@ export abstract class BasePollDetailComponent<V extends ViewBasePoll> extends Ba
this.updateBreadcrumbs(); this.updateBreadcrumbs();
this.checkData(); this.checkData();
this.onPollLoaded(); this.onPollLoaded();
// wait for options to be loaded
(function waitForOptions(): void {
if (!this.poll.options || !this.poll.options.length) {
setTimeout(waitForOptions.bind(this), 1);
} else {
this.onPollWithOptionsLoaded();
}
}.call(this));
} }
}) })
); );

View File

@ -0,0 +1 @@
<span>{{ this.poll.voted_id.length }} von {{ this.max }} haben abgestimmt.</span>

View File

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

View File

@ -0,0 +1,44 @@
import { Component, Input, OnInit } from '@angular/core';
import { MatSnackBar } from '@angular/material';
import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
import { map } from 'rxjs/operators';
import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
import { BaseViewComponent } from 'app/site/base/base-view';
import { ViewBasePoll } from 'app/site/polls/models/view-base-poll';
@Component({
selector: 'os-poll-progress',
templateUrl: './poll-progress.component.html',
styleUrls: ['./poll-progress.component.scss']
})
export class PollProgressComponent extends BaseViewComponent implements OnInit {
@Input()
public poll: ViewBasePoll;
public max: number;
public constructor(
title: Title,
protected translate: TranslateService,
snackbar: MatSnackBar,
private userRepo: UserRepositoryService
) {
super(title, translate, snackbar);
}
/**
* OnInit.
* Sets the observable for groups.
*/
public ngOnInit(): void {
this.userRepo
.getViewModelListObservable()
.pipe(map(users => users.filter(user => this.poll.groups_id.intersect(user.groups_id).length)))
.subscribe(users => {
this.max = users.length;
});
}
}

View File

@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { PollListComponent } from './components/poll-list/poll-list.component'; import { PollListComponent } from './components/poll-list/poll-list.component';
import { PollProgressComponent } from './components/poll-progress/poll-progress.component';
import { PollsRoutingModule } from './polls-routing.module'; import { PollsRoutingModule } from './polls-routing.module';
import { SharedModule } from '../../shared/shared.module'; import { SharedModule } from '../../shared/shared.module';
@ -11,6 +12,7 @@ import { SharedModule } from '../../shared/shared.module';
*/ */
@NgModule({ @NgModule({
imports: [CommonModule, PollsRoutingModule, SharedModule], imports: [CommonModule, PollsRoutingModule, SharedModule],
declarations: [PollListComponent] exports: [PollProgressComponent],
declarations: [PollListComponent, PollProgressComponent]
}) })
export class PollsModule {} export class PollsModule {}

View File

@ -2,10 +2,12 @@ import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs'; import { BehaviorSubject, Observable } from 'rxjs';
import { CollectionStringMapperService } from 'app/core/core-services/collection-string-mapper.service';
import { HasViewModelListObservable } from 'app/core/definitions/has-view-model-list-observable'; import { HasViewModelListObservable } from 'app/core/definitions/has-view-model-list-observable';
import { AssignmentPollRepositoryService } from 'app/core/repositories/assignments/assignment-poll-repository.service'; import { AssignmentPollRepositoryService } from 'app/core/repositories/assignments/assignment-poll-repository.service';
import { MotionPollRepositoryService } from 'app/core/repositories/motions/motion-poll-repository.service'; import { MotionPollRepositoryService } from 'app/core/repositories/motions/motion-poll-repository.service';
import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll'; import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll';
import { BaseViewModel } from 'app/site/base/base-view-model';
import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll';
import { ViewBasePoll } from '../models/view-base-poll'; import { ViewBasePoll } from '../models/view-base-poll';
@ -21,7 +23,8 @@ export class PollListObservableService implements HasViewModelListObservable<Vie
public constructor( public constructor(
motionPollRepo: MotionPollRepositoryService, motionPollRepo: MotionPollRepositoryService,
assignmentPollRepo: AssignmentPollRepositoryService assignmentPollRepo: AssignmentPollRepositoryService,
private mapper: CollectionStringMapperService
) { ) {
motionPollRepo motionPollRepo
.getViewModelListObservable() .getViewModelListObservable()
@ -41,4 +44,8 @@ export class PollListObservableService implements HasViewModelListObservable<Vie
public getViewModelListObservable(): Observable<ViewBasePoll[]> { public getViewModelListObservable(): Observable<ViewBasePoll[]> {
return this.viewPollListSubject.asObservable(); return this.viewPollListSubject.asObservable();
} }
public getObservableFromViewModel(poll: ViewBasePoll): Observable<BaseViewModel> {
return this.mapper.getRepository(poll.collectionString).getViewModelObservable(poll.id);
}
} }

View File

@ -94,7 +94,6 @@ class BasePollViewSet(ModelViewSet):
return super().update(request, *args, **kwargs) return super().update(request, *args, **kwargs)
def handle_request_with_votes(self, request, poll): def handle_request_with_votes(self, request, poll):
print(poll, poll.type, BasePoll.TYPE_ANALOG)
if poll.type != BasePoll.TYPE_ANALOG: if poll.type != BasePoll.TYPE_ANALOG:
raise ValidationError( raise ValidationError(
{"detail": "You cannot enter votes for a non-analog poll."} {"detail": "You cannot enter votes for a non-analog poll."}
@ -142,6 +141,7 @@ class BasePollViewSet(ModelViewSet):
poll.state = BasePoll.STATE_FINISHED poll.state = BasePoll.STATE_FINISHED
poll.save() poll.save()
inform_changed_data(poll.get_votes()) inform_changed_data(poll.get_votes())
inform_changed_data(poll.get_options())
return Response() return Response()
@detail_route(methods=["POST"]) @detail_route(methods=["POST"])
@ -233,7 +233,9 @@ class BasePollViewSet(ModelViewSet):
if poll.state != BasePoll.STATE_STARTED: if poll.state != BasePoll.STATE_STARTED:
raise ValidationError("You can only vote on a started poll.") raise ValidationError("You can only vote on a started poll.")
if not request.user.is_present or not in_some_groups( if not request.user.is_present or not in_some_groups(
request.user.id, poll.groups.all(), exact=True request.user.id,
list(poll.groups.values_list("pk", flat=True)),
exact=True,
): ):
self.permission_denied(request) self.permission_denied(request)

View File

@ -7,7 +7,6 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.db.models import Model from django.db.models import Model
from django.db.models.query import QuerySet
from .cache import element_cache from .cache import element_cache
@ -112,9 +111,8 @@ async def async_has_perm(user_id: int, perm: str) -> bool:
return has_perm return has_perm
def in_some_groups( # async code doesn't work well with QuerySets, so we have to give a list of ints for groups
user_id: int, groups: Union[List[int], QuerySet], exact: bool = False def in_some_groups(user_id: int, groups: List[int], exact: bool = False) -> bool:
) -> bool:
""" """
Checks that user is in at least one given group. Groups can be given as a list Checks that user is in at least one given group. Groups can be given as a list
of ids or a QuerySet. of ids or a QuerySet.
@ -134,7 +132,7 @@ def in_some_groups(
async def async_in_some_groups( async def async_in_some_groups(
user_id: int, groups: Union[List[int], QuerySet], exact: bool = False user_id: int, groups: List[int], exact: bool = False
) -> bool: ) -> bool:
""" """
Checks that user is in at least one given group. Groups can be given as a list Checks that user is in at least one given group. Groups can be given as a list
@ -143,9 +141,6 @@ async def async_in_some_groups(
user_id 0 means anonymous user. user_id 0 means anonymous user.
""" """
if isinstance(groups, QuerySet):
groups = [group.pk for group in groups]
if not user_id and not await async_anonymous_is_enabled(): if not user_id and not await async_anonymous_is_enabled():
in_some_groups = False in_some_groups = False
elif not user_id: elif not user_id:

View File

@ -382,7 +382,6 @@ class ManageSpeaker(TestCase):
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
def test_remove_someone_else(self): def test_remove_someone_else(self):
print(self.user)
speaker = Speaker.objects.add(self.user, self.list_of_speakers) speaker = Speaker.objects.add(self.user, self.list_of_speakers)
response = self.client.delete( response = self.client.delete(
reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]), reverse("listofspeakers-manage-speaker", args=[self.list_of_speakers.pk]),

View File

@ -708,6 +708,7 @@ class VoteAssignmentPollBaseTestClass(TestCase):
self.admin.save() self.admin.save()
self.poll.groups.add(GROUP_ADMIN_PK) self.poll.groups.add(GROUP_ADMIN_PK)
self.poll.create_options() self.poll.create_options()
inform_changed_data(self.poll)
def create_poll(self): def create_poll(self):
# has to be implemented by subclasses # has to be implemented by subclasses

View File

@ -70,6 +70,7 @@ class TestCreation(TestCase):
"is_directory": True, "is_directory": True,
"mediafile": self.file, "mediafile": self.file,
}, },
format="multipart",
) )
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertFalse(Mediafile.objects.exists()) self.assertFalse(Mediafile.objects.exists())
@ -79,6 +80,7 @@ class TestCreation(TestCase):
response = self.client.post( response = self.client.post(
reverse("mediafile-list"), reverse("mediafile-list"),
{"title": "test_title_vai8oDogohheideedie4", "mediafile": file}, {"title": "test_title_vai8oDogohheideedie4", "mediafile": file},
format="multipart",
) )
self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.status_code, status.HTTP_201_CREATED)
mediafile = Mediafile.objects.get() mediafile = Mediafile.objects.get()
@ -90,6 +92,7 @@ class TestCreation(TestCase):
response = self.client.post( response = self.client.post(
reverse("mediafile-list"), reverse("mediafile-list"),
{"title": "test_title_Zeicheipeequie3ohfid", "mediafile": file1}, {"title": "test_title_Zeicheipeequie3ohfid", "mediafile": file1},
format="multipart",
) )
self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.status_code, status.HTTP_201_CREATED)
mediafile = Mediafile.objects.get() mediafile = Mediafile.objects.get()
@ -98,6 +101,7 @@ class TestCreation(TestCase):
response = self.client.post( response = self.client.post(
reverse("mediafile-list"), reverse("mediafile-list"),
{"title": "test_title_aiChaetohs0quicee9eb", "mediafile": file2}, {"title": "test_title_aiChaetohs0quicee9eb", "mediafile": file2},
format="multipart",
) )
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(Mediafile.objects.count(), 1) self.assertEqual(Mediafile.objects.count(), 1)