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 {
data: number[];
@ -213,6 +213,7 @@ export class ChartsComponent extends BaseViewComponent {
/**
* Chart option for pie and doughnut
*/
@Input()
public pieChartOptions: ChartOptions = {
aspectRatio: 1
};

View File

@ -59,7 +59,7 @@ export class SlideContainerComponent extends BaseComponent {
}
if (error) {
console.log(error);
console.error(error);
}
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';
describe('PollPercentBasePipe', () => {
it('create an instance', () => {
const pipe = new PollPercentBasePipe();
expect(pipe).toBeTruthy();
fdescribe('PollPercentBasePipe', () => {
beforeEach(() => {
TestBed.configureTestingModule({
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 { 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.
@ -21,8 +23,18 @@ import { ViewBasePoll } from 'app/site/polls/models/view-base-poll';
export class PollPercentBasePipe implements PipeTransform {
private decimalPlaces = 3;
public transform(value: number, viewPoll: ViewBasePoll): string | null {
const totalByBase = viewPoll.getPercentBase();
public constructor(
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) {
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 { AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll';
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 { ViewAssignmentPoll } from '../../models/view-assignment-poll';
@ -60,10 +61,11 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewA
groupRepo: GroupRepositoryService,
prompt: PromptService,
pollDialog: AssignmentPollDialogService,
pollService: PollService,
private operator: OperatorService,
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 {
@ -129,7 +131,7 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewA
this.setVotesData(Object.values(votes));
this.candidatesLabels = this.poll.initChartLabels();
this.candidatesLabels = this.pollService.getChartLabels(this.poll);
this.isReady = true;
}
@ -146,7 +148,7 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponent<ViewA
protected initChartData(): void {
if (this.isVotedPoll) {
this._chartType = 'doughnut';
this.chartDataSubject.next(this.poll.generateCircleChartData());
this.chartDataSubject.next(this.pollService.generateCircleChartData(this.poll));
} else {
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 { PromptService } from 'app/core/ui-services/prompt.service';
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 { BasePollComponent } from 'app/site/polls/components/base-poll.component';
import { PollService } from 'app/site/polls/services/poll.service';
@ -32,11 +31,8 @@ export class AssignmentPollComponent extends BasePollComponent<ViewAssignmentPol
@Input()
public set poll(value: ViewAssignmentPoll) {
this.initPoll(value);
this.candidatesLabels = value.initChartLabels();
const chartData =
value.pollmethod === AssignmentPollMethods.Votes
? value.generateCircleChartData()
: value.generateChartData();
this.candidatesLabels = this.pollService.getChartLabels(value);
const chartData = this.pollService.generateChartData(value);
this.chartDataSubject.next(chartData);
}
@ -45,7 +41,7 @@ export class AssignmentPollComponent extends BasePollComponent<ViewAssignmentPol
}
public get chartType(): ChartType {
return this.poll && this.poll.pollmethod === AssignmentPollMethods.Votes ? 'doughnut' : 'horizontalBar';
return this.pollService.getChartType(this.poll);
}
public candidatesLabels: string[] = [];

View File

@ -1,11 +1,11 @@
import { BehaviorSubject } from 'rxjs';
import { ChartData } from 'app/shared/components/charts/charts.component';
import { AssignmentPoll, AssignmentPollMethods } from 'app/shared/models/assignments/assignment-poll';
import { PercentBase, PollColor, PollState } from 'app/shared/models/poll/base-poll';
import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll';
import { PollState } from 'app/shared/models/poll/base-poll';
import { BaseViewModel } from 'app/site/base/base-view-model';
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 { ViewAssignmentOption } from './view-assignment-option';
@ -48,33 +48,7 @@ export class ViewAssignmentPoll extends ViewBasePoll<AssignmentPoll> implements
};
}
public initChartLabels(): string[] {
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[] {
public generateTableData(): PollTableData[] {
const data = this.options
.map(candidate => ({
yes: candidate.yes,
@ -97,43 +71,6 @@ export class ViewAssignmentPoll extends ViewBasePoll<AssignmentPoll> implements
}
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 {

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 { PollKeyVerbosePipe } from 'app/shared/pipes/poll-key-verbose.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 { ViewAssignmentPoll } from '../models/view-assignment-poll';
@ -215,7 +215,7 @@ export class AssignmentPdfService {
/**
* 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 votingKey = this.translate.instant(this.pollKeyVerbose.transform(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 { Collection } from 'app/shared/models/base/collection';
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';
@Injectable({
@ -53,4 +53,41 @@ export class AssignmentPollService extends PollService {
poll.pollmethod = AssignmentPollMethods.YN;
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;
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 {

View File

@ -1,10 +1,9 @@
import { ChartData } from 'app/shared/components/charts/charts.component';
import { MotionPoll, MotionPollMethods } from 'app/shared/models/motions/motion-poll';
import { PercentBase, PollColor, PollState } from 'app/shared/models/poll/base-poll';
import { MotionPoll } from 'app/shared/models/motions/motion-poll';
import { PollState } from 'app/shared/models/poll/base-poll';
import { BaseViewModel } from 'app/site/base/base-view-model';
import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable';
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';
export interface MotionPollTitleInformation {
@ -76,15 +75,11 @@ export class ViewMotionPoll extends ViewBasePoll<MotionPoll> implements MotionPo
return !!this.result.votes.length;
}
public initChartLabels(): string[] {
return ['Votes'];
}
public getContentObject(): BaseViewModel {
return this.motion;
}
public generateTableData(): PollData[] {
public generateTableData(): PollTableData[] {
let tableData = this.options.flatMap(vote =>
this.tableKeys.map(key => ({
key: key.vote,
@ -101,21 +96,6 @@ export class ViewMotionPoll extends ViewBasePoll<MotionPoll> implements MotionPo
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 {
return {
getBasicProjectorElement: options => ({
@ -146,39 +126,6 @@ export class ViewMotionPoll extends ViewBasePoll<MotionPoll> implements MotionPo
}
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 {

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 { MotionPollDialogService } from 'app/site/motions/services/motion-poll-dialog.service';
import { BasePollDetailComponent } from 'app/site/polls/components/base-poll-detail.component';
import { PollService } from 'app/site/polls/services/poll.service';
@Component({
selector: 'os-motion-poll-detail',
@ -57,10 +58,11 @@ export class MotionPollDetailComponent extends BasePollDetailComponent<ViewMotio
groupRepo: GroupRepositoryService,
prompt: PromptService,
pollDialog: MotionPollDialogService,
pollService: PollService,
private operator: OperatorService,
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 {

View File

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

View File

@ -3,12 +3,10 @@ import { MatDialog, MatSnackBar } from '@angular/material';
import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
import { BehaviorSubject } from 'rxjs';
import { OperatorService } from 'app/core/core-services/operator.service';
import { MotionPollRepositoryService } from 'app/core/repositories/motions/motion-poll-repository.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 { MotionPollDialogService } from 'app/site/motions/services/motion-poll-dialog.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) {
this.initPoll(value);
const chartData = this.poll.generateChartData();
const chartData = this.pollService.generateChartData(value);
for (const data of chartData) {
if (data.label === 'YES') {
this.voteYes = data.data[0];
@ -55,11 +53,6 @@ export class MotionPollComponent extends BasePollComponent<ViewMotionPoll> {
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`.
*/
@ -148,8 +141,4 @@ export class MotionPollComponent extends BasePollComponent<ViewMotionPoll> {
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 { MajorityMethod, PercentBase } from 'app/shared/models/poll/base-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.
@ -55,4 +61,51 @@ export class MotionPollService extends PollService {
poll.pollmethod = MotionPollMethods.YNA;
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 { ViewUser } from 'app/site/users/models/view-user';
import { BasePollRepositoryService } from '../services/base-poll-repository.service';
import { PollService } from '../services/poll.service';
import { ViewBasePoll } from '../models/view-base-poll';
export interface BaseVoteData {
@ -103,7 +104,8 @@ export abstract class BasePollDetailComponent<V extends ViewBasePoll> extends Ba
protected route: ActivatedRoute,
protected groupRepo: GroupRepositoryService,
protected promptService: PromptService,
protected pollDialog: BasePollDialogService<V>
protected pollDialog: BasePollDialogService<V>,
protected pollService: PollService
) {
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.
*/
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 { ViewAssignmentOption } from 'app/site/assignments/models/view-assignment-option';
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.
*/
export interface PollData {
export interface PollTableData {
key?: string;
value?: number;
yes?: number;
@ -84,9 +83,9 @@ export const PercentBaseVerbose = {
};
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) {
this._tableData = this.generateTableData();
}
@ -154,20 +153,11 @@ export abstract class ViewBasePoll<M extends BasePoll<M, any> = any> extends Bas
public abstract getContentObject(): BaseViewModel;
/**
* Initializes labels for a chart.
*/
public abstract initChartLabels(): string[];
public abstract generateChartData(): ChartData;
public abstract generateTableData(): PollData[];
public abstract getPercentBase(): number;
public abstract generateTableData(): PollTableData[];
}
export interface ViewBasePoll<M extends BasePoll<M, any> = any> extends BasePoll<M, any> {
voted: ViewUser[];
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 { _ } 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 { 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 {
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 {
ENABLE_ELECTRONIC_VOTING: boolean;
}
@ -122,10 +141,6 @@ export abstract class PollService {
*/
public pollValues: CalculablePollKey[] = ['yes', 'no', 'abstain', 'votesvalid', 'votesinvalid', 'votescast'];
/**
* empty constructor
*
*/
public constructor(constants: ConstantsService) {
constants
.get<OpenSlidesSettings>('Settings')
@ -158,4 +173,52 @@ export abstract class PollService {
public getVerboseNameForKey(key: string): string {
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 { MajorityMethod, PercentBase, PollState, PollType } from 'app/shared/models/poll/base-poll';
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;
poll: {
title: string;
@ -11,21 +12,23 @@ export interface AssignmentPollSlideData {
votes_amount: number;
description: string;
state: PollState;
onehundered_percent_base: PercentBase;
onehundred_percent_base: PercentBase;
majority_method: MajorityMethod;
options: {
user: string;
yes?: string;
no?: string;
abstain?: string;
user: {
full_name: string;
};
yes?: number;
no?: number;
abstain?: number;
}[];
// optional for published polls:
amount_global_no?: string;
amount_global_abstain: string;
votesvalid: string;
votesinvalid: string;
votescast: string;
amount_global_no?: number;
amount_global_abstain?: number;
votesvalid: number;
votesinvalid: number;
votescast: number;
};
}

View File

@ -1,5 +1,19 @@
<div *ngIf="data">
<pre>{{ verboseData }}</pre>
</div>
<ng-container *ngIf="data && data.data">
<div class="slidetitle">
<h1 class="assignment-title">{{ data.data.assignment.title }}</h1>
<h2 class="poll-title">{{ data.data.poll.title }}</h2>
</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 { 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';
@Component({
@ -8,8 +9,8 @@ import { AssignmentPollSlideData } from './assignment-poll-slide-data';
templateUrl: './assignment-poll-slide.component.html',
styleUrls: ['./assignment-poll-slide.component.scss']
})
export class AssignmentPollSlideComponent extends BaseSlideComponent<AssignmentPollSlideData> {
public get verboseData(): string {
return JSON.stringify(this.data, null, 2);
}
export class AssignmentPollSlideComponent extends BasePollSlideComponent<AssignmentPollSlideData> {
public PollState = PollState;
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 { MajorityMethod, PercentBase, PollState, PollType } from 'app/shared/models/poll/base-poll';
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;
poll: {
title: string;
type: PollType;
pollmethod: MotionPollMethods;
state: PollState;
onehundered_percent_base: PercentBase;
onehundred_percent_base: PercentBase;
majority_method: MajorityMethod;
options: {
yes?: string;
no?: string;
abstain?: string;
yes?: number;
no?: number;
abstain?: number;
}[];
// optional for published polls:
votesvalid: string;
votesinvalid: string;
votescast: string;
votesvalid: number;
votesinvalid: number;
votescast: number;
};
}

View File

@ -1,5 +1,45 @@
<div *ngIf="data">
<pre>{{ verboseData }}</pre>
</div>
<ng-container *ngIf="data && data.data">
<div class="slidetitle">
<h1 class="motion-title">
<span *ngIf="data.data.motion.identifier">{{ data.data.motion.identifier }}:</span>
{{ 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 { 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';
@Component({
@ -8,8 +10,22 @@ import { MotionPollSlideData } from './motion-poll-slide-data';
templateUrl: './motion-poll-slide.component.html',
styleUrls: ['./motion-poll-slide.component.scss']
})
export class MotionPollSlideComponent extends BaseSlideComponent<MotionPollSlideData> {
public get verboseData(): string {
return JSON.stringify(this.data, null, 2);
export class MotionPollSlideComponent extends BasePollSlideComponent<MotionPollSlideData> {
public PollState = PollState;
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",
"attachments",
"polls",
"polls__options",
)
)
@ -274,8 +275,27 @@ class AssignmentVote(RESTModelMixin, BaseVote):
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):
access_permissions = AssignmentOptionAccessPermissions()
objects = AssignmentOptionManager()
vote_class = AssignmentVote
poll = models.ForeignKey(
@ -307,7 +327,9 @@ class AssignmentPollManager(BaseManager):
super()
.get_prefetched_queryset(*args, **kwargs)
.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
return abstain_sum
def create_options(self):
def create_options(self, skip_autoupdate=False):
related_users = AssignmentRelatedUser.objects.filter(
assignment__id=self.assignment.id
).exclude(elected=True)
for related_user in related_users:
AssignmentOption.objects.create(
option = AssignmentOption(
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
if config["assignment_poll_add_candidates_to_list_of_speakers"]:
@ -401,4 +424,5 @@ class AssignmentPoll(RESTModelMixin, BasePoll):
except OpenSlidesError:
# The Speaker is already on the list. Do nothing.
pass
if not skip_autoupdate:
inform_changed_data(self.assignment.list_of_speakers)

View File

@ -1,7 +1,7 @@
from typing import Any, Dict, List
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
@ -62,20 +62,27 @@ async def assignment_poll_slide(
# Add options:
poll_data["options"] = []
for option in sorted(poll["options"], key=lambda option: option["weight"]):
option_data = {"user": await get_user_name(all_data, option["user_id"])}
options = get_models(all_data, "assignments/assignment-option", poll["options_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:
option_data["yes"] = option["yes"]
option_data["no"] = option["no"]
option_data["abstain"] = option["abstain"]
option_data["yes"] = float(option["yes"])
option_data["no"] = float(option["no"])
option_data["abstain"] = float(option["abstain"])
poll_data["options"].append(option_data)
if poll["state"] == AssignmentPoll.STATE_PUBLISHED:
poll_data["amount_global_no"] = poll["amount_global_no"]
poll_data["amount_global_abstain"] = poll["amount_global_abstain"]
poll_data["votesvalid"] = poll["votesvalid"]
poll_data["votesinvalid"] = poll["votesinvalid"]
poll_data["votescast"] = poll["votescast"]
poll_data["amount_global_no"] = (
float(poll["amount_global_no"]) if poll["amount_global_no"] else None
)
poll_data["amount_global_abstain"] = (
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 {
"assignment": {"title": assignment["title"]},

View File

@ -380,7 +380,7 @@ class AssignmentPollViewSet(BasePollViewSet):
vote_obj.save()
poll.save()
def validate_vote_data(self, data, poll):
def validate_vote_data(self, data, poll, user):
"""
Request data:
analog:
@ -511,6 +511,9 @@ class AssignmentPollViewSet(BasePollViewSet):
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
if poll.type not in (
AssignmentPoll.TYPE_NAMED,
@ -520,12 +523,20 @@ class AssignmentPollViewSet(BasePollViewSet):
AssignmentPoll.POLLMETHOD_YNA,
):
# 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:
raise ValidationError(
{"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):
"""

View File

@ -882,8 +882,27 @@ class MotionVote(RESTModelMixin, BaseVote):
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):
access_permissions = MotionOptionAccessPermissions()
objects = MotionOptionManager()
vote_class = MotionVote
poll = models.ForeignKey(
@ -911,7 +930,7 @@ class MotionPollManager(BaseManager):
super()
.get_prefetched_queryset(*args, **kwargs)
.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:
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["votesinvalid"] = poll["votesinvalid"]
poll_data["votescast"] = poll["votescast"]

View File

@ -1196,7 +1196,7 @@ class MotionPollViewSet(BasePollViewSet):
poll.save()
def validate_vote_data(self, data, poll):
def validate_vote_data(self, data, poll, user):
"""
Request data for analog:
{ "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"):
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):
option = poll.options.get()
vote, _ = MotionVote.objects.get_or_create(user=user, option=option)
self.set_vote_data(data, vote, poll)
inform_changed_data(option)
self.handle_named_or_pseudoanonymous_vote(data, poll, user, False)
def handle_pseudoanonymous_vote(self, data, poll):
option = poll.options.get()
vote = MotionVote.objects.create(option=option)
self.set_vote_data(data, vote, poll)
inform_changed_data(option)
def handle_pseudoanonymous_vote(self, data, poll, user):
self.handle_named_or_pseudoanonymous_vote(data, poll, user, True)
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.weight = Decimal("1")
vote.save(no_delete_on_restriction=True)
option.voted.add(user)
option.save()
class MotionOptionViewSet(BaseOptionViewSet):
queryset = MotionOption.objects.all()

View File

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

View File

@ -103,7 +103,7 @@ class BasePollViewSet(ModelViewSet):
# convert user ids to option ids
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)
if request.data.get("publish_immediately"):
@ -198,7 +198,7 @@ class BasePollViewSet(ModelViewSet):
self.assert_can_vote(poll, request)
data = request.data
self.validate_vote_data(data, poll)
self.validate_vote_data(data, poll, request.user)
if poll.type == BasePoll.TYPE_ANALOG:
self.handle_analog_vote(data, poll, request.user)
@ -258,7 +258,7 @@ class BasePollViewSet(ModelViewSet):
"""
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.
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:
raise ProjectorElementException(f"{collection} with id {id} does not exist")
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)
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()
assert count_queries(AssignmentOption.get_elements)() == 1
assert count_queries(AssignmentOption.get_elements)() == 3
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
"""
assignment = Assignment.objects.create(
title="test_assignment_ohneivoh9caiB8Yiungo", open_posts=1
)
assignment = Assignment(title="test_assignment_ohneivoh9caiB8Yiungo", open_posts=1)
assignment.save(skip_autoupdate=True)
group1 = get_group_model().objects.get(pk=1)
group2 = get_group_model().objects.get(pk=2)
for i in range(3):
@ -73,13 +77,14 @@ def create_assignment_polls():
assignment.add_candidate(user)
for i in range(5):
poll = AssignmentPoll.objects.create(
poll = AssignmentPoll(
assignment=assignment,
title="test_title_UnMiGzEHmwqplmVBPNEZ",
pollmethod=AssignmentPoll.POLLMETHOD_YN,
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(group2)
@ -94,7 +99,7 @@ def create_assignment_polls():
AssignmentVote.objects.create(
user=user, option=option, value="Y", weight=Decimal(weight)
)
poll.voted.add(user)
option.voted.add(user)
class CreateAssignmentPoll(TestCase):
@ -105,7 +110,7 @@ class CreateAssignmentPoll(TestCase):
self.assignment.add_candidate(self.admin)
def test_simple(self):
with self.assertNumQueries(41):
with self.assertNumQueries(50):
response = self.client.post(
reverse("assignmentpoll-list"),
{
@ -1018,7 +1023,7 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass):
self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST)
self.assertFalse(AssignmentPoll.objects.get().get_votes().exists())
def test_too_few_options(self):
def test_partial_vote(self):
self.add_candidate()
self.start_poll()
response = self.client.post(
@ -1026,8 +1031,8 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass):
{"1": "Y"},
format="json",
)
self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST)
self.assertFalse(AssignmentPoll.objects.get().get_votes().exists())
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
self.assertTrue(AssignmentPoll.objects.get().get_votes().exists())
def test_wrong_options(self):
self.add_candidate()
@ -1082,7 +1087,9 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass):
def test_missing_data(self):
self.start_poll()
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())
def test_wrong_data_format(self):
@ -1469,7 +1476,7 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass):
{"1": "N"},
format="json",
)
self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN)
self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST)
poll = AssignmentPoll.objects.get()
option1 = poll.options.get(pk=1)
self.assertEqual(option1.yes, Decimal("1"))
@ -1486,7 +1493,7 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass):
self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST)
self.assertFalse(AssignmentPoll.objects.get().get_votes().exists())
def test_too_few_options(self):
def test_partial_vote(self):
self.add_candidate()
self.start_poll()
response = self.client.post(
@ -1494,8 +1501,8 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass):
{"1": "Y"},
format="json",
)
self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST)
self.assertFalse(AssignmentPoll.objects.get().get_votes().exists())
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
self.assertTrue(AssignmentPoll.objects.get().get_votes().exists())
def test_wrong_options(self):
self.add_candidate()
@ -1550,7 +1557,9 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass):
def test_missing_data(self):
self.start_poll()
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())
def test_wrong_data_format(self):
@ -1658,7 +1667,7 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass):
{"1": 0, "2": 1},
format="json",
)
self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN)
self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST)
poll = AssignmentPoll.objects.get()
option1 = poll.options.get(pk=1)
option2 = poll.options.get(pk=2)
@ -1896,13 +1905,23 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass)
"type": AssignmentPoll.TYPE_NAMED,
"onehundred_percent_base": AssignmentPoll.PERCENT_BASE_CAST,
"majority_method": AssignmentPoll.MAJORITY_TWO_THIRDS,
"user_has_voted": False,
"voted_id": [self.user.id],
"votes_amount": 1,
"votescast": "1.000000",
"votesinvalid": "0.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": {
"id": 1,
"option_id": 1,
@ -1951,7 +1970,6 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass)
"groups_id": [GROUP_DELEGATE_PK],
"options_id": [1],
"id": 1,
"user_has_voted": user == self.user,
"votes_amount": 1,
},
)
@ -1966,7 +1984,7 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass)
vote.value = "A"
vote.weight = Decimal("1")
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.save(skip_autoupdate=True)
response = self.client.post(
@ -2004,8 +2022,6 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass)
"state": 4,
"title": self.poll.title,
"type": "named",
"user_has_voted": user == self.user,
"voted_id": [self.user.id],
"votes_amount": 1,
"votescast": "1.000000",
"votesinvalid": "0.000000",
@ -2028,6 +2044,8 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass)
"yes": "0.000000",
"user_id": 1,
"weight": 1,
"user_has_voted": user == self.user,
"voted_id": [self.user.id],
},
},
)
@ -2067,13 +2085,23 @@ class VoteAssignmentPollPseudoanonymousAutoupdates(
"type": AssignmentPoll.TYPE_PSEUDOANONYMOUS,
"onehundred_percent_base": AssignmentPoll.PERCENT_BASE_CAST,
"majority_method": AssignmentPoll.MAJORITY_TWO_THIRDS,
"user_has_voted": False,
"voted_id": [self.user.id],
"votes_amount": 1,
"votescast": "1.000000",
"votesinvalid": "0.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": {
"id": 1,
"option_id": 1,
@ -2108,7 +2136,6 @@ class VoteAssignmentPollPseudoanonymousAutoupdates(
"groups_id": [GROUP_DELEGATE_PK],
"options_id": [1],
"id": 1,
"user_has_voted": user == self.user,
"votes_amount": 1,
},
)
@ -2122,7 +2149,7 @@ class VoteAssignmentPollPseudoanonymousAutoupdates(
vote.value = "A"
vote.weight = Decimal("1")
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.save(skip_autoupdate=True)
response = self.client.post(
@ -2160,8 +2187,6 @@ class VoteAssignmentPollPseudoanonymousAutoupdates(
"state": 4,
"title": self.poll.title,
"type": AssignmentPoll.TYPE_PSEUDOANONYMOUS,
"user_has_voted": user == self.user,
"voted_id": [self.user.id],
"votes_amount": 1,
"votescast": "1.000000",
"votesinvalid": "0.000000",
@ -2184,6 +2209,8 @@ class VoteAssignmentPollPseudoanonymousAutoupdates(
"yes": "0.000000",
"user_id": 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 attachments and
* 1 Request to get the polls of the assignment
* 1 Request to get the options of these polls
"""
for index in range(10):
assignment = Assignment.objects.create(title=f"assignment{index}", open_posts=1)
@ -35,7 +36,7 @@ def test_assignment_db_queries():
type=AssignmentPoll.TYPE_NAMED,
)
assert count_queries(Assignment.get_elements)() == 7
assert count_queries(Assignment.get_elements)() == 8
class CreateAssignment(TestCase):

View File

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

View File

@ -44,10 +44,14 @@ def test_motion_vote_db_queries():
@pytest.mark.django_db(transaction=False)
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()
assert count_queries(MotionOption.get_elements)() == 1
assert count_queries(MotionOption.get_elements)() == 3
def create_motion_polls():
@ -79,7 +83,7 @@ def create_motion_polls():
value=("Y" if k == 0 else "N"),
weight=Decimal(1),
)
poll.voted.add(user)
option.voted.add(user)
class CreateMotionPoll(TestCase):
@ -157,12 +161,10 @@ class CreateMotionPoll(TestCase):
"onehundred_percent_base": MotionPoll.PERCENT_BASE_YN,
"majority_method": MotionPoll.MAJORITY_SIMPLE,
"groups_id": [],
"user_has_voted": False,
"votesvalid": "0.000000",
"votesinvalid": "0.000000",
"votescast": "0.000000",
"options_id": [1],
"voted_id": [],
"id": 1,
},
)
@ -754,7 +756,6 @@ class VoteMotionPollNamed(TestCase):
self.assertEqual(poll.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("1"))
self.assertEqual(poll.get_votes().count(), 1)
self.assertEqual(poll.count_users_voted(), 1)
option = poll.options.get()
self.assertEqual(option.yes, Decimal("0"))
self.assertEqual(option.no, Decimal("1"))
@ -779,7 +780,6 @@ class VoteMotionPollNamed(TestCase):
self.assertEqual(poll.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("1"))
self.assertEqual(poll.get_votes().count(), 1)
self.assertEqual(poll.count_users_voted(), 1)
option = poll.options.get()
self.assertEqual(option.yes, Decimal("0"))
self.assertEqual(option.no, Decimal("0"))
@ -905,12 +905,10 @@ class VoteMotionPollNamedAutoupdates(TestCase):
"onehundred_percent_base": "YN",
"majority_method": "simple",
"groups_id": [GROUP_DELEGATE_PK],
"user_has_voted": False,
"votesvalid": "1.000000",
"votesinvalid": "0.000000",
"votescast": "1.000000",
"options_id": [1],
"voted_id": [self.user.id],
"id": 1,
},
"motions/motion-vote:1": {
@ -928,6 +926,8 @@ class VoteMotionPollNamedAutoupdates(TestCase):
"poll_id": 1,
"pollstate": 2,
"yes": "0.000000",
"user_has_voted": False,
"voted_id": [self.user.id],
},
},
)
@ -948,7 +948,7 @@ class VoteMotionPollNamedAutoupdates(TestCase):
)
self.assertEqual(
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], [])
@ -969,12 +969,16 @@ class VoteMotionPollNamedAutoupdates(TestCase):
"groups_id": [GROUP_DELEGATE_PK],
"options_id": [1],
"id": 1,
"user_has_voted": user == self.user,
},
)
self.assertEqual(
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
@ -1040,12 +1044,10 @@ class VoteMotionPollPseudoanonymousAutoupdates(TestCase):
"onehundred_percent_base": "YN",
"majority_method": "simple",
"groups_id": [GROUP_DELEGATE_PK],
"user_has_voted": False,
"votesvalid": "1.000000",
"votesinvalid": "0.000000",
"votescast": "1.000000",
"options_id": [1],
"voted_id": [self.user.id],
"id": 1,
},
"motions/motion-vote:1": {
@ -1063,6 +1065,8 @@ class VoteMotionPollPseudoanonymousAutoupdates(TestCase):
"poll_id": 1,
"pollstate": 2,
"yes": "0.000000",
"user_has_voted": False,
"voted_id": [self.user.id],
},
},
)
@ -1086,7 +1090,6 @@ class VoteMotionPollPseudoanonymousAutoupdates(TestCase):
"groups_id": [GROUP_DELEGATE_PK],
"options_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.votescast, Decimal("1"))
self.assertEqual(poll.get_votes().count(), 1)
self.assertEqual(poll.count_users_voted(), 1)
self.assertTrue(self.admin in poll.voted.all())
self.assertEqual(poll.amount_valid_votes(), 1)
option = poll.options.get()
self.assertEqual(option.yes, Decimal("0"))
self.assertEqual(option.no, Decimal("1"))
self.assertEqual(option.abstain, Decimal("0"))
self.assertTrue(self.admin in option.voted.all())
vote = option.votes.get()
self.assertEqual(vote.user, None)
@ -1170,7 +1173,7 @@ class VoteMotionPollPseudoanonymous(TestCase):
response = self.client.post(
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()
self.assertEqual(option.yes, Decimal("0"))
self.assertEqual(option.no, Decimal("1"))
@ -1303,12 +1306,10 @@ class PublishMotionPoll(TestCase):
"onehundred_percent_base": "YN",
"majority_method": "simple",
"groups_id": [],
"user_has_voted": False,
"votesvalid": "0.000000",
"votesinvalid": "0.000000",
"votescast": "0.000000",
"options_id": [1],
"voted_id": [],
"id": 1,
},
"motions/motion-vote:1": {
@ -1326,6 +1327,8 @@ class PublishMotionPoll(TestCase):
"poll_id": 1,
"pollstate": 4,
"yes": "0.000000",
"user_has_voted": False,
"voted_id": [],
},
},
)
@ -1359,12 +1362,12 @@ class PseudoanonymizeMotionPoll(TestCase):
self.vote1 = MotionVote.objects.create(
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.vote2 = MotionVote.objects.create(
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):
response = self.client.post(
@ -1373,16 +1376,16 @@ class PseudoanonymizeMotionPoll(TestCase):
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = MotionPoll.objects.get()
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.votesinvalid, Decimal("0"))
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()
self.assertEqual(option.yes, Decimal("1"))
self.assertEqual(option.no, Decimal("1"))
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():
self.assertTrue(vote.user is None)
@ -1429,19 +1432,19 @@ class ResetMotionPoll(TestCase):
self.vote1 = MotionVote.objects.create(
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.vote2 = MotionVote.objects.create(
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):
response = self.client.post(reverse("motionpoll-reset", args=[self.poll.pk]))
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = MotionPoll.objects.get()
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.votesinvalid, None)
self.assertEqual(poll.votescast, None)
@ -1468,4 +1471,4 @@ class ResetMotionPoll(TestCase):
self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST)
poll = MotionPoll.objects.get()
self.assertTrue(poll.get_votes().exists())
self.assertEqual(poll.count_users_voted(), 2)
self.assertEqual(poll.amount_valid_votes(), 2)