diff --git a/client/src/app/shared/components/charts/charts.component.ts b/client/src/app/shared/components/charts/charts.component.ts index 9f38fc14b..0b9d554a6 100644 --- a/client/src/app/shared/components/charts/charts.component.ts +++ b/client/src/app/shared/components/charts/charts.component.ts @@ -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 }; diff --git a/client/src/app/shared/components/slide-container/slide-container.component.ts b/client/src/app/shared/components/slide-container/slide-container.component.ts index 747616a67..da565ca1f 100644 --- a/client/src/app/shared/components/slide-container/slide-container.component.ts +++ b/client/src/app/shared/components/slide-container/slide-container.component.ts @@ -59,7 +59,7 @@ export class SlideContainerComponent extends BaseComponent { } if (error) { - console.log(error); + console.error(error); } return; } diff --git a/client/src/app/shared/pipes/poll-percent-base.pipe.spec.ts b/client/src/app/shared/pipes/poll-percent-base.pipe.spec.ts index f10efe859..dbf1eadea 100644 --- a/client/src/app/shared/pipes/poll-percent-base.pipe.spec.ts +++ b/client/src/app/shared/pipes/poll-percent-base.pipe.spec.ts @@ -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(); + } + )); }); diff --git a/client/src/app/shared/pipes/poll-percent-base.pipe.ts b/client/src/app/shared/pipes/poll-percent-base.pipe.ts index 6054e94d9..51e948f03 100644 --- a/client/src/app/shared/pipes/poll-percent-base.pipe.ts +++ b/client/src/app/shared/pipes/poll-percent-base.pipe.ts @@ -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 ((poll).assignment) { + totalByBase = this.assignmentPollService.getPercentBase(poll); + } else { + totalByBase = this.motionPollService.getPercentBase(poll); + } if (totalByBase) { const percentNumber = (value / totalByBase) * 100; diff --git a/client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.ts b/client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.ts index cbc26e629..d3c0a5662 100644 --- a/client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.ts +++ b/client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.ts @@ -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 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 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 { diff --git a/client/src/app/site/assignments/services/assignment-pdf.service.ts b/client/src/app/site/assignments/services/assignment-pdf.service.ts index 05569d414..d1a3e37e5 100644 --- a/client/src/app/site/assignments/services/assignment-pdf.service.ts +++ b/client/src/app/site/assignments/services/assignment-pdf.service.ts @@ -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]); diff --git a/client/src/app/site/assignments/services/assignment-poll.service.ts b/client/src/app/site/assignments/services/assignment-poll.service.ts index 13d315b1a..8d0dc9f1d 100644 --- a/client/src/app/site/assignments/services/assignment-poll.service.ts +++ b/client/src/app/site/assignments/services/assignment-poll.service.ts @@ -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; + } } diff --git a/client/src/app/site/motions/models/view-motion-option.ts b/client/src/app/site/motions/models/view-motion-option.ts index 64880b50a..b380648cb 100644 --- a/client/src/app/site/motions/models/view-motion-option.ts +++ b/client/src/app/site/motions/models/view-motion-option.ts @@ -9,19 +9,6 @@ export class ViewMotionOption extends BaseViewModel { } 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 { diff --git a/client/src/app/site/motions/models/view-motion-poll.ts b/client/src/app/site/motions/models/view-motion-poll.ts index 5d3d3a23c..9fff194f9 100644 --- a/client/src/app/site/motions/models/view-motion-poll.ts +++ b/client/src/app/site/motions/models/view-motion-poll.ts @@ -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 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 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 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 { diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.ts b/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.ts index 4872b7441..7677243c3 100644 --- a/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.ts +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.ts @@ -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
-
+
{{ voteYes | parsePollNumber }} {{ voteYes | pollPercentBase: poll }}
-
+
{{ voteNo | parsePollNumber }} {{ voteNo | pollPercentBase: poll }}
-
+
{{ voteAbstain | parsePollNumber }} {{ voteAbstain | pollPercentBase: poll }} diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.ts b/client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.ts index af81a8ffd..ddcca33f1 100644 --- a/client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.ts +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.ts @@ -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 { 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 { return `/motions/polls/${this.poll.id}`; } - /** - * Subject to holding the data needed for the chart. - */ - public chartDataSubject: BehaviorSubject = new BehaviorSubject([]); - /** * Number of votes for `Yes`. */ @@ -148,8 +141,4 @@ export class MotionPollComponent extends BasePollComponent { this.repo.delete(this.poll).catch(this.raiseError); } } - - public isVoteDocumented(vote: number): boolean { - return vote !== null && vote !== undefined && vote !== -2; - } } diff --git a/client/src/app/site/motions/services/motion-poll.service.ts b/client/src/app/site/motions/services/motion-poll.service.ts index 895dee8df..94c967d99 100644 --- a/client/src/app/site/motions/services/motion-poll.service.ts +++ b/client/src/app/site/motions/services/motion-poll.service.ts @@ -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; + } } diff --git a/client/src/app/site/polls/components/base-poll-detail.component.ts b/client/src/app/site/polls/components/base-poll-detail.component.ts index 1620b0963..5f50ebfce 100644 --- a/client/src/app/site/polls/components/base-poll-detail.component.ts +++ b/client/src/app/site/polls/components/base-poll-detail.component.ts @@ -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 extends Ba protected route: ActivatedRoute, protected groupRepo: GroupRepositoryService, protected promptService: PromptService, - protected pollDialog: BasePollDialogService + protected pollDialog: BasePollDialogService, + protected pollService: PollService ) { super(title, translate, matSnackbar); } @@ -174,7 +176,7 @@ export abstract class BasePollDetailComponent 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)); } /** diff --git a/client/src/app/site/polls/models/view-base-poll.ts b/client/src/app/site/polls/models/view-base-poll.ts index 2bb175911..6d29573b4 100644 --- a/client/src/app/site/polls/models/view-base-poll.ts +++ b/client/src/app/site/polls/models/view-base-poll.ts @@ -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 = any> extends BaseProjectableViewModel { - 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 = 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 = any> extends BasePoll { 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 } diff --git a/client/src/app/site/polls/services/poll.service.ts b/client/src/app/site/polls/services/poll.service.ts index 4ddcc4093..60bce0f4f 100644 --- a/client/src/app/site/polls/services/poll.service.ts +++ b/client/src/app/site/polls/services/poll.service.ts @@ -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('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 ((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 ((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; + } } diff --git a/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide-data.ts b/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide-data.ts index f26720fb3..d510fcc5c 100644 --- a/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide-data.ts +++ b/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide-data.ts @@ -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; }; } diff --git a/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide.component.html b/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide.component.html index 09eeb2634..073d7bec1 100644 --- a/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide.component.html +++ b/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide.component.html @@ -1,5 +1,19 @@ -
- -
{{ verboseData }}
- -
+ +
+

{{ data.data.assignment.title }}

+

{{ data.data.poll.title }}

+
+
+ +
+
+ + {{ "Nothing to see here!" | translate }} +
+
diff --git a/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide.component.scss b/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide.component.scss index e69de29bb..94c5fff87 100644 --- a/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide.component.scss +++ b/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide.component.scss @@ -0,0 +1,11 @@ +.assignment-title { + margin: 0 0 10px; +} + +.slidetitle { + margin-bottom: 15px; +} + +.charts-wrapper { + position: relative; +} diff --git a/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide.component.ts b/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide.component.ts index 45a3f915a..39a1ccc24 100644 --- a/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide.component.ts +++ b/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide.component.ts @@ -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 { - public get verboseData(): string { - return JSON.stringify(this.data, null, 2); - } +export class AssignmentPollSlideComponent extends BasePollSlideComponent { + public PollState = PollState; + + public options = { maintainAspectRatio: false, responsive: true, legend: { position: 'right' } }; } diff --git a/client/src/app/slides/motions/motion-poll/motion-poll-slide-data.ts b/client/src/app/slides/motions/motion-poll/motion-poll-slide-data.ts index 1ea5937ea..435bbd42d 100644 --- a/client/src/app/slides/motions/motion-poll/motion-poll-slide-data.ts +++ b/client/src/app/slides/motions/motion-poll/motion-poll-slide-data.ts @@ -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; }; } diff --git a/client/src/app/slides/motions/motion-poll/motion-poll-slide.component.html b/client/src/app/slides/motions/motion-poll/motion-poll-slide.component.html index 09eeb2634..a9ef60042 100644 --- a/client/src/app/slides/motions/motion-poll/motion-poll-slide.component.html +++ b/client/src/app/slides/motions/motion-poll/motion-poll-slide.component.html @@ -1,5 +1,45 @@ -
- -
{{ verboseData }}
- -
+ +
+

+ {{ data.data.motion.identifier }}: + {{ data.data.motion.title }} +

+

{{ data.data.poll.title }}

+
+
+
+ + +
+
+
+ + {{ voteYes | parsePollNumber }} + {{ voteYes | pollPercentBase: data.data.poll }} + +
+
+ + {{ voteNo | parsePollNumber }} + {{ voteNo | pollPercentBase: data.data.poll }} + +
+
+ + {{ voteAbstain | parsePollNumber }} + {{ voteAbstain | pollPercentBase: data.data.poll }} + +
+
+
+
+ + {{ "Nothing to see here!" | translate }} +
+
\ No newline at end of file diff --git a/client/src/app/slides/motions/motion-poll/motion-poll-slide.component.scss b/client/src/app/slides/motions/motion-poll/motion-poll-slide.component.scss index e69de29bb..d7d8b3c8f 100644 --- a/client/src/app/slides/motions/motion-poll/motion-poll-slide.component.scss +++ b/client/src/app/slides/motions/motion-poll/motion-poll-slide.component.scss @@ -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; + } + } +} diff --git a/client/src/app/slides/motions/motion-poll/motion-poll-slide.component.ts b/client/src/app/slides/motions/motion-poll/motion-poll-slide.component.ts index 8ed6216dd..9a6fe219c 100644 --- a/client/src/app/slides/motions/motion-poll/motion-poll-slide.component.ts +++ b/client/src/app/slides/motions/motion-poll/motion-poll-slide.component.ts @@ -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 { - public get verboseData(): string { - return JSON.stringify(this.data, null, 2); +export class MotionPollSlideComponent extends BasePollSlideComponent { + 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; + } + }); } } diff --git a/client/src/app/slides/polls/base-poll-slide-data.ts b/client/src/app/slides/polls/base-poll-slide-data.ts new file mode 100644 index 000000000..1790d84bd --- /dev/null +++ b/client/src/app/slides/polls/base-poll-slide-data.ts @@ -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; + }; +} diff --git a/client/src/app/slides/polls/base-poll-slide.component.ts b/client/src/app/slides/polls/base-poll-slide.component.ts new file mode 100644 index 000000000..4612b327b --- /dev/null +++ b/client/src/app/slides/polls/base-poll-slide.component.ts @@ -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 extends BaseSlideComponent { + public chartDataSubject: BehaviorSubject = new BehaviorSubject([]); + + @Input() + public set data(value: SlideData) { + 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 { + return this._data; + } + + private _data: SlideData; + + public constructor( + @Inject(forwardRef(() => PollService)) + public pollService: PollService + ) { + super(); + } +} diff --git a/openslides/assignments/models.py b/openslides/assignments/models.py index db9b878f1..32e3c2d9c 100644 --- a/openslides/assignments/models.py +++ b/openslides/assignments/models.py @@ -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 - inform_changed_data(self.assignment.list_of_speakers) + if not skip_autoupdate: + inform_changed_data(self.assignment.list_of_speakers) diff --git a/openslides/assignments/projector.py b/openslides/assignments/projector.py index 5dd9055ea..2d12e9c67 100644 --- a/openslides/assignments/projector.py +++ b/openslides/assignments/projector.py @@ -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"]}, diff --git a/openslides/assignments/views.py b/openslides/assignments/views.py index 0e7cb851d..65e182103 100644 --- a/openslides/assignments/views.py +++ b/openslides/assignments/views.py @@ -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): """ diff --git a/openslides/motions/models.py b/openslides/motions/models.py index 70c722093..9ba5efc99 100644 --- a/openslides/motions/models.py +++ b/openslides/motions/models.py @@ -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") ) diff --git a/openslides/motions/projector.py b/openslides/motions/projector.py index 3b29e903f..74a9a399f 100644 --- a/openslides/motions/projector.py +++ b/openslides/motions/projector.py @@ -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"] diff --git a/openslides/motions/views.py b/openslides/motions/views.py index 8b5e5fc78..b89c27029 100644 --- a/openslides/motions/views.py +++ b/openslides/motions/views.py @@ -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": , "N": , ["A": ], @@ -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() diff --git a/openslides/poll/models.py b/openslides/poll/models.py index 1e5f46990..5b81a3337 100644 --- a/openslides/poll/models.py +++ b/openslides/poll/models.py @@ -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() - 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) + 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): diff --git a/openslides/poll/views.py b/openslides/poll/views.py index 8d5d9711d..aa7e87a2b 100644 --- a/openslides/poll/views.py +++ b/openslides/poll/views.py @@ -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 diff --git a/openslides/utils/projector.py b/openslides/utils/projector.py index 7fb6da472..9e20fddcd 100644 --- a/openslides/utils/projector.py +++ b/openslides/utils/projector.py @@ -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] diff --git a/tests/integration/assignments/test_polls.py b/tests/integration/assignments/test_polls.py index aa40d620f..baabac71f 100644 --- a/tests/integration/assignments/test_polls.py +++ b/tests/integration/assignments/test_polls.py @@ -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], }, }, ) diff --git a/tests/integration/assignments/test_viewset.py b/tests/integration/assignments/test_viewset.py index 57817a9b3..35047a71c 100644 --- a/tests/integration/assignments/test_viewset.py +++ b/tests/integration/assignments/test_viewset.py @@ -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): diff --git a/tests/integration/motions/test_motions.py b/tests/integration/motions/test_motions.py index bb531004c..79f430e0d 100644 --- a/tests/integration/motions/test_motions.py +++ b/tests/integration/motions/test_motions.py @@ -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"), { diff --git a/tests/integration/motions/test_polls.py b/tests/integration/motions/test_polls.py index f1ccee70e..93844fe0b 100644 --- a/tests/integration/motions/test_polls.py +++ b/tests/integration/motions/test_polls.py @@ -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)