added chart projection for polls

This commit is contained in:
Joshua Sangmeister 2020-02-13 18:24:51 +01:00 committed by FinnStutzenstein
parent 6ba0d0c5e6
commit b48ca8c434
41 changed files with 677 additions and 342 deletions

View File

@ -23,7 +23,7 @@ interface ChartEvent {
} }
/** /**
* One single collection in an arry. * One single collection in an array.
*/ */
export interface ChartDate { export interface ChartDate {
data: number[]; data: number[];
@ -213,6 +213,7 @@ export class ChartsComponent extends BaseViewComponent {
/** /**
* Chart option for pie and doughnut * Chart option for pie and doughnut
*/ */
@Input()
public pieChartOptions: ChartOptions = { public pieChartOptions: ChartOptions = {
aspectRatio: 1 aspectRatio: 1
}; };

View File

@ -59,7 +59,7 @@ export class SlideContainerComponent extends BaseComponent {
} }
if (error) { if (error) {
console.log(error); console.error(error);
} }
return; return;
} }

View File

@ -1,8 +1,24 @@
import { inject, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { AssignmentPollService } from 'app/site/assignments/services/assignment-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';
describe('PollPercentBasePipe', () => { fdescribe('PollPercentBasePipe', () => {
it('create an instance', () => { beforeEach(() => {
const pipe = new PollPercentBasePipe(); TestBed.configureTestingModule({
expect(pipe).toBeTruthy(); imports: [E2EImportsModule]
}); });
TestBed.compileComponents();
});
it('create an instance', inject(
[AssignmentPollService, MotionPollService],
(assignmentPollService: AssignmentPollService, motionPollService: MotionPollService) => {
const pipe = new PollPercentBasePipe(assignmentPollService, motionPollService);
expect(pipe).toBeTruthy();
}
));
}); });

View File

@ -1,6 +1,8 @@
import { Pipe, PipeTransform } from '@angular/core'; import { Pipe, PipeTransform } from '@angular/core';
import { ViewBasePoll } from 'app/site/polls/models/view-base-poll'; import { AssignmentPollService } from 'app/site/assignments/services/assignment-poll.service';
import { MotionPollService } from 'app/site/motions/services/motion-poll.service';
import { PollData } from 'app/site/polls/services/poll.service';
/** /**
* Uses a number and a ViewPoll-object. * Uses a number and a ViewPoll-object.
@ -21,8 +23,18 @@ import { ViewBasePoll } from 'app/site/polls/models/view-base-poll';
export class PollPercentBasePipe implements PipeTransform { export class PollPercentBasePipe implements PipeTransform {
private decimalPlaces = 3; private decimalPlaces = 3;
public transform(value: number, viewPoll: ViewBasePoll): string | null { public constructor(
const totalByBase = viewPoll.getPercentBase(); private assignmentPollService: AssignmentPollService,
private motionPollService: MotionPollService
) {}
public transform(value: number, poll: PollData): string | null {
let totalByBase: number;
if ((<any>poll).assignment) {
totalByBase = this.assignmentPollService.getPercentBase(poll);
} else {
totalByBase = this.motionPollService.getPercentBase(poll);
}
if (totalByBase) { if (totalByBase) {
const percentNumber = (value / totalByBase) * 100; const percentNumber = (value / totalByBase) * 100;

View File

@ -14,6 +14,7 @@ import { ViewportService } from 'app/core/ui-services/viewport.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 { PollService } from 'app/site/polls/services/poll.service';
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';
@ -60,10 +61,11 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewA
groupRepo: GroupRepositoryService, groupRepo: GroupRepositoryService,
prompt: PromptService, prompt: PromptService,
pollDialog: AssignmentPollDialogService, pollDialog: AssignmentPollDialogService,
pollService: PollService,
private operator: OperatorService, private operator: OperatorService,
private viewport: ViewportService private viewport: ViewportService
) { ) {
super(title, translate, matSnackbar, repo, route, groupRepo, prompt, pollDialog); super(title, translate, matSnackbar, repo, route, groupRepo, prompt, pollDialog, pollService);
} }
public onPollWithOptionsLoaded(): void { public onPollWithOptionsLoaded(): void {
@ -129,7 +131,7 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewA
this.setVotesData(Object.values(votes)); this.setVotesData(Object.values(votes));
this.candidatesLabels = this.poll.initChartLabels(); this.candidatesLabels = this.pollService.getChartLabels(this.poll);
this.isReady = true; this.isReady = true;
} }
@ -146,7 +148,7 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewA
protected initChartData(): void { protected initChartData(): void {
if (this.isVotedPoll) { if (this.isVotedPoll) {
this._chartType = 'doughnut'; this._chartType = 'doughnut';
this.chartDataSubject.next(this.poll.generateCircleChartData()); this.chartDataSubject.next(this.pollService.generateCircleChartData(this.poll));
} else { } else {
super.initChartData(); super.initChartData();
} }

View File

@ -10,7 +10,6 @@ import { OperatorService } from 'app/core/core-services/operator.service';
import { AssignmentPollRepositoryService } from 'app/core/repositories/assignments/assignment-poll-repository.service'; import { AssignmentPollRepositoryService } from 'app/core/repositories/assignments/assignment-poll-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';
import { AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll';
import { PollState } from 'app/shared/models/poll/base-poll'; import { PollState } from 'app/shared/models/poll/base-poll';
import { BasePollComponent } from 'app/site/polls/components/base-poll.component'; import { BasePollComponent } from 'app/site/polls/components/base-poll.component';
import { PollService } from 'app/site/polls/services/poll.service'; import { PollService } from 'app/site/polls/services/poll.service';
@ -32,11 +31,8 @@ export class AssignmentPollComponent extends BasePollComponent<ViewAssignmentPol
@Input() @Input()
public set poll(value: ViewAssignmentPoll) { public set poll(value: ViewAssignmentPoll) {
this.initPoll(value); this.initPoll(value);
this.candidatesLabels = value.initChartLabels(); this.candidatesLabels = this.pollService.getChartLabels(value);
const chartData = const chartData = this.pollService.generateChartData(value);
value.pollmethod === AssignmentPollMethods.Votes
? value.generateCircleChartData()
: value.generateChartData();
this.chartDataSubject.next(chartData); this.chartDataSubject.next(chartData);
} }
@ -45,7 +41,7 @@ export class AssignmentPollComponent extends BasePollComponent<ViewAssignmentPol
} }
public get chartType(): ChartType { public get chartType(): ChartType {
return this.poll && this.poll.pollmethod === AssignmentPollMethods.Votes ? 'doughnut' : 'horizontalBar'; return this.pollService.getChartType(this.poll);
} }
public candidatesLabels: string[] = []; public candidatesLabels: string[] = [];

View File

@ -1,11 +1,11 @@
import { BehaviorSubject } from 'rxjs'; import { BehaviorSubject } from 'rxjs';
import { ChartData } from 'app/shared/components/charts/charts.component'; import { ChartData } from 'app/shared/components/charts/charts.component';
import { AssignmentPoll, AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll'; import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll';
import { PercentBase, PollColor, PollState } from 'app/shared/models/poll/base-poll'; import { PollState } from 'app/shared/models/poll/base-poll';
import { BaseViewModel } from 'app/site/base/base-view-model'; import { BaseViewModel } from 'app/site/base/base-view-model';
import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable'; import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable';
import { PollData, ViewBasePoll } from 'app/site/polls/models/view-base-poll'; import { PollTableData, ViewBasePoll } from 'app/site/polls/models/view-base-poll';
import { ViewAssignment } from './view-assignment'; import { ViewAssignment } from './view-assignment';
import { ViewAssignmentOption } from './view-assignment-option'; import { ViewAssignmentOption } from './view-assignment-option';
@ -48,33 +48,7 @@ export class ViewAssignmentPoll extends ViewBasePoll<AssignmentPoll> implements
}; };
} }
public initChartLabels(): string[] { public generateTableData(): PollTableData[] {
return this.options.map(candidate => candidate.user.full_name);
}
public generateChartData(): ChartData {
const fields = ['yes', 'no'];
if (this.pollmethod === AssignmentPollMethods.YNA) {
fields.push('abstain');
}
const data: ChartData = fields.map(key => ({
label: key.toUpperCase(),
data: this.options.map(vote => vote[key]),
backgroundColor: PollColor[key],
hoverBackgroundColor: PollColor[key]
}));
return data;
}
public generateCircleChartData(): ChartData {
const data: ChartData = this.options.map(candidate => ({
label: candidate.user.getFullName(),
data: [candidate.yes]
}));
return data;
}
public generateTableData(): PollData[] {
const data = this.options const data = this.options
.map(candidate => ({ .map(candidate => ({
yes: candidate.yes, yes: candidate.yes,
@ -97,43 +71,6 @@ export class ViewAssignmentPoll extends ViewBasePoll<AssignmentPoll> implements
} }
return super.getNextStates(); return super.getNextStates();
} }
private sumOptionsYN(): number {
return this.options.reduce((o, n) => {
o += n.yes > 0 ? n.yes : 0;
o += n.no > 0 ? n.no : 0;
return o;
}, 0);
}
private sumOptionsYNA(): number {
return this.options.reduce((o, n) => {
o += n.abstain > 0 ? n.abstain : 0;
return o;
}, this.sumOptionsYN());
}
public getPercentBase(): number {
const base: PercentBase = this.poll.onehundred_percent_base;
let totalByBase: number;
switch (base) {
case PercentBase.YN:
totalByBase = this.sumOptionsYN();
break;
case PercentBase.YNA:
totalByBase = this.sumOptionsYNA();
break;
case PercentBase.Valid:
totalByBase = this.poll.votesvalid;
break;
case PercentBase.Cast:
totalByBase = this.poll.votescast;
break;
default:
break;
}
return totalByBase;
}
} }
export interface ViewAssignmentPoll extends AssignmentPoll { export interface ViewAssignmentPoll extends AssignmentPoll {

View File

@ -6,7 +6,7 @@ import { HtmlToPdfService } from 'app/core/pdf-services/html-to-pdf.service';
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 { PollPercentBasePipe } from 'app/shared/pipes/poll-percent-base.pipe'; import { PollPercentBasePipe } from 'app/shared/pipes/poll-percent-base.pipe';
import { PollData } from 'app/site/polls/models/view-base-poll'; import { PollTableData } from 'app/site/polls/models/view-base-poll';
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';
@ -215,7 +215,7 @@ export class AssignmentPdfService {
/** /**
* Converts pollData to a printable string representation * Converts pollData to a printable string representation
*/ */
private getPollResult(votingResult: PollData, poll: ViewAssignmentPoll): string { private getPollResult(votingResult: PollTableData, poll: ViewAssignmentPoll): string {
const resultList = poll.pollmethodFields.map(field => { const resultList = poll.pollmethodFields.map(field => {
const votingKey = this.translate.instant(this.pollKeyVerbose.transform(field)); const votingKey = this.translate.instant(this.pollKeyVerbose.transform(field));
const resultValue = this.parsePollNumber.transform(votingResult[field]); const resultValue = this.parsePollNumber.transform(votingResult[field]);

View File

@ -8,7 +8,7 @@ import { ConfigService } from 'app/core/ui-services/config.service';
import { AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll'; import { AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll';
import { Collection } from 'app/shared/models/base/collection'; import { Collection } from 'app/shared/models/base/collection';
import { MajorityMethod, PercentBase } from 'app/shared/models/poll/base-poll'; import { MajorityMethod, PercentBase } from 'app/shared/models/poll/base-poll';
import { PollService } from 'app/site/polls/services/poll.service'; import { PollData, PollService } from 'app/site/polls/services/poll.service';
import { ViewAssignmentPoll } from '../models/view-assignment-poll'; import { ViewAssignmentPoll } from '../models/view-assignment-poll';
@Injectable({ @Injectable({
@ -53,4 +53,41 @@ export class AssignmentPollService extends PollService {
poll.pollmethod = AssignmentPollMethods.YN; poll.pollmethod = AssignmentPollMethods.YN;
poll.assignment_id = poll.assignment_id; poll.assignment_id = poll.assignment_id;
} }
private sumOptionsYN(poll: PollData): number {
return poll.options.reduce((o, n) => {
o += n.yes > 0 ? n.yes : 0;
o += n.no > 0 ? n.no : 0;
return o;
}, 0);
}
private sumOptionsYNA(poll: PollData): number {
return poll.options.reduce((o, n) => {
o += n.abstain > 0 ? n.abstain : 0;
return o;
}, this.sumOptionsYN(poll));
}
public getPercentBase(poll: PollData): number {
const base: PercentBase = poll.onehundred_percent_base;
let totalByBase: number;
switch (base) {
case PercentBase.YN:
totalByBase = this.sumOptionsYN(poll);
break;
case PercentBase.YNA:
totalByBase = this.sumOptionsYNA(poll);
break;
case PercentBase.Valid:
totalByBase = poll.votesvalid;
break;
case PercentBase.Cast:
totalByBase = poll.votescast;
break;
default:
break;
}
return totalByBase;
}
} }

View File

@ -9,19 +9,6 @@ export class ViewMotionOption extends BaseViewModel<MotionOption> {
} }
public static COLLECTIONSTRING = MotionOption.COLLECTIONSTRING; public static COLLECTIONSTRING = MotionOption.COLLECTIONSTRING;
protected _collectionString = MotionOption.COLLECTIONSTRING; protected _collectionString = MotionOption.COLLECTIONSTRING;
public sumYN(): number {
let sum = 0;
sum += this.yes > 0 ? this.yes : 0;
sum += this.no > 0 ? this.no : 0;
return sum;
}
public sumYNA(): number {
let sum = this.sumYN();
sum += this.abstain > 0 ? this.abstain : 0;
return sum;
}
} }
interface TIMotionOptionRelations { interface TIMotionOptionRelations {

View File

@ -1,10 +1,9 @@
import { ChartData } from 'app/shared/components/charts/charts.component'; import { MotionPoll } from 'app/shared/models/motions/motion-poll';
import { MotionPoll, MotionPollMethods } from 'app/shared/models/motions/motion-poll'; import { PollState } from 'app/shared/models/poll/base-poll';
import { PercentBase, PollColor, PollState } from 'app/shared/models/poll/base-poll';
import { BaseViewModel } from 'app/site/base/base-view-model'; import { BaseViewModel } from 'app/site/base/base-view-model';
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 { PollData, ViewBasePoll } from 'app/site/polls/models/view-base-poll'; import { PollTableData, ViewBasePoll } from 'app/site/polls/models/view-base-poll';
import { ViewMotion } from './view-motion'; import { ViewMotion } from './view-motion';
export interface MotionPollTitleInformation { export interface MotionPollTitleInformation {
@ -76,15 +75,11 @@ export class ViewMotionPoll extends ViewBasePoll<MotionPoll> implements MotionPo
return !!this.result.votes.length; return !!this.result.votes.length;
} }
public initChartLabels(): string[] {
return ['Votes'];
}
public getContentObject(): BaseViewModel { public getContentObject(): BaseViewModel {
return this.motion; return this.motion;
} }
public generateTableData(): PollData[] { public generateTableData(): PollTableData[] {
let tableData = this.options.flatMap(vote => let tableData = this.options.flatMap(vote =>
this.tableKeys.map(key => ({ this.tableKeys.map(key => ({
key: key.vote, key: key.vote,
@ -101,21 +96,6 @@ export class ViewMotionPoll extends ViewBasePoll<MotionPoll> implements MotionPo
return tableData; return tableData;
} }
public generateChartData(): ChartData {
const fields = ['yes', 'no'];
if (this.pollmethod === MotionPollMethods.YNA) {
fields.push('abstain');
}
const data: ChartData = fields.map(key => ({
label: key.toUpperCase(),
data: this.options.map(option => option[key]),
backgroundColor: PollColor[key],
hoverBackgroundColor: PollColor[key]
}));
return data;
}
public getSlide(): ProjectorElementBuildDeskriptor { public getSlide(): ProjectorElementBuildDeskriptor {
return { return {
getBasicProjectorElement: options => ({ getBasicProjectorElement: options => ({
@ -146,39 +126,6 @@ export class ViewMotionPoll extends ViewBasePoll<MotionPoll> implements MotionPo
} }
return super.getNextStates(); return super.getNextStates();
} }
public getPercentBase(): number {
const base: PercentBase = this.poll.onehundred_percent_base;
let totalByBase: number;
switch (base) {
case PercentBase.YN:
if (this.result.yes >= 0 && this.result.no >= 0) {
totalByBase = this.result.sumYN();
}
break;
case PercentBase.YNA:
if (this.result.yes >= 0 && this.result.no >= 0 && this.result.abstain >= 0) {
totalByBase = this.result.sumYNA();
}
break;
case PercentBase.Valid:
// auslagern
if (this.result.yes >= 0 && this.result.no >= 0 && this.result.abstain >= 0) {
totalByBase = this.poll.votesvalid;
}
break;
case PercentBase.Cast:
totalByBase = this.poll.votescast;
break;
case PercentBase.Disabled:
break;
default:
throw new Error('The given poll has no percent base: ' + this);
}
return totalByBase;
}
} }
export interface ViewMotionPoll extends MotionPoll { export interface ViewMotionPoll extends MotionPoll {

View File

@ -15,6 +15,7 @@ import { ViewMotion } from 'app/site/motions/models/view-motion';
import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll';
import { MotionPollDialogService } from 'app/site/motions/services/motion-poll-dialog.service'; import { MotionPollDialogService } from 'app/site/motions/services/motion-poll-dialog.service';
import { BasePollDetailComponent } from 'app/site/polls/components/base-poll-detail.component'; import { BasePollDetailComponent } from 'app/site/polls/components/base-poll-detail.component';
import { PollService } from 'app/site/polls/services/poll.service';
@Component({ @Component({
selector: 'os-motion-poll-detail', selector: 'os-motion-poll-detail',
@ -57,10 +58,11 @@ export class MotionPollDetailComponent extends BasePollDetailComponent<ViewMotio
groupRepo: GroupRepositoryService, groupRepo: GroupRepositoryService,
prompt: PromptService, prompt: PromptService,
pollDialog: MotionPollDialogService, pollDialog: MotionPollDialogService,
pollService: PollService,
private operator: OperatorService, private operator: OperatorService,
private router: Router private router: Router
) { ) {
super(title, translate, matSnackbar, repo, route, groupRepo, prompt, pollDialog); super(title, translate, matSnackbar, repo, route, groupRepo, prompt, pollDialog, pollService);
} }
protected onPollWithOptionsLoaded(): void { protected onPollWithOptionsLoaded(): void {

View File

@ -58,19 +58,19 @@
</os-charts> </os-charts>
</div> </div>
<div class="vote-legend"> <div class="vote-legend">
<div class="votes-yes" *ngIf="isVoteDocumented(voteYes)"> <div class="votes-yes" *ngIf="pollService.isVoteDocumented(voteYes)">
<os-icon-container icon="thumb_up" size="large"> <os-icon-container icon="thumb_up" size="large">
{{ voteYes | parsePollNumber }} {{ voteYes | parsePollNumber }}
{{ voteYes | pollPercentBase: poll }} {{ voteYes | pollPercentBase: poll }}
</os-icon-container> </os-icon-container>
</div> </div>
<div class="votes-no" *ngIf="isVoteDocumented(voteNo)"> <div class="votes-no" *ngIf="pollService.isVoteDocumented(voteNo)">
<os-icon-container icon="thumb_down" size="large"> <os-icon-container icon="thumb_down" size="large">
{{ voteNo | parsePollNumber }} {{ voteNo | parsePollNumber }}
{{ voteNo | pollPercentBase: poll }} {{ voteNo | pollPercentBase: poll }}
</os-icon-container> </os-icon-container>
</div> </div>
<div class="votes-abstain" *ngIf="isVoteDocumented(voteAbstain)"> <div class="votes-abstain" *ngIf="pollService.isVoteDocumented(voteAbstain)">
<os-icon-container icon="trip_origin" size="large"> <os-icon-container icon="trip_origin" size="large">
{{ voteAbstain | parsePollNumber }} {{ voteAbstain | parsePollNumber }}
{{ voteAbstain | pollPercentBase: poll }} {{ voteAbstain | pollPercentBase: poll }}

View File

@ -3,12 +3,10 @@ import { MatDialog, MatSnackBar } from '@angular/material';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { BehaviorSubject } from 'rxjs';
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 { PromptService } from 'app/core/ui-services/prompt.service'; import { PromptService } from 'app/core/ui-services/prompt.service';
import { ChartData } from 'app/shared/components/charts/charts.component';
import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll';
import { MotionPollDialogService } from 'app/site/motions/services/motion-poll-dialog.service'; import { MotionPollDialogService } from 'app/site/motions/services/motion-poll-dialog.service';
import { MotionPollPdfService } from 'app/site/motions/services/motion-poll-pdf.service'; import { MotionPollPdfService } from 'app/site/motions/services/motion-poll-pdf.service';
@ -32,7 +30,7 @@ export class MotionPollComponent extends BasePollComponent<ViewMotionPoll> {
public set poll(value: ViewMotionPoll) { public set poll(value: ViewMotionPoll) {
this.initPoll(value); this.initPoll(value);
const chartData = this.poll.generateChartData(); const chartData = this.pollService.generateChartData(value);
for (const data of chartData) { for (const data of chartData) {
if (data.label === 'YES') { if (data.label === 'YES') {
this.voteYes = data.data[0]; this.voteYes = data.data[0];
@ -55,11 +53,6 @@ export class MotionPollComponent extends BasePollComponent<ViewMotionPoll> {
return `/motions/polls/${this.poll.id}`; return `/motions/polls/${this.poll.id}`;
} }
/**
* Subject to holding the data needed for the chart.
*/
public chartDataSubject: BehaviorSubject<ChartData> = new BehaviorSubject([]);
/** /**
* Number of votes for `Yes`. * Number of votes for `Yes`.
*/ */
@ -148,8 +141,4 @@ export class MotionPollComponent extends BasePollComponent<ViewMotionPoll> {
this.repo.delete(this.poll).catch(this.raiseError); this.repo.delete(this.poll).catch(this.raiseError);
} }
} }
public isVoteDocumented(vote: number): boolean {
return vote !== null && vote !== undefined && vote !== -2;
}
} }

View File

@ -9,7 +9,13 @@ import { Collection } from 'app/shared/models/base/collection';
import { MotionPollMethods } from 'app/shared/models/motions/motion-poll'; import { MotionPollMethods } from 'app/shared/models/motions/motion-poll';
import { MajorityMethod, PercentBase } from 'app/shared/models/poll/base-poll'; import { MajorityMethod, PercentBase } from 'app/shared/models/poll/base-poll';
import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll';
import { PollService } from 'app/site/polls/services/poll.service'; import { PollData, PollService } from 'app/site/polls/services/poll.service';
interface PollResultData {
yes?: number;
no?: number;
abstain?: number;
}
/** /**
* Service class for motion polls. * Service class for motion polls.
@ -55,4 +61,51 @@ export class MotionPollService extends PollService {
poll.pollmethod = MotionPollMethods.YNA; poll.pollmethod = MotionPollMethods.YNA;
poll.motion_id = poll.motion_id; poll.motion_id = poll.motion_id;
} }
public getPercentBase(poll: PollData): number {
const base: PercentBase = poll.onehundred_percent_base;
let totalByBase: number;
const result = poll.options[0];
switch (base) {
case PercentBase.YN:
if (result.yes >= 0 && result.no >= 0) {
totalByBase = this.sumYN(result);
}
break;
case PercentBase.YNA:
if (result.yes >= 0 && result.no >= 0 && result.abstain >= 0) {
totalByBase = this.sumYNA(result);
}
break;
case PercentBase.Valid:
// auslagern
if (result.yes >= 0 && result.no >= 0 && result.abstain >= 0) {
totalByBase = poll.votesvalid;
}
break;
case PercentBase.Cast:
totalByBase = poll.votescast;
break;
case PercentBase.Disabled:
break;
default:
throw new Error('The given poll has no percent base: ' + this);
}
return totalByBase;
}
private sumYN(result: PollResultData): number {
let sum = 0;
sum += result.yes > 0 ? result.yes : 0;
sum += result.no > 0 ? result.no : 0;
return sum;
}
private sumYNA(result: PollResultData): number {
let sum = this.sumYN(result);
sum += result.abstain > 0 ? result.abstain : 0;
return sum;
}
} }

View File

@ -17,6 +17,7 @@ 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 { 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 { PollService } from '../services/poll.service';
import { ViewBasePoll } from '../models/view-base-poll'; import { ViewBasePoll } from '../models/view-base-poll';
export interface BaseVoteData { export interface BaseVoteData {
@ -103,7 +104,8 @@ export abstract class BasePollDetailComponent<V extends ViewBasePoll> extends Ba
protected route: ActivatedRoute, protected route: ActivatedRoute,
protected groupRepo: GroupRepositoryService, protected groupRepo: GroupRepositoryService,
protected promptService: PromptService, protected promptService: PromptService,
protected pollDialog: BasePollDialogService<V> protected pollDialog: BasePollDialogService<V>,
protected pollService: PollService
) { ) {
super(title, translate, matSnackbar); super(title, translate, matSnackbar);
} }
@ -174,7 +176,7 @@ export abstract class BasePollDetailComponent<V extends ViewBasePoll> extends Ba
* Could be overwritten to implement custom chart data. * Could be overwritten to implement custom chart data.
*/ */
protected initChartData(): void { protected initChartData(): void {
this.chartDataSubject.next(this.poll.generateChartData()); this.chartDataSubject.next(this.pollService.generateChartData(this.poll));
} }
/** /**

View File

@ -1,4 +1,3 @@
import { ChartData } from 'app/shared/components/charts/charts.component';
import { BasePoll, PollState } from 'app/shared/models/poll/base-poll'; import { BasePoll, PollState } from 'app/shared/models/poll/base-poll';
import { ViewAssignmentOption } from 'app/site/assignments/models/view-assignment-option'; import { ViewAssignmentOption } from 'app/site/assignments/models/view-assignment-option';
import { BaseProjectableViewModel } from 'app/site/base/base-projectable-view-model'; import { BaseProjectableViewModel } from 'app/site/base/base-projectable-view-model';
@ -16,7 +15,7 @@ export enum PollClassType {
/** /**
* Interface describes the possible data for the result-table. * Interface describes the possible data for the result-table.
*/ */
export interface PollData { export interface PollTableData {
key?: string; key?: string;
value?: number; value?: number;
yes?: number; yes?: number;
@ -84,9 +83,9 @@ export const PercentBaseVerbose = {
}; };
export abstract class ViewBasePoll<M extends BasePoll<M, any> = any> extends BaseProjectableViewModel<M> { export abstract class ViewBasePoll<M extends BasePoll<M, any> = any> extends BaseProjectableViewModel<M> {
private _tableData: PollData[] = []; private _tableData: PollTableData[] = [];
public get tableData(): PollData[] { public get tableData(): PollTableData[] {
if (!this._tableData.length) { if (!this._tableData.length) {
this._tableData = this.generateTableData(); this._tableData = this.generateTableData();
} }
@ -154,20 +153,11 @@ export abstract class ViewBasePoll<M extends BasePoll<M, any> = any> extends Bas
public abstract getContentObject(): BaseViewModel; public abstract getContentObject(): BaseViewModel;
/** public abstract generateTableData(): PollTableData[];
* Initializes labels for a chart.
*/
public abstract initChartLabels(): string[];
public abstract generateChartData(): ChartData;
public abstract generateTableData(): PollData[];
public abstract getPercentBase(): number;
} }
export interface ViewBasePoll<M extends BasePoll<M, any> = any> extends BasePoll<M, any> { export interface ViewBasePoll<M extends BasePoll<M, any> = any> extends BasePoll<M, any> {
voted: ViewUser[]; voted: ViewUser[];
groups: ViewGroup[]; groups: ViewGroup[];
options: ViewMotionOption[] | ViewAssignmentOption[]; // TODO find a better solution. but works for the moment options: (ViewMotionOption | ViewAssignmentOption)[]; // TODO find a better solution. but works for the moment
} }

View File

@ -1,8 +1,11 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { _ } from 'app/core/translate/translation-marker'; import { _ } from 'app/core/translate/translation-marker';
import { ChartData, ChartType } from 'app/shared/components/charts/charts.component';
import { AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll';
import { Collection } from 'app/shared/models/base/collection'; import { Collection } from 'app/shared/models/base/collection';
import { MajorityMethod, PercentBase, PollType } from 'app/shared/models/poll/base-poll'; import { MotionPollMethods } from 'app/shared/models/motions/motion-poll';
import { MajorityMethod, PercentBase, PollColor, PollType } from 'app/shared/models/poll/base-poll';
import { AssignmentPollMethodsVerbose } from 'app/site/assignments/models/view-assignment-poll'; import { AssignmentPollMethodsVerbose } from 'app/site/assignments/models/view-assignment-poll';
import { import {
MajorityMethodVerbose, MajorityMethodVerbose,
@ -88,6 +91,22 @@ export const PollMajorityMethod: CalculableMajorityMethod[] = [
} }
]; ];
export interface PollData {
pollmethod?: string;
onehundred_percent_base: PercentBase;
options: {
user?: {
full_name: string;
};
yes?: number;
no?: number;
abstain?: number;
}[];
votesvalid: number;
votesinvalid: number;
votescast: number;
}
interface OpenSlidesSettings { interface OpenSlidesSettings {
ENABLE_ELECTRONIC_VOTING: boolean; ENABLE_ELECTRONIC_VOTING: boolean;
} }
@ -122,10 +141,6 @@ export abstract class PollService {
*/ */
public pollValues: CalculablePollKey[] = ['yes', 'no', 'abstain', 'votesvalid', 'votesinvalid', 'votescast']; public pollValues: CalculablePollKey[] = ['yes', 'no', 'abstain', 'votesvalid', 'votesinvalid', 'votescast'];
/**
* empty constructor
*
*/
public constructor(constants: ConstantsService) { public constructor(constants: ConstantsService) {
constants constants
.get<OpenSlidesSettings>('Settings') .get<OpenSlidesSettings>('Settings')
@ -158,4 +173,52 @@ export abstract class PollService {
public getVerboseNameForKey(key: string): string { public getVerboseNameForKey(key: string): string {
return PollPropertyVerbose[key]; return PollPropertyVerbose[key];
} }
public generateChartData(poll: PollData): ChartData {
if (poll.pollmethod === AssignmentPollMethods.Votes) {
return this.generateCircleChartData(poll);
} else {
return this.generateBarChartData(poll);
}
}
public generateBarChartData(poll: PollData): ChartData {
const fields = ['yes', 'no'];
// cast is needed because ViewBasePoll doesn't have the field `pollmethod`, no easy fix :(
if ((<any>poll).pollmethod === MotionPollMethods.YNA) {
fields.push('abstain');
}
const data: ChartData = fields.map(key => ({
label: key.toUpperCase(),
data: poll.options.map(option => option[key]),
backgroundColor: PollColor[key],
hoverBackgroundColor: PollColor[key]
}));
return data;
}
public generateCircleChartData(poll: PollData): ChartData {
const data: ChartData = poll.options.map(candidate => ({
label: candidate.user.full_name,
data: [candidate.yes]
}));
return data;
}
public getChartType(poll: PollData): ChartType {
if ((<any>poll).pollmethod === AssignmentPollMethods.Votes) {
return 'doughnut';
} else {
return 'horizontalBar';
}
}
public getChartLabels(poll: PollData): string[] {
return poll.options.map(candidate => candidate.user.full_name);
}
public isVoteDocumented(vote: number): boolean {
return vote !== null && vote !== undefined && vote !== -2;
}
} }

View File

@ -1,8 +1,9 @@
import { AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll'; import { AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll';
import { MajorityMethod, PercentBase, PollState, PollType } from 'app/shared/models/poll/base-poll'; import { MajorityMethod, PercentBase, PollState, PollType } from 'app/shared/models/poll/base-poll';
import { AssignmentTitleInformation } from 'app/site/assignments/models/view-assignment'; import { AssignmentTitleInformation } from 'app/site/assignments/models/view-assignment';
import { BasePollSlideData } from 'app/slides/polls/base-poll-slide-data';
export interface AssignmentPollSlideData { export interface AssignmentPollSlideData extends BasePollSlideData {
assignment: AssignmentTitleInformation; assignment: AssignmentTitleInformation;
poll: { poll: {
title: string; title: string;
@ -11,21 +12,23 @@ export interface AssignmentPollSlideData {
votes_amount: number; votes_amount: number;
description: string; description: string;
state: PollState; state: PollState;
onehundered_percent_base: PercentBase; onehundred_percent_base: PercentBase;
majority_method: MajorityMethod; majority_method: MajorityMethod;
options: { options: {
user: string; user: {
yes?: string; full_name: string;
no?: string; };
abstain?: string; yes?: number;
no?: number;
abstain?: number;
}[]; }[];
// optional for published polls: // optional for published polls:
amount_global_no?: string; amount_global_no?: number;
amount_global_abstain: string; amount_global_abstain?: number;
votesvalid: string; votesvalid: number;
votesinvalid: string; votesinvalid: number;
votescast: string; votescast: number;
}; };
} }

View File

@ -1,5 +1,19 @@
<div *ngIf="data"> <ng-container *ngIf="data && data.data">
<div class="slidetitle">
<pre>{{ verboseData }}</pre> <h1 class="assignment-title">{{ data.data.assignment.title }}</h1>
<h2 class="poll-title">{{ data.data.poll.title }}</h2>
</div> </div>
<div class="charts-wrapper" *ngIf="data.data.poll.state === PollState.Published">
<os-charts
[type]="pollService.getChartType(data.data.poll)"
[labels]="pollService.getChartLabels(data.data.poll)"
[data]="chartDataSubject"
[hasPadding]="false"
[pieChartOptions]="options"
></os-charts>
</div>
<div *ngIf="data.data.poll.state !== PollState.Published">
<!-- TODO -->
{{ "Nothing to see here!" | translate }}
</div>
</ng-container>

View File

@ -0,0 +1,11 @@
.assignment-title {
margin: 0 0 10px;
}
.slidetitle {
margin-bottom: 15px;
}
.charts-wrapper {
position: relative;
}

View File

@ -1,6 +1,7 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { BaseSlideComponent } from 'app/slides/base-slide-component'; import { PollState } from 'app/shared/models/poll/base-poll';
import { BasePollSlideComponent } from 'app/slides/polls/base-poll-slide.component';
import { AssignmentPollSlideData } from './assignment-poll-slide-data'; import { AssignmentPollSlideData } from './assignment-poll-slide-data';
@Component({ @Component({
@ -8,8 +9,8 @@ import { AssignmentPollSlideData } from './assignment-poll-slide-data';
templateUrl: './assignment-poll-slide.component.html', templateUrl: './assignment-poll-slide.component.html',
styleUrls: ['./assignment-poll-slide.component.scss'] styleUrls: ['./assignment-poll-slide.component.scss']
}) })
export class AssignmentPollSlideComponent extends BaseSlideComponent<AssignmentPollSlideData> { export class AssignmentPollSlideComponent extends BasePollSlideComponent<AssignmentPollSlideData> {
public get verboseData(): string { public PollState = PollState;
return JSON.stringify(this.data, null, 2);
} public options = { maintainAspectRatio: false, responsive: true, legend: { position: 'right' } };
} }

View File

@ -1,26 +1,27 @@
import { MotionPollMethods } from 'app/shared/models/motions/motion-poll'; import { MotionPollMethods } from 'app/shared/models/motions/motion-poll';
import { MajorityMethod, PercentBase, PollState, PollType } from 'app/shared/models/poll/base-poll'; import { MajorityMethod, PercentBase, PollState, PollType } from 'app/shared/models/poll/base-poll';
import { MotionTitleInformation } from 'app/site/motions/models/view-motion'; import { MotionTitleInformation } from 'app/site/motions/models/view-motion';
import { BasePollSlideData } from 'app/slides/polls/base-poll-slide-data';
export interface MotionPollSlideData { export interface MotionPollSlideData extends BasePollSlideData {
motion: MotionTitleInformation; motion: MotionTitleInformation;
poll: { poll: {
title: string; title: string;
type: PollType; type: PollType;
pollmethod: MotionPollMethods; pollmethod: MotionPollMethods;
state: PollState; state: PollState;
onehundered_percent_base: PercentBase; onehundred_percent_base: PercentBase;
majority_method: MajorityMethod; majority_method: MajorityMethod;
options: { options: {
yes?: string; yes?: number;
no?: string; no?: number;
abstain?: string; abstain?: number;
}[]; }[];
// optional for published polls: // optional for published polls:
votesvalid: string; votesvalid: number;
votesinvalid: string; votesinvalid: number;
votescast: string; votescast: number;
}; };
} }

View File

@ -1,5 +1,45 @@
<div *ngIf="data"> <ng-container *ngIf="data && data.data">
<div class="slidetitle">
<pre>{{ verboseData }}</pre> <h1 class="motion-title">
<span *ngIf="data.data.motion.identifier">{{ data.data.motion.identifier }}:</span>
</div> {{ data.data.motion.title }}
</h1>
<h2 class="poll-title">{{ data.data.poll.title }}</h2>
</div>
<div class="poll-chart-wrapper" *ngIf="data.data.poll.state === PollState.Published">
<div class="doughnut-chart">
<os-charts
[type]="'doughnut'"
[data]="chartDataSubject"
[pieChartOptions]="{ maintainAspectRatio: false, responsive: true }"
[showLegend]="false"
[hasPadding]="false"
>
</os-charts>
</div>
<div class="vote-legend">
<div class="votes-yes" *ngIf="pollService.isVoteDocumented(voteYes)">
<os-icon-container icon="thumb_up" size="large">
{{ voteYes | parsePollNumber }}
{{ voteYes | pollPercentBase: data.data.poll }}
</os-icon-container>
</div>
<div class="votes-no" *ngIf="pollService.isVoteDocumented(voteNo)">
<os-icon-container icon="thumb_down" size="large">
{{ voteNo | parsePollNumber }}
{{ voteNo | pollPercentBase: data.data.poll }}
</os-icon-container>
</div>
<div class="votes-abstain" *ngIf="pollService.isVoteDocumented(voteAbstain)">
<os-icon-container icon="trip_origin" size="large">
{{ voteAbstain | parsePollNumber }}
{{ voteAbstain | pollPercentBase: data.data.poll }}
</os-icon-container>
</div>
</div>
</div>
<div *ngIf="data.data.poll.state !== PollState.Published">
<!-- TODO -->
{{ "Nothing to see here!" | translate }}
</div>
</ng-container>

View File

@ -0,0 +1,40 @@
@import '~assets/styles/poll-colors.scss';
.motion-title {
margin: 0 0 10px;
}
.poll-chart-wrapper {
display: grid;
grid-gap: 10px;
grid-template-areas: 'chart legend';
grid-template-columns: min-content auto;
.doughnut-chart {
grid-area: chart;
margin-top: auto;
margin-bottom: auto;
}
.vote-legend {
grid-area: legend;
margin-top: auto;
margin-bottom: auto;
div + div {
margin-top: 10px;
}
.votes-yes {
color: $votes-yes-color;
}
.votes-no {
color: $votes-no-color;
}
.votes-abstain {
color: $votes-abstain-color;
}
}
}

View File

@ -1,6 +1,8 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { BaseSlideComponent } from 'app/slides/base-slide-component'; import { PollState } from 'app/shared/models/poll/base-poll';
import { PollService } from 'app/site/polls/services/poll.service';
import { BasePollSlideComponent } from 'app/slides/polls/base-poll-slide.component';
import { MotionPollSlideData } from './motion-poll-slide-data'; import { MotionPollSlideData } from './motion-poll-slide-data';
@Component({ @Component({
@ -8,8 +10,22 @@ import { MotionPollSlideData } from './motion-poll-slide-data';
templateUrl: './motion-poll-slide.component.html', templateUrl: './motion-poll-slide.component.html',
styleUrls: ['./motion-poll-slide.component.scss'] styleUrls: ['./motion-poll-slide.component.scss']
}) })
export class MotionPollSlideComponent extends BaseSlideComponent<MotionPollSlideData> { export class MotionPollSlideComponent extends BasePollSlideComponent<MotionPollSlideData> {
public get verboseData(): string { public PollState = PollState;
return JSON.stringify(this.data, null, 2);
public voteYes: number;
public voteNo: number;
public voteAbstain: number;
public constructor(pollService: PollService) {
super(pollService);
this.chartDataSubject.subscribe(() => {
if (this.data && this.data.data) {
const result = this.data.data.poll.options[0];
this.voteYes = result.yes;
this.voteNo = result.no;
this.voteAbstain = result.abstain;
}
});
} }
} }

View File

@ -0,0 +1,22 @@
import { MajorityMethod, PercentBase, PollState, PollType } from 'app/shared/models/poll/base-poll';
export interface BasePollSlideData {
poll: {
title: string;
type: PollType;
state: PollState;
onehundred_percent_base: PercentBase;
majority_method: MajorityMethod;
pollmethod: string;
options: {
yes?: number;
no?: number;
abstain?: number;
}[];
votesvalid: number;
votesinvalid: number;
votescast: number;
};
}

View File

@ -0,0 +1,36 @@
import { forwardRef, Inject, Input } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { SlideData } from 'app/core/core-services/projector-data.service';
import { ChartData } from 'app/shared/components/charts/charts.component';
import { PollState } from 'app/shared/models/poll/base-poll';
import { PollService } from 'app/site/polls/services/poll.service';
import { BasePollSlideData } from './base-poll-slide-data';
import { BaseSlideComponent } from '../base-slide-component';
export class BasePollSlideComponent<T extends BasePollSlideData> extends BaseSlideComponent<T> {
public chartDataSubject: BehaviorSubject<ChartData> = new BehaviorSubject([]);
@Input()
public set data(value: SlideData<T>) {
this._data = value;
if (value.data.poll.state === PollState.Published) {
const chartData = this.pollService.generateChartData(value.data.poll);
this.chartDataSubject.next(chartData);
}
}
public get data(): SlideData<T> {
return this._data;
}
private _data: SlideData<T>;
public constructor(
@Inject(forwardRef(() => PollService))
public pollService: PollService
) {
super();
}
}

View File

@ -87,6 +87,7 @@ class AssignmentManager(BaseManager):
"tags", "tags",
"attachments", "attachments",
"polls", "polls",
"polls__options",
) )
) )
@ -274,8 +275,27 @@ class AssignmentVote(RESTModelMixin, BaseVote):
default_permissions = () default_permissions = ()
class AssignmentOptionManager(BaseManager):
"""
Customized model manager to support our get_prefetched_queryset method.
"""
def get_prefetched_queryset(self, *args, **kwargs):
"""
Returns the normal queryset with all voted users. In the background we
join and prefetch all related models.
"""
return (
super()
.get_prefetched_queryset(*args, **kwargs)
.select_related("user", "poll")
.prefetch_related("voted", "votes")
)
class AssignmentOption(RESTModelMixin, BaseOption): class AssignmentOption(RESTModelMixin, BaseOption):
access_permissions = AssignmentOptionAccessPermissions() access_permissions = AssignmentOptionAccessPermissions()
objects = AssignmentOptionManager()
vote_class = AssignmentVote vote_class = AssignmentVote
poll = models.ForeignKey( poll = models.ForeignKey(
@ -307,7 +327,9 @@ class AssignmentPollManager(BaseManager):
super() super()
.get_prefetched_queryset(*args, **kwargs) .get_prefetched_queryset(*args, **kwargs)
.select_related("assignment") .select_related("assignment")
.prefetch_related("options", "options__user", "options__votes", "groups") .prefetch_related(
"options", "options__user", "options__votes", "options__voted", "groups"
)
) )
@ -379,15 +401,16 @@ class AssignmentPoll(RESTModelMixin, BasePoll):
abstain_sum += option.abstain abstain_sum += option.abstain
return abstain_sum return abstain_sum
def create_options(self): def create_options(self, skip_autoupdate=False):
related_users = AssignmentRelatedUser.objects.filter( related_users = AssignmentRelatedUser.objects.filter(
assignment__id=self.assignment.id assignment__id=self.assignment.id
).exclude(elected=True) ).exclude(elected=True)
for related_user in related_users: for related_user in related_users:
AssignmentOption.objects.create( option = AssignmentOption(
user=related_user.user, weight=related_user.weight, poll=self user=related_user.user, weight=related_user.weight, poll=self
) )
option.save(skip_autoupdate=skip_autoupdate)
# Add all candidates to list of speakers of related agenda item # Add all candidates to list of speakers of related agenda item
if config["assignment_poll_add_candidates_to_list_of_speakers"]: if config["assignment_poll_add_candidates_to_list_of_speakers"]:
@ -401,4 +424,5 @@ class AssignmentPoll(RESTModelMixin, BasePoll):
except OpenSlidesError: except OpenSlidesError:
# The Speaker is already on the list. Do nothing. # The Speaker is already on the list. Do nothing.
pass pass
if not skip_autoupdate:
inform_changed_data(self.assignment.list_of_speakers) inform_changed_data(self.assignment.list_of_speakers)

View File

@ -1,7 +1,7 @@
from typing import Any, Dict, List from typing import Any, Dict, List
from ..users.projector import get_user_name from ..users.projector import get_user_name
from ..utils.projector import AllData, get_model, register_projector_slide from ..utils.projector import AllData, get_model, get_models, register_projector_slide
from .models import AssignmentPoll from .models import AssignmentPoll
@ -62,20 +62,27 @@ async def assignment_poll_slide(
# Add options: # Add options:
poll_data["options"] = [] poll_data["options"] = []
for option in sorted(poll["options"], key=lambda option: option["weight"]): options = get_models(all_data, "assignments/assignment-option", poll["options_id"])
option_data = {"user": await get_user_name(all_data, option["user_id"])} for option in sorted(options, key=lambda option: option["weight"]):
option_data: Dict[str, Any] = {
"user": {"full_name": await get_user_name(all_data, option["user_id"])}
}
if poll["state"] == AssignmentPoll.STATE_PUBLISHED: if poll["state"] == AssignmentPoll.STATE_PUBLISHED:
option_data["yes"] = option["yes"] option_data["yes"] = float(option["yes"])
option_data["no"] = option["no"] option_data["no"] = float(option["no"])
option_data["abstain"] = option["abstain"] option_data["abstain"] = float(option["abstain"])
poll_data["options"].append(option_data) poll_data["options"].append(option_data)
if poll["state"] == AssignmentPoll.STATE_PUBLISHED: if poll["state"] == AssignmentPoll.STATE_PUBLISHED:
poll_data["amount_global_no"] = poll["amount_global_no"] poll_data["amount_global_no"] = (
poll_data["amount_global_abstain"] = poll["amount_global_abstain"] float(poll["amount_global_no"]) if poll["amount_global_no"] else None
poll_data["votesvalid"] = poll["votesvalid"] )
poll_data["votesinvalid"] = poll["votesinvalid"] poll_data["amount_global_abstain"] = (
poll_data["votescast"] = poll["votescast"] float(poll["amount_global_abstain"]) if poll["amount_global_no"] else None
)
poll_data["votesvalid"] = float(poll["votesvalid"])
poll_data["votesinvalid"] = float(poll["votesinvalid"])
poll_data["votescast"] = float(poll["votescast"])
return { return {
"assignment": {"title": assignment["title"]}, "assignment": {"title": assignment["title"]},

View File

@ -380,7 +380,7 @@ class AssignmentPollViewSet(BasePollViewSet):
vote_obj.save() vote_obj.save()
poll.save() poll.save()
def validate_vote_data(self, data, poll): def validate_vote_data(self, data, poll, user):
""" """
Request data: Request data:
analog: analog:
@ -511,6 +511,9 @@ class AssignmentPollViewSet(BasePollViewSet):
options_data = data options_data = data
db_option_ids = set(option.id for option in poll.get_options())
data_option_ids = set(int(option_id) for option_id in options_data.keys())
# Just for named/pseudoanonymous with YN/YNA skip the all-options-given check # Just for named/pseudoanonymous with YN/YNA skip the all-options-given check
if poll.type not in ( if poll.type not in (
AssignmentPoll.TYPE_NAMED, AssignmentPoll.TYPE_NAMED,
@ -520,12 +523,20 @@ class AssignmentPollViewSet(BasePollViewSet):
AssignmentPoll.POLLMETHOD_YNA, AssignmentPoll.POLLMETHOD_YNA,
): ):
# Check if all options were given # Check if all options were given
db_option_ids = set(option.id for option in poll.get_options())
data_option_ids = set(int(option_id) for option_id in options_data.keys())
if data_option_ids != db_option_ids: if data_option_ids != db_option_ids:
raise ValidationError( raise ValidationError(
{"error": "You have to provide values for all options"} {"error": "You have to provide values for all options"}
) )
else:
if not data_option_ids.issubset(db_option_ids):
raise ValidationError(
{
"error": "You gave the following invalid option ids: "
+ ", ".join(
str(id) for id in data_option_ids.difference(db_option_ids)
)
}
)
def create_votes_type_votes(self, data, poll, user): def create_votes_type_votes(self, data, poll, user):
""" """

View File

@ -882,8 +882,27 @@ class MotionVote(RESTModelMixin, BaseVote):
default_permissions = () default_permissions = ()
class MotionOptionManager(BaseManager):
"""
Customized model manager to support our get_prefetched_queryset method.
"""
def get_prefetched_queryset(self, *args, **kwargs):
"""
Returns the normal queryset with all voted users. In the background we
join and prefetch all related models.
"""
return (
super()
.get_prefetched_queryset(*args, **kwargs)
.select_related("poll")
.prefetch_related("voted", "votes")
)
class MotionOption(RESTModelMixin, BaseOption): class MotionOption(RESTModelMixin, BaseOption):
access_permissions = MotionOptionAccessPermissions() access_permissions = MotionOptionAccessPermissions()
objects = MotionOptionManager()
vote_class = MotionVote vote_class = MotionVote
poll = models.ForeignKey( poll = models.ForeignKey(
@ -911,7 +930,7 @@ class MotionPollManager(BaseManager):
super() super()
.get_prefetched_queryset(*args, **kwargs) .get_prefetched_queryset(*args, **kwargs)
.select_related("motion") .select_related("motion")
.prefetch_related("options", "options__votes", "groups") .prefetch_related("options", "options__votes", "options__voted", "groups")
) )

View File

@ -363,7 +363,16 @@ async def motion_poll_slide(
} }
if poll["state"] == MotionPoll.STATE_PUBLISHED: if poll["state"] == MotionPoll.STATE_PUBLISHED:
poll_data["options"] = poll["options"] option = get_model(
all_data, "motions/motion-option", poll["options_id"][0]
) # there can only be exactly one option
poll_data["options"] = [
{
"yes": float(option["yes"]),
"no": float(option["no"]),
"abstain": float(option["abstain"]),
}
]
poll_data["votesvalid"] = poll["votesvalid"] poll_data["votesvalid"] = poll["votesvalid"]
poll_data["votesinvalid"] = poll["votesinvalid"] poll_data["votesinvalid"] = poll["votesinvalid"]
poll_data["votescast"] = poll["votescast"] poll_data["votescast"] = poll["votescast"]

View File

@ -1196,7 +1196,7 @@ class MotionPollViewSet(BasePollViewSet):
poll.save() poll.save()
def validate_vote_data(self, data, poll): def validate_vote_data(self, data, poll, user):
""" """
Request data for analog: Request data for analog:
{ "Y": <amount>, "N": <amount>, ["A": <amount>], { "Y": <amount>, "N": <amount>, ["A": <amount>],
@ -1223,23 +1223,28 @@ class MotionPollViewSet(BasePollViewSet):
elif poll.pollmethod == MotionPoll.POLLMETHOD_YN and data not in ("Y", "N"): elif poll.pollmethod == MotionPoll.POLLMETHOD_YN and data not in ("Y", "N"):
raise ValidationError("Data must be Y or N") raise ValidationError("Data must be Y or N")
if poll.type == MotionPoll.TYPE_PSEUDOANONYMOUS:
if user in poll.options.get().voted.all():
raise ValidationError("You already voted on this poll")
def handle_named_vote(self, data, poll, user): def handle_named_vote(self, data, poll, user):
option = poll.options.get() self.handle_named_or_pseudoanonymous_vote(data, poll, user, False)
vote, _ = MotionVote.objects.get_or_create(user=user, option=option)
self.set_vote_data(data, vote, poll)
inform_changed_data(option)
def handle_pseudoanonymous_vote(self, data, poll): def handle_pseudoanonymous_vote(self, data, poll, user):
option = poll.options.get() self.handle_named_or_pseudoanonymous_vote(data, poll, user, True)
vote = MotionVote.objects.create(option=option)
self.set_vote_data(data, vote, poll)
inform_changed_data(option)
def set_vote_data(self, data, vote, poll): def handle_named_or_pseudoanonymous_vote(self, data, poll, user, pseudoanonymous):
option = poll.options.get()
vote, _ = MotionVote.objects.get_or_create(
user=None if pseudoanonymous else user, option=option
)
vote.value = data vote.value = data
vote.weight = Decimal("1") vote.weight = Decimal("1")
vote.save(no_delete_on_restriction=True) vote.save(no_delete_on_restriction=True)
option.voted.add(user)
option.save()
class MotionOptionViewSet(BaseOptionViewSet): class MotionOptionViewSet(BaseOptionViewSet):
queryset = MotionOption.objects.all() queryset = MotionOption.objects.all()

View File

@ -222,19 +222,21 @@ class BasePoll(models.Model):
votescast = property(get_votescast, set_votescast) votescast = property(get_votescast, set_votescast)
def get_user_ids_with_valid_votes(self): def get_user_ids_with_valid_votes(self):
initial_option = self.get_options().first() if self.get_options().count():
initial_option = self.get_options()[0]
user_ids = set(map(lambda u: u.id, initial_option.voted.all())) user_ids = set(map(lambda u: u.id, initial_option.voted.all()))
for option in self.get_options(): for option in self.get_options():
user_ids = user_ids.intersection( user_ids = user_ids.intersection(
set(map(lambda u: u.id, option.voted.all())) set(map(lambda u: u.id, option.voted.all()))
) )
return list(user_ids) return list(user_ids)
else:
return []
def get_all_voted_user_ids(self): def get_all_voted_user_ids(self):
# TODO: This might be faster with only one DB query using distinct.
user_ids: Set[int] = set() user_ids: Set[int] = set()
for option in self.get_options(): for option in self.get_options():
user_ids.update(option.voted.all().values_list("pk", flat=True)) user_ids.update(map(lambda u: u.id, option.voted.all()))
return list(user_ids) return list(user_ids)
def amount_valid_votes(self): def amount_valid_votes(self):
@ -262,7 +264,7 @@ class BasePoll(models.Model):
""" """
Returns the option objects for the poll. Returns the option objects for the poll.
""" """
return self.get_option_class().objects.filter(poll=self) return self.options.all()
@classmethod @classmethod
def get_vote_class(cls): def get_vote_class(cls):

View File

@ -103,7 +103,7 @@ class BasePollViewSet(ModelViewSet):
# convert user ids to option ids # convert user ids to option ids
self.convert_option_data(poll, vote_data) self.convert_option_data(poll, vote_data)
self.validate_vote_data(vote_data, poll) self.validate_vote_data(vote_data, poll, request.user)
self.handle_analog_vote(vote_data, poll, request.user) self.handle_analog_vote(vote_data, poll, request.user)
if request.data.get("publish_immediately"): if request.data.get("publish_immediately"):
@ -198,7 +198,7 @@ class BasePollViewSet(ModelViewSet):
self.assert_can_vote(poll, request) self.assert_can_vote(poll, request)
data = request.data data = request.data
self.validate_vote_data(data, poll) self.validate_vote_data(data, poll, request.user)
if poll.type == BasePoll.TYPE_ANALOG: if poll.type == BasePoll.TYPE_ANALOG:
self.handle_analog_vote(data, poll, request.user) self.handle_analog_vote(data, poll, request.user)
@ -258,7 +258,7 @@ class BasePollViewSet(ModelViewSet):
""" """
pass pass
def validate_vote_data(self, data, poll): def validate_vote_data(self, data, poll, user):
""" """
To be implemented by subclass. Validates the data according to poll type and method and fields by validated versions. To be implemented by subclass. Validates the data according to poll type and method and fields by validated versions.
Raises ValidationError on failure Raises ValidationError on failure

View File

@ -115,3 +115,12 @@ def get_model(all_data: AllData, collection: str, id: Any) -> Dict[str, Any]:
except KeyError: except KeyError:
raise ProjectorElementException(f"{collection} with id {id} does not exist") raise ProjectorElementException(f"{collection} with id {id} does not exist")
return model return model
def get_models(
all_data: AllData, collection: str, ids: List[Any]
) -> List[Dict[str, Any]]:
"""
Tries to fetch all given models. Models are required to be all of the collection `collection`.
"""
return [get_model(all_data, collection, id) for id in ids]

View File

@ -51,19 +51,23 @@ def test_assignment_vote_db_queries():
@pytest.mark.django_db(transaction=False) @pytest.mark.django_db(transaction=False)
def test_assignment_option_db_queries(): def test_assignment_option_db_queries():
""" """
Tests that only 1 query is done when fetching AssignmentOptions Tests that only the following db queries are done:
* 1 request to get the options,
* 1 request to get all users that voted on the options,
* 1 request to get all votes for all options,
= 3 queries
""" """
create_assignment_polls() create_assignment_polls()
assert count_queries(AssignmentOption.get_elements)() == 1 assert count_queries(AssignmentOption.get_elements)() == 3
def create_assignment_polls(): def create_assignment_polls():
""" """
Creates 1 assignment with 3 candidates which has 5 polls in which each candidate got a random amount of votes between 0 and 10 from 3 users Creates 1 assignment with 3 candidates which has 5 polls in which each candidate got a random amount of votes between 0 and 10 from 3 users
""" """
assignment = Assignment.objects.create( assignment = Assignment(title="test_assignment_ohneivoh9caiB8Yiungo", open_posts=1)
title="test_assignment_ohneivoh9caiB8Yiungo", open_posts=1 assignment.save(skip_autoupdate=True)
)
group1 = get_group_model().objects.get(pk=1) group1 = get_group_model().objects.get(pk=1)
group2 = get_group_model().objects.get(pk=2) group2 = get_group_model().objects.get(pk=2)
for i in range(3): for i in range(3):
@ -73,13 +77,14 @@ def create_assignment_polls():
assignment.add_candidate(user) assignment.add_candidate(user)
for i in range(5): for i in range(5):
poll = AssignmentPoll.objects.create( poll = AssignmentPoll(
assignment=assignment, assignment=assignment,
title="test_title_UnMiGzEHmwqplmVBPNEZ", title="test_title_UnMiGzEHmwqplmVBPNEZ",
pollmethod=AssignmentPoll.POLLMETHOD_YN, pollmethod=AssignmentPoll.POLLMETHOD_YN,
type=AssignmentPoll.TYPE_NAMED, type=AssignmentPoll.TYPE_NAMED,
) )
poll.create_options() poll.save(skip_autoupdate=True)
poll.create_options(skip_autoupdate=True)
poll.groups.add(group1) poll.groups.add(group1)
poll.groups.add(group2) poll.groups.add(group2)
@ -94,7 +99,7 @@ def create_assignment_polls():
AssignmentVote.objects.create( AssignmentVote.objects.create(
user=user, option=option, value="Y", weight=Decimal(weight) user=user, option=option, value="Y", weight=Decimal(weight)
) )
poll.voted.add(user) option.voted.add(user)
class CreateAssignmentPoll(TestCase): class CreateAssignmentPoll(TestCase):
@ -105,7 +110,7 @@ class CreateAssignmentPoll(TestCase):
self.assignment.add_candidate(self.admin) self.assignment.add_candidate(self.admin)
def test_simple(self): def test_simple(self):
with self.assertNumQueries(41): with self.assertNumQueries(50):
response = self.client.post( response = self.client.post(
reverse("assignmentpoll-list"), reverse("assignmentpoll-list"),
{ {
@ -1018,7 +1023,7 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass):
self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST)
self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists())
def test_too_few_options(self): def test_partial_vote(self):
self.add_candidate() self.add_candidate()
self.start_poll() self.start_poll()
response = self.client.post( response = self.client.post(
@ -1026,8 +1031,8 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass):
{"1": "Y"}, {"1": "Y"},
format="json", format="json",
) )
self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) self.assertTrue(AssignmentPoll.objects.get().get_votes().exists())
def test_wrong_options(self): def test_wrong_options(self):
self.add_candidate() self.add_candidate()
@ -1082,7 +1087,9 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass):
def test_missing_data(self): def test_missing_data(self):
self.start_poll() self.start_poll()
response = self.client.post(reverse("assignmentpoll-vote", args=[self.poll.pk])) response = self.client.post(reverse("assignmentpoll-vote", args=[self.poll.pk]))
self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertHttpStatusVerbose(
response, status.HTTP_200_OK
) # new "feature" because of partial requests: empty requests work!
self.assertFalse(AssignmentVote.objects.exists()) self.assertFalse(AssignmentVote.objects.exists())
def test_wrong_data_format(self): def test_wrong_data_format(self):
@ -1469,7 +1476,7 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass):
{"1": "N"}, {"1": "N"},
format="json", format="json",
) )
self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST)
poll = AssignmentPoll.objects.get() poll = AssignmentPoll.objects.get()
option1 = poll.options.get(pk=1) option1 = poll.options.get(pk=1)
self.assertEqual(option1.yes, Decimal("1")) self.assertEqual(option1.yes, Decimal("1"))
@ -1486,7 +1493,7 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass):
self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST)
self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists())
def test_too_few_options(self): def test_partial_vote(self):
self.add_candidate() self.add_candidate()
self.start_poll() self.start_poll()
response = self.client.post( response = self.client.post(
@ -1494,8 +1501,8 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass):
{"1": "Y"}, {"1": "Y"},
format="json", format="json",
) )
self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) self.assertTrue(AssignmentPoll.objects.get().get_votes().exists())
def test_wrong_options(self): def test_wrong_options(self):
self.add_candidate() self.add_candidate()
@ -1550,7 +1557,9 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass):
def test_missing_data(self): def test_missing_data(self):
self.start_poll() self.start_poll()
response = self.client.post(reverse("assignmentpoll-vote", args=[self.poll.pk])) response = self.client.post(reverse("assignmentpoll-vote", args=[self.poll.pk]))
self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertHttpStatusVerbose(
response, status.HTTP_200_OK
) # new "feature" because of partial requests: empty requests work!
self.assertFalse(AssignmentVote.objects.exists()) self.assertFalse(AssignmentVote.objects.exists())
def test_wrong_data_format(self): def test_wrong_data_format(self):
@ -1658,7 +1667,7 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass):
{"1": 0, "2": 1}, {"1": 0, "2": 1},
format="json", format="json",
) )
self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST)
poll = AssignmentPoll.objects.get() poll = AssignmentPoll.objects.get()
option1 = poll.options.get(pk=1) option1 = poll.options.get(pk=1)
option2 = poll.options.get(pk=2) option2 = poll.options.get(pk=2)
@ -1896,13 +1905,23 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass)
"type": AssignmentPoll.TYPE_NAMED, "type": AssignmentPoll.TYPE_NAMED,
"onehundred_percent_base": AssignmentPoll.PERCENT_BASE_CAST, "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_CAST,
"majority_method": AssignmentPoll.MAJORITY_TWO_THIRDS, "majority_method": AssignmentPoll.MAJORITY_TWO_THIRDS,
"user_has_voted": False,
"voted_id": [self.user.id],
"votes_amount": 1, "votes_amount": 1,
"votescast": "1.000000", "votescast": "1.000000",
"votesinvalid": "0.000000", "votesinvalid": "0.000000",
"votesvalid": "1.000000", "votesvalid": "1.000000",
}, },
"assignments/assignment-option:1": {
"abstain": "1.000000",
"id": 1,
"no": "0.000000",
"poll_id": 1,
"pollstate": AssignmentPoll.STATE_STARTED,
"yes": "0.000000",
"user_id": 1,
"weight": 1,
"user_has_voted": False,
"voted_id": [self.user.id],
},
"assignments/assignment-vote:1": { "assignments/assignment-vote:1": {
"id": 1, "id": 1,
"option_id": 1, "option_id": 1,
@ -1951,7 +1970,6 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass)
"groups_id": [GROUP_DELEGATE_PK], "groups_id": [GROUP_DELEGATE_PK],
"options_id": [1], "options_id": [1],
"id": 1, "id": 1,
"user_has_voted": user == self.user,
"votes_amount": 1, "votes_amount": 1,
}, },
) )
@ -1966,7 +1984,7 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass)
vote.value = "A" vote.value = "A"
vote.weight = Decimal("1") vote.weight = Decimal("1")
vote.save(no_delete_on_restriction=True, skip_autoupdate=True) vote.save(no_delete_on_restriction=True, skip_autoupdate=True)
self.poll.voted.add(self.user.id) option.voted.add(self.user.id)
self.poll.state = AssignmentPoll.STATE_FINISHED self.poll.state = AssignmentPoll.STATE_FINISHED
self.poll.save(skip_autoupdate=True) self.poll.save(skip_autoupdate=True)
response = self.client.post( response = self.client.post(
@ -2004,8 +2022,6 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass)
"state": 4, "state": 4,
"title": self.poll.title, "title": self.poll.title,
"type": "named", "type": "named",
"user_has_voted": user == self.user,
"voted_id": [self.user.id],
"votes_amount": 1, "votes_amount": 1,
"votescast": "1.000000", "votescast": "1.000000",
"votesinvalid": "0.000000", "votesinvalid": "0.000000",
@ -2028,6 +2044,8 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass)
"yes": "0.000000", "yes": "0.000000",
"user_id": 1, "user_id": 1,
"weight": 1, "weight": 1,
"user_has_voted": user == self.user,
"voted_id": [self.user.id],
}, },
}, },
) )
@ -2067,13 +2085,23 @@ class VoteAssignmentPollPseudoanonymousAutoupdates(
"type": AssignmentPoll.TYPE_PSEUDOANONYMOUS, "type": AssignmentPoll.TYPE_PSEUDOANONYMOUS,
"onehundred_percent_base": AssignmentPoll.PERCENT_BASE_CAST, "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_CAST,
"majority_method": AssignmentPoll.MAJORITY_TWO_THIRDS, "majority_method": AssignmentPoll.MAJORITY_TWO_THIRDS,
"user_has_voted": False,
"voted_id": [self.user.id],
"votes_amount": 1, "votes_amount": 1,
"votescast": "1.000000", "votescast": "1.000000",
"votesinvalid": "0.000000", "votesinvalid": "0.000000",
"votesvalid": "1.000000", "votesvalid": "1.000000",
}, },
"assignments/assignment-option:1": {
"abstain": "1.000000",
"id": 1,
"no": "0.000000",
"poll_id": 1,
"pollstate": AssignmentPoll.STATE_STARTED,
"yes": "0.000000",
"user_id": 1,
"weight": 1,
"user_has_voted": False,
"voted_id": [self.user.id],
},
"assignments/assignment-vote:1": { "assignments/assignment-vote:1": {
"id": 1, "id": 1,
"option_id": 1, "option_id": 1,
@ -2108,7 +2136,6 @@ class VoteAssignmentPollPseudoanonymousAutoupdates(
"groups_id": [GROUP_DELEGATE_PK], "groups_id": [GROUP_DELEGATE_PK],
"options_id": [1], "options_id": [1],
"id": 1, "id": 1,
"user_has_voted": user == self.user,
"votes_amount": 1, "votes_amount": 1,
}, },
) )
@ -2122,7 +2149,7 @@ class VoteAssignmentPollPseudoanonymousAutoupdates(
vote.value = "A" vote.value = "A"
vote.weight = Decimal("1") vote.weight = Decimal("1")
vote.save(no_delete_on_restriction=True, skip_autoupdate=True) vote.save(no_delete_on_restriction=True, skip_autoupdate=True)
self.poll.voted.add(self.user.id) option.voted.add(self.user.id)
self.poll.state = AssignmentPoll.STATE_FINISHED self.poll.state = AssignmentPoll.STATE_FINISHED
self.poll.save(skip_autoupdate=True) self.poll.save(skip_autoupdate=True)
response = self.client.post( response = self.client.post(
@ -2160,8 +2187,6 @@ class VoteAssignmentPollPseudoanonymousAutoupdates(
"state": 4, "state": 4,
"title": self.poll.title, "title": self.poll.title,
"type": AssignmentPoll.TYPE_PSEUDOANONYMOUS, "type": AssignmentPoll.TYPE_PSEUDOANONYMOUS,
"user_has_voted": user == self.user,
"voted_id": [self.user.id],
"votes_amount": 1, "votes_amount": 1,
"votescast": "1.000000", "votescast": "1.000000",
"votesinvalid": "0.000000", "votesinvalid": "0.000000",
@ -2184,6 +2209,8 @@ class VoteAssignmentPollPseudoanonymousAutoupdates(
"yes": "0.000000", "yes": "0.000000",
"user_id": 1, "user_id": 1,
"weight": 1, "weight": 1,
"user_has_voted": user == self.user,
"voted_id": [self.user.id],
}, },
}, },
) )

View File

@ -24,6 +24,7 @@ def test_assignment_db_queries():
* 1 request to get the tags, * 1 request to get the tags,
* 1 request to get the attachments and * 1 request to get the attachments and
* 1 Request to get the polls of the assignment * 1 Request to get the polls of the assignment
* 1 Request to get the options of these polls
""" """
for index in range(10): for index in range(10):
assignment = Assignment.objects.create(title=f"assignment{index}", open_posts=1) assignment = Assignment.objects.create(title=f"assignment{index}", open_posts=1)
@ -35,7 +36,7 @@ def test_assignment_db_queries():
type=AssignmentPoll.TYPE_NAMED, type=AssignmentPoll.TYPE_NAMED,
) )
assert count_queries(Assignment.get_elements)() == 7 assert count_queries(Assignment.get_elements)() == 8
class CreateAssignment(TestCase): class CreateAssignment(TestCase):

View File

@ -109,7 +109,7 @@ class CreateMotion(TestCase):
The created motion should have an identifier and the admin user should The created motion should have an identifier and the admin user should
be the submitter. be the submitter.
""" """
with self.assertNumQueries(51, verbose=True): with self.assertNumQueries(51):
response = self.client.post( response = self.client.post(
reverse("motion-list"), reverse("motion-list"),
{ {

View File

@ -44,10 +44,14 @@ def test_motion_vote_db_queries():
@pytest.mark.django_db(transaction=False) @pytest.mark.django_db(transaction=False)
def test_motion_option_db_queries(): def test_motion_option_db_queries():
""" """
Tests that only 1 query is done when fetching MotionOptions Tests that only the following db queries are done:
* 1 request to get the options,
* 1 request to get all votes for all options,
* 1 request to get all users that voted on the options
= 5 queries
""" """
create_motion_polls() create_motion_polls()
assert count_queries(MotionOption.get_elements)() == 1 assert count_queries(MotionOption.get_elements)() == 3
def create_motion_polls(): def create_motion_polls():
@ -79,7 +83,7 @@ def create_motion_polls():
value=("Y" if k == 0 else "N"), value=("Y" if k == 0 else "N"),
weight=Decimal(1), weight=Decimal(1),
) )
poll.voted.add(user) option.voted.add(user)
class CreateMotionPoll(TestCase): class CreateMotionPoll(TestCase):
@ -157,12 +161,10 @@ class CreateMotionPoll(TestCase):
"onehundred_percent_base": MotionPoll.PERCENT_BASE_YN, "onehundred_percent_base": MotionPoll.PERCENT_BASE_YN,
"majority_method": MotionPoll.MAJORITY_SIMPLE, "majority_method": MotionPoll.MAJORITY_SIMPLE,
"groups_id": [], "groups_id": [],
"user_has_voted": False,
"votesvalid": "0.000000", "votesvalid": "0.000000",
"votesinvalid": "0.000000", "votesinvalid": "0.000000",
"votescast": "0.000000", "votescast": "0.000000",
"options_id": [1], "options_id": [1],
"voted_id": [],
"id": 1, "id": 1,
}, },
) )
@ -754,7 +756,6 @@ class VoteMotionPollNamed(TestCase):
self.assertEqual(poll.votesinvalid, Decimal("0")) self.assertEqual(poll.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("1")) self.assertEqual(poll.votescast, Decimal("1"))
self.assertEqual(poll.get_votes().count(), 1) self.assertEqual(poll.get_votes().count(), 1)
self.assertEqual(poll.count_users_voted(), 1)
option = poll.options.get() option = poll.options.get()
self.assertEqual(option.yes, Decimal("0")) self.assertEqual(option.yes, Decimal("0"))
self.assertEqual(option.no, Decimal("1")) self.assertEqual(option.no, Decimal("1"))
@ -779,7 +780,6 @@ class VoteMotionPollNamed(TestCase):
self.assertEqual(poll.votesinvalid, Decimal("0")) self.assertEqual(poll.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("1")) self.assertEqual(poll.votescast, Decimal("1"))
self.assertEqual(poll.get_votes().count(), 1) self.assertEqual(poll.get_votes().count(), 1)
self.assertEqual(poll.count_users_voted(), 1)
option = poll.options.get() option = poll.options.get()
self.assertEqual(option.yes, Decimal("0")) self.assertEqual(option.yes, Decimal("0"))
self.assertEqual(option.no, Decimal("0")) self.assertEqual(option.no, Decimal("0"))
@ -905,12 +905,10 @@ class VoteMotionPollNamedAutoupdates(TestCase):
"onehundred_percent_base": "YN", "onehundred_percent_base": "YN",
"majority_method": "simple", "majority_method": "simple",
"groups_id": [GROUP_DELEGATE_PK], "groups_id": [GROUP_DELEGATE_PK],
"user_has_voted": False,
"votesvalid": "1.000000", "votesvalid": "1.000000",
"votesinvalid": "0.000000", "votesinvalid": "0.000000",
"votescast": "1.000000", "votescast": "1.000000",
"options_id": [1], "options_id": [1],
"voted_id": [self.user.id],
"id": 1, "id": 1,
}, },
"motions/motion-vote:1": { "motions/motion-vote:1": {
@ -928,6 +926,8 @@ class VoteMotionPollNamedAutoupdates(TestCase):
"poll_id": 1, "poll_id": 1,
"pollstate": 2, "pollstate": 2,
"yes": "0.000000", "yes": "0.000000",
"user_has_voted": False,
"voted_id": [self.user.id],
}, },
}, },
) )
@ -948,7 +948,7 @@ class VoteMotionPollNamedAutoupdates(TestCase):
) )
self.assertEqual( self.assertEqual(
autoupdate[0]["motions/motion-option:1"], autoupdate[0]["motions/motion-option:1"],
{"id": 1, "poll_id": 1, "pollstate": 2}, {"id": 1, "poll_id": 1, "pollstate": 2, "user_has_voted": True},
) )
self.assertEqual(autoupdate[1], []) self.assertEqual(autoupdate[1], [])
@ -969,12 +969,16 @@ class VoteMotionPollNamedAutoupdates(TestCase):
"groups_id": [GROUP_DELEGATE_PK], "groups_id": [GROUP_DELEGATE_PK],
"options_id": [1], "options_id": [1],
"id": 1, "id": 1,
"user_has_voted": user == self.user,
}, },
) )
self.assertEqual( self.assertEqual(
autoupdate[0]["motions/motion-option:1"], autoupdate[0]["motions/motion-option:1"],
{"id": 1, "poll_id": 1, "pollstate": 2}, {
"id": 1,
"poll_id": 1,
"pollstate": 2,
"user_has_voted": user == self.user,
},
) )
# Other users should not get a vote autoupdate # Other users should not get a vote autoupdate
@ -1040,12 +1044,10 @@ class VoteMotionPollPseudoanonymousAutoupdates(TestCase):
"onehundred_percent_base": "YN", "onehundred_percent_base": "YN",
"majority_method": "simple", "majority_method": "simple",
"groups_id": [GROUP_DELEGATE_PK], "groups_id": [GROUP_DELEGATE_PK],
"user_has_voted": False,
"votesvalid": "1.000000", "votesvalid": "1.000000",
"votesinvalid": "0.000000", "votesinvalid": "0.000000",
"votescast": "1.000000", "votescast": "1.000000",
"options_id": [1], "options_id": [1],
"voted_id": [self.user.id],
"id": 1, "id": 1,
}, },
"motions/motion-vote:1": { "motions/motion-vote:1": {
@ -1063,6 +1065,8 @@ class VoteMotionPollPseudoanonymousAutoupdates(TestCase):
"poll_id": 1, "poll_id": 1,
"pollstate": 2, "pollstate": 2,
"yes": "0.000000", "yes": "0.000000",
"user_has_voted": False,
"voted_id": [self.user.id],
}, },
}, },
) )
@ -1086,7 +1090,6 @@ class VoteMotionPollPseudoanonymousAutoupdates(TestCase):
"groups_id": [GROUP_DELEGATE_PK], "groups_id": [GROUP_DELEGATE_PK],
"options_id": [1], "options_id": [1],
"id": 1, "id": 1,
"user_has_voted": user == self.user,
}, },
) )
@ -1150,12 +1153,12 @@ class VoteMotionPollPseudoanonymous(TestCase):
self.assertEqual(poll.votesinvalid, Decimal("0")) self.assertEqual(poll.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("1")) self.assertEqual(poll.votescast, Decimal("1"))
self.assertEqual(poll.get_votes().count(), 1) self.assertEqual(poll.get_votes().count(), 1)
self.assertEqual(poll.count_users_voted(), 1) self.assertEqual(poll.amount_valid_votes(), 1)
self.assertTrue(self.admin in poll.voted.all())
option = poll.options.get() option = poll.options.get()
self.assertEqual(option.yes, Decimal("0")) self.assertEqual(option.yes, Decimal("0"))
self.assertEqual(option.no, Decimal("1")) self.assertEqual(option.no, Decimal("1"))
self.assertEqual(option.abstain, Decimal("0")) self.assertEqual(option.abstain, Decimal("0"))
self.assertTrue(self.admin in option.voted.all())
vote = option.votes.get() vote = option.votes.get()
self.assertEqual(vote.user, None) self.assertEqual(vote.user, None)
@ -1170,7 +1173,7 @@ class VoteMotionPollPseudoanonymous(TestCase):
response = self.client.post( response = self.client.post(
reverse("motionpoll-vote", args=[self.poll.pk]), "A" reverse("motionpoll-vote", args=[self.poll.pk]), "A"
) )
self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST)
option = MotionPoll.objects.get().options.get() option = MotionPoll.objects.get().options.get()
self.assertEqual(option.yes, Decimal("0")) self.assertEqual(option.yes, Decimal("0"))
self.assertEqual(option.no, Decimal("1")) self.assertEqual(option.no, Decimal("1"))
@ -1303,12 +1306,10 @@ class PublishMotionPoll(TestCase):
"onehundred_percent_base": "YN", "onehundred_percent_base": "YN",
"majority_method": "simple", "majority_method": "simple",
"groups_id": [], "groups_id": [],
"user_has_voted": False,
"votesvalid": "0.000000", "votesvalid": "0.000000",
"votesinvalid": "0.000000", "votesinvalid": "0.000000",
"votescast": "0.000000", "votescast": "0.000000",
"options_id": [1], "options_id": [1],
"voted_id": [],
"id": 1, "id": 1,
}, },
"motions/motion-vote:1": { "motions/motion-vote:1": {
@ -1326,6 +1327,8 @@ class PublishMotionPoll(TestCase):
"poll_id": 1, "poll_id": 1,
"pollstate": 4, "pollstate": 4,
"yes": "0.000000", "yes": "0.000000",
"user_has_voted": False,
"voted_id": [],
}, },
}, },
) )
@ -1359,12 +1362,12 @@ class PseudoanonymizeMotionPoll(TestCase):
self.vote1 = MotionVote.objects.create( self.vote1 = MotionVote.objects.create(
user=self.user1, option=self.option, value="Y", weight=Decimal(1) user=self.user1, option=self.option, value="Y", weight=Decimal(1)
) )
self.poll.voted.add(self.user1) self.option.voted.add(self.user1)
self.user2, _ = self.create_user() self.user2, _ = self.create_user()
self.vote2 = MotionVote.objects.create( self.vote2 = MotionVote.objects.create(
user=self.user2, option=self.option, value="N", weight=Decimal(1) user=self.user2, option=self.option, value="N", weight=Decimal(1)
) )
self.poll.voted.add(self.user2) self.option.voted.add(self.user2)
def test_pseudoanonymize_poll(self): def test_pseudoanonymize_poll(self):
response = self.client.post( response = self.client.post(
@ -1373,16 +1376,16 @@ class PseudoanonymizeMotionPoll(TestCase):
self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = MotionPoll.objects.get() poll = MotionPoll.objects.get()
self.assertEqual(poll.get_votes().count(), 2) self.assertEqual(poll.get_votes().count(), 2)
self.assertEqual(poll.count_users_voted(), 2) self.assertEqual(poll.amount_valid_votes(), 2)
self.assertEqual(poll.votesvalid, Decimal("2")) self.assertEqual(poll.votesvalid, Decimal("2"))
self.assertEqual(poll.votesinvalid, Decimal("0")) self.assertEqual(poll.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("2")) self.assertEqual(poll.votescast, Decimal("2"))
self.assertTrue(self.user1 in poll.voted.all())
self.assertTrue(self.user2 in poll.voted.all())
option = poll.options.get() option = poll.options.get()
self.assertEqual(option.yes, Decimal("1")) self.assertEqual(option.yes, Decimal("1"))
self.assertEqual(option.no, Decimal("1")) self.assertEqual(option.no, Decimal("1"))
self.assertEqual(option.abstain, Decimal("0")) self.assertEqual(option.abstain, Decimal("0"))
self.assertTrue(self.user1 in option.voted.all())
self.assertTrue(self.user2 in option.voted.all())
for vote in poll.get_votes().all(): for vote in poll.get_votes().all():
self.assertTrue(vote.user is None) self.assertTrue(vote.user is None)
@ -1429,19 +1432,19 @@ class ResetMotionPoll(TestCase):
self.vote1 = MotionVote.objects.create( self.vote1 = MotionVote.objects.create(
user=self.user1, option=self.option, value="Y", weight=Decimal(1) user=self.user1, option=self.option, value="Y", weight=Decimal(1)
) )
self.poll.voted.add(self.user1) self.option.voted.add(self.user1)
self.user2, _ = self.create_user() self.user2, _ = self.create_user()
self.vote2 = MotionVote.objects.create( self.vote2 = MotionVote.objects.create(
user=self.user2, option=self.option, value="N", weight=Decimal(1) user=self.user2, option=self.option, value="N", weight=Decimal(1)
) )
self.poll.voted.add(self.user2) self.option.voted.add(self.user2)
def test_reset_poll(self): def test_reset_poll(self):
response = self.client.post(reverse("motionpoll-reset", args=[self.poll.pk])) response = self.client.post(reverse("motionpoll-reset", args=[self.poll.pk]))
self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = MotionPoll.objects.get() poll = MotionPoll.objects.get()
self.assertEqual(poll.get_votes().count(), 0) self.assertEqual(poll.get_votes().count(), 0)
self.assertEqual(poll.count_users_voted(), 0) self.assertEqual(poll.amount_valid_votes(), 0)
self.assertEqual(poll.votesvalid, None) self.assertEqual(poll.votesvalid, None)
self.assertEqual(poll.votesinvalid, None) self.assertEqual(poll.votesinvalid, None)
self.assertEqual(poll.votescast, None) self.assertEqual(poll.votescast, None)
@ -1468,4 +1471,4 @@ class ResetMotionPoll(TestCase):
self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST)
poll = MotionPoll.objects.get() poll = MotionPoll.objects.get()
self.assertTrue(poll.get_votes().exists()) self.assertTrue(poll.get_votes().exists())
self.assertEqual(poll.count_users_voted(), 2) self.assertEqual(poll.amount_valid_votes(), 2)