Add global no and abstain to form

Minur UI changes
Minor Chart enhancements
Server Changes
This commit is contained in:
Sean Engelhardt 2020-03-11 10:22:03 +01:00 committed by FinnStutzenstein
parent 8fe5a0c9f4
commit 3c36441967
32 changed files with 429 additions and 361 deletions

View File

@ -34,7 +34,7 @@ export class VotingBannerService {
*/ */
private checkForVotablePolls(polls: ViewBasePoll[]): void { private checkForVotablePolls(polls: ViewBasePoll[]): void {
// display no banner if in history mode or there are no polls to vote // display no banner if in history mode or there are no polls to vote
const pollsToVote = polls.filter(poll => this.votingService.canVote(poll) && !poll.user_has_voted_valid); const pollsToVote = polls.filter(poll => this.votingService.canVote(poll) && !poll.user_has_voted);
if ((this.OSStatus.isInHistoryMode && this.currentBanner) || !pollsToVote.length) { if ((this.OSStatus.isInHistoryMode && this.currentBanner) || !pollsToVote.length) {
this.sliceBanner(); this.sliceBanner();
return; return;

View File

@ -9,8 +9,7 @@ export enum VotingError {
POLL_WRONG_TYPE, POLL_WRONG_TYPE,
USER_HAS_NO_PERMISSION, USER_HAS_NO_PERMISSION,
USER_IS_ANONYMOUS, USER_IS_ANONYMOUS,
USER_NOT_PRESENT, USER_NOT_PRESENT
USER_HAS_VOTED_VALID
} }
/** /**
@ -60,9 +59,6 @@ export class VotingService {
if (!user.is_present) { if (!user.is_present) {
return VotingError.USER_NOT_PRESENT; return VotingError.USER_NOT_PRESENT;
} }
if (poll.type === PollType.Pseudoanonymous && poll.user_has_voted_valid) {
return VotingError.USER_HAS_VOTED_VALID;
}
} }
public getVotePermissionErrorVerbose(poll: ViewBasePoll): string | void { public getVotePermissionErrorVerbose(poll: ViewBasePoll): string | void {

View File

@ -211,9 +211,11 @@ export class ChartsComponent extends BaseViewComponent {
yAxes: [ yAxes: [
{ {
gridLines: { gridLines: {
drawOnChartArea: false drawBorder: false,
drawOnChartArea: false,
drawTicks: false
}, },
ticks: { beginAtZero: true, mirror: true, labelOffset: -20 }, ticks: { mirror: true, labelOffset: -20 },
stacked: true stacked: true
} }
] ]

View File

@ -88,12 +88,14 @@ export class CheckInputComponent extends BaseViewComponent implements OnInit, Co
* @param obj the value from the parent form. Type "any" is required by the interface * @param obj the value from the parent form. Type "any" is required by the interface
*/ */
public writeValue(obj: string | number): void { public writeValue(obj: string | number): void {
if (obj && obj === this.checkboxValue) { if (obj || typeof obj === 'number') {
if (obj === this.checkboxValue) {
this.checkboxStateChanged(true); this.checkboxStateChanged(true);
} else { } else {
this.contentForm.patchValue(obj); this.contentForm.patchValue(obj);
} }
} }
}
/** /**
* Hands changes back to the parent form * Hands changes back to the parent form

View File

@ -29,6 +29,13 @@ export class AssignmentPoll extends BasePoll<
public static COLLECTIONSTRING = 'assignments/assignment-poll'; public static COLLECTIONSTRING = 'assignments/assignment-poll';
public static defaultGroupsConfig = 'assignment_poll_default_groups'; public static defaultGroupsConfig = 'assignment_poll_default_groups';
public static defaultPollMethodConfig = 'assignment_poll_method'; public static defaultPollMethodConfig = 'assignment_poll_method';
public static DECIMAL_FIELDS = [
'votesvalid',
'votesinvalid',
'votescast',
'amount_global_abstain',
'amount_global_no'
];
public id: number; public id: number;
public assignment_id: number; public assignment_id: number;
@ -36,6 +43,8 @@ export class AssignmentPoll extends BasePoll<
public allow_multiple_votes_per_candidate: boolean; public allow_multiple_votes_per_candidate: boolean;
public global_no: boolean; public global_no: boolean;
public global_abstain: boolean; public global_abstain: boolean;
public amount_global_no: number;
public amount_global_abstain: number;
public description: string; public description: string;
public get isMethodY(): boolean { public get isMethodY(): boolean {
@ -63,4 +72,8 @@ export class AssignmentPoll extends BasePoll<
public constructor(input?: any) { public constructor(input?: any) {
super(AssignmentPoll.COLLECTIONSTRING, input); super(AssignmentPoll.COLLECTIONSTRING, input);
} }
protected getDecimalFields(): string[] {
return AssignmentPoll.DECIMAL_FIELDS;
}
} }

View File

@ -1,7 +1,8 @@
import { BaseModel } from './base-model'; import { BaseModel } from './base-model';
export abstract class BaseDecimalModel<T = any> extends BaseModel<T> { export abstract class BaseDecimalModel<T = any> extends BaseModel<T> {
protected abstract getDecimalFields(): (keyof this)[]; // TODO: no more elegant solution available in current Typescript
protected abstract getDecimalFields(): string[];
public deserialize(input: any): void { public deserialize(input: any): void {
if (input && typeof input === 'object') { if (input && typeof input === 'object') {

View File

@ -6,10 +6,9 @@ export abstract class BaseOption<T> extends BaseDecimalModel<T> {
public no: number; public no: number;
public abstain: number; public abstain: number;
public poll_id: number; public poll_id: number;
public user_has_voted: boolean;
public voted_id: number[]; public voted_id: number[];
protected getDecimalFields(): (keyof BaseOption<T>)[] { protected getDecimalFields(): string[] {
return ['yes', 'no', 'abstain']; return ['yes', 'no', 'abstain'];
} }
} }

View File

@ -52,6 +52,7 @@ export abstract class BasePoll<
public votescast: number; public votescast: number;
public groups_id: number[]; public groups_id: number[];
public majority_method: MajorityMethod; public majority_method: MajorityMethod;
public user_has_voted: boolean;
public pollmethod: PM; public pollmethod: PM;
public onehundred_percent_base: PB; public onehundred_percent_base: PB;
@ -91,7 +92,7 @@ export abstract class BasePoll<
return this.state + 1; return this.state + 1;
} }
protected getDecimalFields(): (keyof BasePoll<T, O>)[] { protected getDecimalFields(): string[] {
return ['votesvalid', 'votesinvalid', 'votescast']; return ['votesvalid', 'votesinvalid', 'votescast'];
} }
} }

View File

@ -26,7 +26,7 @@ export abstract class BaseVote<T = any> extends BaseDecimalModel<T> {
return VoteValueVerbose[this.value]; return VoteValueVerbose[this.value];
} }
protected getDecimalFields(): (keyof BaseVote<T>)[] { protected getDecimalFields(): string[] {
return ['weight']; return ['weight'];
} }
} }

View File

@ -8,7 +8,9 @@ const PollValues = {
votesabstain: 'Votes abstain', votesabstain: 'Votes abstain',
yes: 'Yes', yes: 'Yes',
no: 'No', no: 'No',
abstain: 'Abstain' abstain: 'Abstain',
amount_global_abstain: 'Global abstain',
amount_global_no: 'Global no'
}; };
/** /**

View File

@ -57,21 +57,31 @@
</mat-menu> </mat-menu>
</os-head-bar> </os-head-bar>
<div class="content-container"> <div>
<div *ngIf="editAssignment"> <div *ngIf="editAssignment">
<ng-container [ngTemplateOutlet]="assignmentFormTemplate"></ng-container> <ng-container [ngTemplateOutlet]="assignmentFormTemplate"></ng-container>
</div> </div>
<div *ngIf="!editAssignment"> <div *ngIf="!editAssignment">
<!-- assignment meta infos--> <!-- assignment meta infos-->
<ng-container [ngTemplateOutlet]="metaInfoTemplate"></ng-container> <ng-container [ngTemplateOutlet]="metaInfoTemplate"></ng-container>
<!-- candidates list -->
<ng-container [ngTemplateOutlet]="candidatesTemplate"></ng-container> <!-- polls -->
<!-- closed polls -->
<ng-container *ngIf="assignment && assignment.polls.length"> <ng-container *ngIf="assignment && assignment.polls.length">
<ng-container *ngFor="let poll of assignment.polls | reverse; trackBy: trackByIndex"> <ng-container *ngFor="let poll of assignment.polls | reverse; trackBy: trackByIndex">
<os-assignment-poll [poll]="poll"> </os-assignment-poll> <os-assignment-poll [poll]="poll"> </os-assignment-poll>
</ng-container> </ng-container>
</ng-container> </ng-container>
<!-- New Ballot button -->
<div class="new-ballot-button" *ngIf="assignment && hasPerms('createPoll')">
<button mat-stroked-button (click)="openDialog()">
<mat-icon color="primary">poll</mat-icon>
<span translate>New ballot</span>
</button>
</div>
<!-- candidates list -->
<ng-container [ngTemplateOutlet]="candidatesTemplate"></ng-container>
</div> </div>
</div> </div>
@ -196,12 +206,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="ballot-button" *ngIf="assignment && hasPerms('createPoll')">
<button mat-button (click)="openDialog()">
<mat-icon color="primary">poll</mat-icon>
<span translate>New ballot</span>
</button>
</div>
</ng-container> </ng-container>
</mat-card> </mat-card>
</ng-template> </ng-template>

View File

@ -26,6 +26,14 @@
} }
} }
.new-ballot-button {
display: flex;
> * {
margin-left: auto;
margin-right: auto;
}
}
.election-document-list mat-list-item { .election-document-list mat-list-item {
height: 20px; height: 20px;
} }
@ -56,10 +64,6 @@
margin: 0; margin: 0;
} }
} }
.ballot-button {
grid-column: 2;
}
} }
.candidate-list-separator { .candidate-list-separator {

View File

@ -1,9 +1,14 @@
<os-poll-form [data]="pollData" [pollMethods]="AssignmentPollMethodVerbose" [percentBases]="AssignmentPollPercentBaseVerbose" #pollForm></os-poll-form> <os-poll-form
[data]="pollData"
[pollMethods]="AssignmentPollMethodVerbose"
[percentBases]="AssignmentPollPercentBaseVerbose"
#pollForm
></os-poll-form>
<!-- Analog voting --> <!-- Analog voting -->
<ng-container *ngIf="isAnalogPoll"> <ng-container *ngIf="isAnalogPoll && dialogVoteForm">
<form [formGroup]="dialogVoteForm"> <form [formGroup]="dialogVoteForm">
<!-- Candidates --> <!-- Candidates Values -->
<div formGroupName="options"> <div formGroupName="options">
<div *ngFor="let option of options" class="votes-grid"> <div *ngFor="let option of options" class="votes-grid">
<div> <div>
@ -24,6 +29,8 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Sum Values -->
<div *ngFor="let value of sumValues" class="votes-grid"> <div *ngFor="let value of sumValues" class="votes-grid">
<div></div> <div></div>
<os-check-input <os-check-input
@ -34,6 +41,27 @@
[formControlName]="value" [formControlName]="value"
></os-check-input> ></os-check-input>
</div> </div>
<!-- Global Values -->
<div>
<os-check-input
*ngIf="globalNoEnabled"
placeholder="{{ 'Global No' | translate }}"
[checkboxValue]="-1"
inputType="number"
[checkboxLabel]="'majority' | translate"
formControlName="amount_global_no"
></os-check-input>
<os-check-input
*ngIf="globalAbstainEnabled"
placeholder="{{ 'Global Abstain' | translate }}"
[checkboxValue]="-1"
inputType="number"
[checkboxLabel]="'majority' | translate"
formControlName="amount_global_abstain"
></os-check-input>
</div>
</form> </form>
<!-- Publish Check --> <!-- Publish Check -->
@ -53,7 +81,8 @@
<button <button
mat-button mat-button
(click)="submitPoll()" (click)="submitPoll()"
[disabled]="!pollForm.contentForm || pollForm.contentForm.invalid || dialogVoteForm.invalid" *ngIf="pollForm && dialogVoteForm && pollForm.contentForm"
[disabled]="pollForm.contentForm.invalid || dialogVoteForm.invalid"
> >
<span translate>Save</span> <span translate>Save</span>
</button> </button>

View File

@ -5,6 +5,7 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { AssignmentPollMethod } from 'app/shared/models/assignments/assignment-poll'; import { AssignmentPollMethod } from 'app/shared/models/assignments/assignment-poll';
import { PollType } from 'app/shared/models/poll/base-poll'; import { PollType } from 'app/shared/models/poll/base-poll';
@ -59,6 +60,9 @@ export class AssignmentPollDialogComponent extends BasePollDialogComponent<ViewA
public options: OptionsObject; public options: OptionsObject;
public globalNoEnabled: boolean;
public globalAbstainEnabled: boolean;
public get isAnalogPoll(): boolean { public get isAnalogPoll(): boolean {
return ( return (
this.pollForm && this.pollForm &&
@ -104,7 +108,7 @@ export class AssignmentPollDialogComponent extends BasePollDialogComponent<ViewA
} }
this.subscriptions.push( this.subscriptions.push(
this.pollForm.contentForm.get('pollmethod').valueChanges.subscribe(() => { this.pollForm.contentForm.valueChanges.pipe(debounceTime(150), distinctUntilChanged()).subscribe(() => {
this.createDialog(); this.createDialog();
}) })
); );
@ -112,6 +116,8 @@ export class AssignmentPollDialogComponent extends BasePollDialogComponent<ViewA
private setAnalogPollValues(): void { private setAnalogPollValues(): void {
const pollmethod = this.pollForm.contentForm.get('pollmethod').value; const pollmethod = this.pollForm.contentForm.get('pollmethod').value;
this.globalNoEnabled = this.pollForm.contentForm.get('global_no').value;
this.globalAbstainEnabled = this.pollForm.contentForm.get('global_abstain').value;
const analogPollValues: VoteValue[] = ['Y']; const analogPollValues: VoteValue[] = ['Y'];
if (pollmethod !== AssignmentPollMethod.Votes) { if (pollmethod !== AssignmentPollMethod.Votes) {
analogPollValues.push('N'); analogPollValues.push('N');
@ -127,7 +133,9 @@ export class AssignmentPollDialogComponent extends BasePollDialogComponent<ViewA
options: {}, options: {},
votesvalid: data.votesvalid, votesvalid: data.votesvalid,
votesinvalid: data.votesinvalid, votesinvalid: data.votesinvalid,
votescast: data.votescast votescast: data.votescast,
amount_global_no: data.amount_global_no,
amount_global_abstain: data.amount_global_abstain
}; };
for (const option of data.options) { for (const option of data.options) {
const votes: any = {}; const votes: any = {};
@ -165,6 +173,8 @@ export class AssignmentPollDialogComponent extends BasePollDialogComponent<ViewA
) )
})) }))
), ),
amount_global_no: ['', [Validators.min(-2)]],
amount_global_abstain: ['', [Validators.min(-2)]],
// insert all used global fields // insert all used global fields
...this.sumValues.mapToObject(sumValue => ({ ...this.sumValues.mapToObject(sumValue => ({
[sumValue]: ['', [Validators.min(-2)]] [sumValue]: ['', [Validators.min(-2)]]

View File

@ -54,7 +54,7 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponent<ViewAssig
} }
public ngOnInit(): void { public ngOnInit(): void {
if (this.poll && this.poll.user_has_not_voted) { if (this.poll && !this.poll.user_has_voted) {
this.alreadyVoted = false; this.alreadyVoted = false;
this.defineVoteOptions(); this.defineVoteOptions();
} else { } else {

View File

@ -39,6 +39,19 @@ export class ViewAssignmentPoll extends ViewBasePoll<AssignmentPoll, AssignmentP
public readonly tableChartData: Map<string, BehaviorSubject<ChartData>> = new Map(); public readonly tableChartData: Map<string, BehaviorSubject<ChartData>> = new Map();
public readonly pollClassType = PollClassType.Assignment; public readonly pollClassType = PollClassType.Assignment;
protected globalVoteKeys: VotingResult[] = [
{
vote: 'amount_global_no',
showPercent: false,
hide: this.poll.amount_global_no === -2 || this.poll.amount_global_no === 0
},
{
vote: 'amount_global_abstain',
showPercent: false,
hide: this.poll.amount_global_abstain === -2 || this.poll.amount_global_abstain === 0
}
];
public get pollmethodVerbose(): string { public get pollmethodVerbose(): string {
return AssignmentPollMethodVerbose[this.pollmethod]; return AssignmentPollMethodVerbose[this.pollmethod];
} }
@ -98,8 +111,31 @@ export class ViewAssignmentPoll extends ViewBasePoll<AssignmentPoll, AssignmentP
] ]
})) }))
); );
tableData.push(
...this.globalVoteKeys
.filter(key => {
return !key.hide;
})
.map(key => ({
votingOption: key.vote,
class: 'sums',
value: [
{
amount: this[key.vote],
hide: key.hide,
showPercent: key.showPercent
} as VotingResult
]
}))
);
return tableData; return tableData;
} }
protected getDecimalFields(): string[] {
return AssignmentPoll.DECIMAL_FIELDS;
}
} }
export interface ViewAssignmentPoll extends AssignmentPoll { export interface ViewAssignmentPoll extends AssignmentPoll {

View File

@ -57,12 +57,12 @@ export class MotionPollDialogComponent extends BasePollDialogComponent<ViewMotio
*/ */
private createDialog(): void { private createDialog(): void {
this.dialogVoteForm = this.fb.group({ this.dialogVoteForm = this.fb.group({
Y: [0, [Validators.min(-2)]], Y: ['', [Validators.min(-2)]],
N: [0, [Validators.min(-2)]], N: ['', [Validators.min(-2)]],
A: [0, [Validators.min(-2)]], A: ['', [Validators.min(-2)]],
votesvalid: [0, [Validators.min(-2)]], votesvalid: ['', [Validators.min(-2)]],
votesinvalid: [0, [Validators.min(-2)]], votesinvalid: ['', [Validators.min(-2)]],
votescast: [0, [Validators.min(-2)]] votescast: ['', [Validators.min(-2)]]
}); });
if (this.pollData.poll) { if (this.pollData.poll) {

View File

@ -1,4 +1,4 @@
<ng-container *ngIf="poll"> <ng-container *ngIf="poll && !poll.user_has_voted; else userHasVotes">
<div *osPerms="'motions.can_manage_polls'; and: poll && poll.isStarted"> <div *osPerms="'motions.can_manage_polls'; and: poll && poll.isStarted">
<os-poll-progress [poll]="poll"></os-poll-progress> <os-poll-progress [poll]="poll"></os-poll-progress>
</div> </div>
@ -16,3 +16,11 @@
</div> </div>
</div> </div>
</ng-container> </ng-container>
<ng-template #userHasVotes>
<div class="user-has-voted">
<os-icon-container icon="check">
{{ 'You already voted on this poll' | translate }}
</os-icon-container>
</div>
</ng-template>

View File

@ -17,6 +17,15 @@
} }
} }
.user-has-voted {
display: flex;
> * {
margin-top: 1em;
margin-left: auto;
margin-right: auto;
}
}
.voted-yes { .voted-yes {
background-color: $votes-yes-color; background-color: $votes-yes-color;
color: $vote-active-color; color: $vote-active-color;

View File

@ -22,7 +22,15 @@ export interface PollTableData {
} }
export interface VotingResult { export interface VotingResult {
vote?: 'yes' | 'no' | 'abstain' | 'votesvalid' | 'votesinvalid' | 'votescast'; vote?:
| 'yes'
| 'no'
| 'abstain'
| 'votesvalid'
| 'votesinvalid'
| 'votescast'
| 'amount_global_no'
| 'amount_global_abstain';
amount?: number; amount?: number;
icon?: string; icon?: string;
hide?: boolean; hide?: boolean;
@ -176,18 +184,6 @@ export abstract class ViewBasePoll<
public canBeVotedFor: () => boolean; public canBeVotedFor: () => boolean;
public get user_has_voted_invalid(): boolean {
return this.options.some(option => option.user_has_voted) && !this.user_has_voted_valid;
}
public get user_has_voted_valid(): boolean {
return this.options.every(option => option.user_has_voted);
}
public get user_has_not_voted(): boolean {
return this.options.every(option => !option.user_has_voted);
}
public abstract getSlide(): ProjectorElementBuildDeskriptor; public abstract getSlide(): ProjectorElementBuildDeskriptor;
public abstract getContentObject(): BaseViewModel; public abstract getContentObject(): BaseViewModel;

View File

@ -31,6 +31,28 @@ class Migration(migrations.Migration):
name="global_no", name="global_no",
field=models.BooleanField(default=True), field=models.BooleanField(default=True),
), ),
migrations.AddField(
model_name="assignmentpoll",
name="db_amount_global_abstain",
field=models.DecimalField(
blank=True,
decimal_places=6,
max_digits=15,
null=True,
validators=[django.core.validators.MinValueValidator(Decimal("-2"))],
),
),
migrations.AddField(
model_name="assignmentpoll",
name="db_amount_global_no",
field=models.DecimalField(
blank=True,
decimal_places=6,
max_digits=15,
null=True,
validators=[django.core.validators.MinValueValidator(Decimal("-2"))],
),
),
migrations.AddField( migrations.AddField(
model_name="assignmentpoll", model_name="assignmentpoll",
name="groups", name="groups",
@ -76,6 +98,11 @@ class Migration(migrations.Migration):
default=1, validators=[django.core.validators.MinValueValidator(1)] default=1, validators=[django.core.validators.MinValueValidator(1)]
), ),
), ),
migrations.AddField(
model_name="assignmentpoll",
name="voted",
field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL),
),
migrations.AddField( migrations.AddField(
model_name="assignmentvote", model_name="assignmentvote",
name="user", name="user",
@ -129,15 +156,6 @@ class Migration(migrations.Migration):
name="number_poll_candidates", name="number_poll_candidates",
field=models.BooleanField(default=False), field=models.BooleanField(default=False),
), ),
migrations.AddField(
model_name="assignmentoption",
name="voted",
field=models.ManyToManyField(
blank=True,
to=settings.AUTH_USER_MODEL,
related_name="assignmentoption_voted",
),
),
migrations.AlterField( migrations.AlterField(
model_name="assignment", model_name="assignment",
name="poll_description_default", name="poll_description_default",

View File

@ -254,14 +254,14 @@ class AssignmentOptionManager(BaseManager):
def get_prefetched_queryset(self, *args, **kwargs): def get_prefetched_queryset(self, *args, **kwargs):
""" """
Returns the normal queryset with all voted users. In the background we Returns the normal queryset. In the background we
join and prefetch all related models. join and prefetch all related models.
""" """
return ( return (
super() super()
.get_prefetched_queryset(*args, **kwargs) .get_prefetched_queryset(*args, **kwargs)
.select_related("user", "poll") .select_related("user", "poll")
.prefetch_related("voted", "votes") .prefetch_related("votes")
) )
@ -277,9 +277,6 @@ class AssignmentOption(RESTModelMixin, BaseOption):
user = models.ForeignKey( user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=SET_NULL_AND_AUTOUPDATE, null=True settings.AUTH_USER_MODEL, on_delete=SET_NULL_AND_AUTOUPDATE, null=True
) )
voted = models.ManyToManyField(
settings.AUTH_USER_MODEL, blank=True, related_name="assignmentoption_voted"
)
weight = models.IntegerField(default=0) weight = models.IntegerField(default=0)
class Meta: class Meta:
@ -301,7 +298,7 @@ class AssignmentPollManager(BaseManager):
.get_prefetched_queryset(*args, **kwargs) .get_prefetched_queryset(*args, **kwargs)
.select_related("assignment") .select_related("assignment")
.prefetch_related( .prefetch_related(
"options", "options__user", "options__votes", "options__voted", "groups" "options", "options__user", "options__votes", "voted", "groups"
) )
) )
@ -348,7 +345,23 @@ class AssignmentPoll(RESTModelMixin, BasePoll):
) )
global_abstain = models.BooleanField(default=True) global_abstain = models.BooleanField(default=True)
db_amount_global_abstain = models.DecimalField(
null=True,
blank=True,
default=Decimal("0"),
validators=[MinValueValidator(Decimal("-2"))],
max_digits=15,
decimal_places=6,
)
global_no = models.BooleanField(default=True) global_no = models.BooleanField(default=True)
db_amount_global_no = models.DecimalField(
null=True,
blank=True,
default=Decimal("0"),
validators=[MinValueValidator(Decimal("-2"))],
max_digits=15,
decimal_places=6,
)
votes_amount = models.IntegerField(default=1, validators=[MinValueValidator(1)]) votes_amount = models.IntegerField(default=1, validators=[MinValueValidator(1)])
""" For "votes" mode: The amount of votes a voter can give. """ """ For "votes" mode: The amount of votes a voter can give. """
@ -358,26 +371,49 @@ class AssignmentPoll(RESTModelMixin, BasePoll):
class Meta: class Meta:
default_permissions = () default_permissions = ()
@property def get_amount_global_abstain(self):
def amount_global_no(self): if not self.global_abstain:
if self.pollmethod != AssignmentPoll.POLLMETHOD_VOTES or not self.global_no:
return None return None
no_sum = Decimal(0) elif (
for option in self.options.all(): self.type == self.TYPE_ANALOG
no_sum += option.no or self.pollmethod == AssignmentPoll.POLLMETHOD_VOTES
return no_sum
@property
def amount_global_abstain(self):
if (
self.pollmethod != AssignmentPoll.POLLMETHOD_VOTES
or not self.global_abstain
): ):
return self.db_amount_global_abstain
else:
return None return None
abstain_sum = Decimal(0)
for option in self.options.all(): def set_amount_global_abstain(self, value):
abstain_sum += option.abstain if (
return abstain_sum self.type != self.TYPE_ANALOG
and self.pollmethod != AssignmentPoll.POLLMETHOD_VOTES
):
raise ValueError("Do not set amount_global_abstain YN/YNA polls")
self.db_amount_global_abstain = value
amount_global_abstain = property(
get_amount_global_abstain, set_amount_global_abstain
)
def get_amount_global_no(self):
if not self.global_no:
return None
elif (
self.type == self.TYPE_ANALOG
or self.pollmethod == AssignmentPoll.POLLMETHOD_VOTES
):
return self.db_amount_global_no
else:
return None
def set_amount_global_no(self, value):
if (
self.type != self.TYPE_ANALOG
and self.pollmethod != AssignmentPoll.POLLMETHOD_VOTES
):
raise ValueError("Do not set amount_global_no YN/YNA polls")
self.db_amount_global_no = value
amount_global_no = property(get_amount_global_no, set_amount_global_no)
def create_options(self, skip_autoupdate=False): def create_options(self, skip_autoupdate=False):
related_users = AssignmentRelatedUser.objects.filter( related_users = AssignmentRelatedUser.objects.filter(
@ -404,3 +440,8 @@ class AssignmentPoll(RESTModelMixin, BasePoll):
pass pass
if not skip_autoupdate: if not skip_autoupdate:
inform_changed_data(self.assignment.list_of_speakers) inform_changed_data(self.assignment.list_of_speakers)
def reset(self):
self.db_amount_global_abstain = Decimal(0)
self.db_amount_global_no = Decimal(0)
super().reset()

View File

@ -2,6 +2,7 @@ from decimal import Decimal
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import transaction from django.db import transaction
from django.db.models import F
from openslides.poll.views import BaseOptionViewSet, BasePollViewSet, BaseVoteViewSet from openslides.poll.views import BaseOptionViewSet, BasePollViewSet, BaseVoteViewSet
from openslides.utils.auth import has_perm from openslides.utils.auth import has_perm
@ -283,6 +284,10 @@ class AssignmentPollViewSet(BasePollViewSet):
) )
super().perform_create(serializer) super().perform_create(serializer)
poll = AssignmentPoll.objects.get(pk=serializer.data["id"])
poll.db_amount_global_abstain = Decimal(0)
poll.db_amount_global_no = Decimal(0)
poll.save()
def handle_analog_vote(self, data, poll, user): def handle_analog_vote(self, data, poll, user):
for field in ["votesvalid", "votesinvalid", "votescast"]: for field in ["votesvalid", "votesinvalid", "votescast"]:
@ -291,9 +296,13 @@ class AssignmentPollViewSet(BasePollViewSet):
global_no_enabled = ( global_no_enabled = (
poll.global_no and poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES poll.global_no and poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES
) )
if global_no_enabled:
poll.amount_global_no = data.get("amount_global_no", Decimal(0))
global_abstain_enabled = ( global_abstain_enabled = (
poll.global_abstain and poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES poll.global_abstain and poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES
) )
if global_abstain_enabled:
poll.amount_global_abstain = data.get("amount_global_abstain", Decimal(0))
options = poll.get_options() options = poll.get_options()
options_data = data.get("options") options_data = data.get("options")
@ -323,21 +332,8 @@ class AssignmentPollViewSet(BasePollViewSet):
) )
vote_obj.weight = vote["A"] vote_obj.weight = vote["A"]
vote_obj.save() vote_obj.save()
inform_changed_data(option)
# Create votes for global no and global abstain
first_option = options.first()
if "global_no" in data and global_no_enabled:
vote_obj, _ = AssignmentVote.objects.get_or_create(
option=first_option, value="N"
)
vote_obj.weight = data["votescast"]
vote_obj.save()
if "global_abstain" in data and global_abstain_enabled:
vote_obj, _ = AssignmentVote.objects.get_or_create(
option=first_option, value="A"
)
vote_obj.weight = data["votescast"]
vote_obj.save()
poll.save() poll.save()
def validate_vote_data(self, data, poll, user): def validate_vote_data(self, data, poll, user):
@ -347,7 +343,7 @@ class AssignmentPollViewSet(BasePollViewSet):
{ {
"options": {<option_id>: {"Y": <amount>, ["N": <amount>], ["A": <amount>] }}, "options": {<option_id>: {"Y": <amount>, ["N": <amount>], ["A": <amount>] }},
["votesvalid": <amount>], ["votesinvalid": <amount>], ["votescast": <amount>], ["votesvalid": <amount>], ["votesinvalid": <amount>], ["votescast": <amount>],
["global_no": <amount>], ["global_abstain": <amount>] ["amount_global_no": <amount>], ["amount_global_abstain": <amount>]
} }
All amounts are decimals as strings All amounts are decimals as strings
required fields per pollmethod: required fields per pollmethod:
@ -363,13 +359,11 @@ class AssignmentPollViewSet(BasePollViewSet):
- amounts must be integer numbers >= 0. - amounts must be integer numbers >= 0.
- ids should be integers of valid option ids for this poll - ids should be integers of valid option ids for this poll
- amounts must be 0 or 1, if poll.allow_multiple_votes_per_candidate is False - amounts must be 0 or 1, if poll.allow_multiple_votes_per_candidate is False
- The sum of all amounts must be grater then 0 and <= poll.votes_amount - The sum of all amounts must be grater than 0 and <= poll.votes_amount
YN/YNA: YN/YNA:
{<option_id>: 'Y' | 'N' [|'A']} {<option_id>: 'Y' | 'N' [|'A']}
- 'A' is only allowed in YNA pollmethod - 'A' is only allowed in YNA pollmethod
Votes for all options have to be given
""" """
if poll.type == AssignmentPoll.TYPE_ANALOG: if poll.type == AssignmentPoll.TYPE_ANALOG:
if not isinstance(data, dict): if not isinstance(data, dict):
@ -403,10 +397,14 @@ class AssignmentPollViewSet(BasePollViewSet):
poll.global_abstain poll.global_abstain
and poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES and poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES
) )
if ("global_no" in data and global_no_enabled) or ( if "amount_global_abstain" in data and global_abstain_enabled:
"global_abstain" in data and global_abstain_enabled data["amount_global_abstain"] = self.parse_vote_value(
): data, "amount_global_abstain"
data["votescast"] = self.parse_vote_value(data, "votescast") )
if "amount_global_no" in data and global_no_enabled:
data["amount_global_no"] = self.parse_vote_value(
data, "amount_global_no"
)
else: else:
if poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES: if poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES:
@ -415,6 +413,10 @@ class AssignmentPollViewSet(BasePollViewSet):
for option_id, amount in data.items(): for option_id, amount in data.items():
if not is_int(option_id): if not is_int(option_id):
raise ValidationError({"detail": "Each id must be an int."}) raise ValidationError({"detail": "Each id must be an int."})
if not AssignmentOption.objects.filter(id=option_id).exists():
raise ValidationError(
{"detail": f"Option {option_id} does not exist."}
)
if not is_int(amount): if not is_int(amount):
raise ValidationError( raise ValidationError(
{"detail": "Each amounts must be int"} {"detail": "Each amounts must be int"}
@ -456,6 +458,10 @@ class AssignmentPollViewSet(BasePollViewSet):
for option_id, value in data.items(): for option_id, value in data.items():
if not is_int(option_id): if not is_int(option_id):
raise ValidationError({"detail": "Keys must be int"}) raise ValidationError({"detail": "Keys must be int"})
if not AssignmentOption.objects.filter(id=option_id).exists():
raise ValidationError(
{"detail": f"Option {option_id} does not exist."}
)
if ( if (
poll.pollmethod == AssignmentPoll.POLLMETHOD_YNA poll.pollmethod == AssignmentPoll.POLLMETHOD_YNA
and value not in ("Y", "N", "A",) and value not in ("Y", "N", "A",)
@ -471,33 +477,6 @@ class AssignmentPollViewSet(BasePollViewSet):
options_data = data options_data = data
db_option_ids = set(option.id for option in poll.get_options())
data_option_ids = set(int(option_id) for option_id in options_data.keys())
# Just for named/pseudoanonymous with YN/YNA skip the all-options-given check
if poll.type not in (
AssignmentPoll.TYPE_NAMED,
AssignmentPoll.TYPE_PSEUDOANONYMOUS,
) or poll.pollmethod not in (
AssignmentPoll.POLLMETHOD_YN,
AssignmentPoll.POLLMETHOD_YNA,
):
# Check if all options were given
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): def create_votes_type_votes(self, data, poll, user):
""" """
Helper function for handle_(named|pseudoanonymous)_vote Helper function for handle_(named|pseudoanonymous)_vote
@ -508,9 +487,6 @@ class AssignmentPollViewSet(BasePollViewSet):
for option_id, amount in data.items(): for option_id, amount in data.items():
# Add user to the option's voted array # Add user to the option's voted array
option = options.get(pk=option_id) option = options.get(pk=option_id)
option.voted.add(user)
inform_changed_data(option)
# skip creating votes with empty weights # skip creating votes with empty weights
if amount == 0: if amount == 0:
continue continue
@ -519,16 +495,15 @@ class AssignmentPollViewSet(BasePollViewSet):
) )
inform_changed_data(vote, no_delete_on_restriction=True) inform_changed_data(vote, no_delete_on_restriction=True)
else: # global_no or global_abstain else: # global_no or global_abstain
option = options.order_by( if data == "A":
"pk" poll.amount_global_abstain = F("db_amount_global_abstain") + 1
).first() # order by is important to always get elif data == "N":
# the correct "first" option poll.amount_global_no = F("db_amount_global_no") + 1
option.voted.add(user) else:
inform_changed_data(option) raise RuntimeError("This should not happen")
vote = AssignmentVote.objects.create( poll.save()
option=option, user=user, weight=Decimal(poll.votes_amount), value=data,
) poll.voted.add(user)
inform_changed_data(vote, no_delete_on_restriction=True)
def create_votes_type_named_pseudoanonymous( def create_votes_type_named_pseudoanonymous(
self, data, poll, check_user, vote_user self, data, poll, check_user, vote_user
@ -537,51 +512,37 @@ class AssignmentPollViewSet(BasePollViewSet):
options = poll.get_options() options = poll.get_options()
for option_id, result in data.items(): for option_id, result in data.items():
option = options.get(pk=option_id) option = options.get(pk=option_id)
option.voted.add(check_user)
inform_changed_data(option)
vote = AssignmentVote.objects.create( vote = AssignmentVote.objects.create(
option=option, user=vote_user, value=result option=option, user=vote_user, value=result
) )
inform_changed_data(vote, no_delete_on_restriction=True) inform_changed_data(vote, no_delete_on_restriction=True)
inform_changed_data(option, no_delete_on_restriction=True)
poll.voted.add(check_user)
def handle_named_vote(self, data, poll, user): def handle_named_vote(self, data, poll, user):
if user in poll.voted.all():
raise ValidationError({"detail": "You have already voted"})
if poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES: if poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES:
# Instead of reusing all existing votes for the user, delete all previous votes
for vote in poll.get_votes().filter(user=user):
vote.delete()
self.create_votes_type_votes(data, poll, user) self.create_votes_type_votes(data, poll, user)
elif poll.pollmethod in ( elif poll.pollmethod in (
AssignmentPoll.POLLMETHOD_YN, AssignmentPoll.POLLMETHOD_YN,
AssignmentPoll.POLLMETHOD_YNA, AssignmentPoll.POLLMETHOD_YNA,
): ):
# Delete all votes for the given options
option_ids = list(data.keys())
for vote in AssignmentVote.objects.filter(
user=user, option_id__in=option_ids
):
vote.delete()
self.create_votes_type_named_pseudoanonymous(data, poll, user, user) self.create_votes_type_named_pseudoanonymous(data, poll, user, user)
def handle_pseudoanonymous_vote(self, data, poll, user): def handle_pseudoanonymous_vote(self, data, poll, user):
if poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES: if user in poll.voted.all():
# check if the user has already voted
for option in poll.get_options():
if user in option.voted.all():
raise ValidationError({"detail": "You have already voted"}) raise ValidationError({"detail": "You have already voted"})
if poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES:
self.create_votes_type_votes(data, poll, user) self.create_votes_type_votes(data, poll, user)
elif poll.pollmethod in ( elif poll.pollmethod in (
AssignmentPoll.POLLMETHOD_YN, AssignmentPoll.POLLMETHOD_YN,
AssignmentPoll.POLLMETHOD_YNA, AssignmentPoll.POLLMETHOD_YNA,
): ):
# Ensure, that the user has not voted any of the given options yet.
options = poll.get_options()
for option_id in data.keys():
option = options.get(pk=option_id)
if user in option.voted.all():
raise ValidationError(
{"detail": f"You have already voted for option {option.pk}"}
)
self.create_votes_type_named_pseudoanonymous(data, poll, user, None) self.create_votes_type_named_pseudoanonymous(data, poll, user, None)
def convert_option_data(self, poll, data): def convert_option_data(self, poll, data):

View File

@ -57,6 +57,11 @@ class Migration(migrations.Migration):
), ),
preserve_default=False, preserve_default=False,
), ),
migrations.AddField(
model_name="motionpoll",
name="voted",
field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL),
),
migrations.AddField( migrations.AddField(
model_name="motionvote", model_name="motionvote",
name="user", name="user",
@ -107,15 +112,6 @@ class Migration(migrations.Migration):
), ),
preserve_default=False, preserve_default=False,
), ),
migrations.AddField(
model_name="motionoption",
name="voted",
field=models.ManyToManyField(
blank=True,
to=settings.AUTH_USER_MODEL,
related_name="motionoption_voted",
),
),
migrations.AlterField( migrations.AlterField(
model_name="motionvote", model_name="motionvote",
name="option", name="option",

View File

@ -889,14 +889,14 @@ class MotionOptionManager(BaseManager):
def get_prefetched_queryset(self, *args, **kwargs): def get_prefetched_queryset(self, *args, **kwargs):
""" """
Returns the normal queryset with all voted users. In the background we Returns the normal queryset. In the background we
join and prefetch all related models. join and prefetch all related models.
""" """
return ( return (
super() super()
.get_prefetched_queryset(*args, **kwargs) .get_prefetched_queryset(*args, **kwargs)
.select_related("poll") .select_related("poll")
.prefetch_related("voted", "votes") .prefetch_related("votes")
) )
@ -909,9 +909,6 @@ class MotionOption(RESTModelMixin, BaseOption):
poll = models.ForeignKey( poll = models.ForeignKey(
"MotionPoll", related_name="options", on_delete=CASCADE_AND_AUTOUPDATE "MotionPoll", related_name="options", on_delete=CASCADE_AND_AUTOUPDATE
) )
voted = models.ManyToManyField(
settings.AUTH_USER_MODEL, blank=True, related_name="motionoption_voted"
)
class Meta: class Meta:
default_permissions = () default_permissions = ()
@ -931,7 +928,7 @@ class MotionPollManager(BaseManager):
super() super()
.get_prefetched_queryset(*args, **kwargs) .get_prefetched_queryset(*args, **kwargs)
.select_related("motion") .select_related("motion")
.prefetch_related("options", "options__votes", "options__voted", "groups") .prefetch_related("options", "options__votes", "voted", "groups")
) )

View File

@ -1224,26 +1224,27 @@ class MotionPollViewSet(BasePollViewSet):
raise ValidationError("Data must be Y or N") raise ValidationError("Data must be Y or N")
if poll.type == MotionPoll.TYPE_PSEUDOANONYMOUS: if poll.type == MotionPoll.TYPE_PSEUDOANONYMOUS:
if user in poll.options.get().voted.all(): if user in poll.voted.all():
raise ValidationError("You already voted on this poll") raise ValidationError("You already voted on this poll")
def handle_named_vote(self, data, poll, user): def handle_named_vote(self, data, poll, user):
option = poll.options.get() option = poll.options.get()
vote, _ = MotionVote.objects.get_or_create(user=user, option=option) vote, _ = MotionVote.objects.get_or_create(user=user, option=option)
self.handle_named_and_pseudoanonymous_vote(vote, data, user, option) self.handle_named_and_pseudoanonymous_vote(data, user, poll, option, vote)
def handle_pseudoanonymous_vote(self, data, poll, user): def handle_pseudoanonymous_vote(self, data, poll, user):
option = poll.options.get() option = poll.options.get()
vote = MotionVote.objects.create(user=None, option=option) vote = MotionVote.objects.create(user=None, option=option)
self.handle_named_and_pseudoanonymous_vote(vote, data, user, option) self.handle_named_and_pseudoanonymous_vote(data, user, poll, option, vote)
def handle_named_and_pseudoanonymous_vote(self, vote, data, user, option): def handle_named_and_pseudoanonymous_vote(self, data, user, poll, option, vote):
vote.value = data vote.value = data
vote.weight = Decimal("1") vote.weight = Decimal("1")
vote.save(no_delete_on_restriction=True) vote.save(no_delete_on_restriction=True)
inform_changed_data(option)
option.voted.add(user) poll.voted.add(user)
option.save() poll.save()
class MotionOptionViewSet(BaseOptionViewSet): class MotionOptionViewSet(BaseOptionViewSet):

View File

@ -6,40 +6,6 @@ from ..utils.access_permissions import BaseAccessPermissions
from ..utils.auth import async_has_perm from ..utils.auth import async_has_perm
class BasePollAccessPermissions(BaseAccessPermissions):
manage_permission = "" # set by subclass
additional_fields: List[str] = []
""" Add fields to be removed from each unpublished poll """
async def get_restricted_data(
self, full_data: List[Dict[str, Any]], user_id: int
) -> List[Dict[str, Any]]:
"""
Poll-managers have full access, even during an active poll.
Non-published polls will be restricted:
- Remove votes* values from the poll
- Remove yes/no/abstain fields from options
- Remove fields given in self.assitional_fields from the poll
"""
if await async_has_perm(user_id, self.manage_permission):
data = full_data
else:
data = []
for poll in full_data:
if poll["state"] != BasePoll.STATE_PUBLISHED:
poll = json.loads(
json.dumps(poll)
) # copy, so we can remove some fields.
del poll["votesvalid"]
del poll["votesinvalid"]
del poll["votescast"]
for field in self.additional_fields:
del poll[field]
data.append(poll)
return data
class BaseVoteAccessPermissions(BaseAccessPermissions): class BaseVoteAccessPermissions(BaseAccessPermissions):
manage_permission = "" # set by subclass manage_permission = "" # set by subclass
@ -71,10 +37,6 @@ class BaseOptionAccessPermissions(BaseAccessPermissions):
self, full_data: List[Dict[str, Any]], user_id: int self, full_data: List[Dict[str, Any]], user_id: int
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
# add has_voted for all users to check whether op has voted
for option in full_data:
option["user_has_voted"] = user_id in option["voted_id"]
if await async_has_perm(user_id, self.manage_permission): if await async_has_perm(user_id, self.manage_permission):
data = full_data data = full_data
else: else:
@ -87,6 +49,45 @@ class BaseOptionAccessPermissions(BaseAccessPermissions):
del option["yes"] del option["yes"]
del option["no"] del option["no"]
del option["abstain"] del option["abstain"]
del option["voted_id"]
data.append(option) data.append(option)
return data return data
class BasePollAccessPermissions(BaseAccessPermissions):
manage_permission = "" # set by subclass
additional_fields: List[str] = []
""" Add fields to be removed from each unpublished poll """
async def get_restricted_data(
self, full_data: List[Dict[str, Any]], user_id: int
) -> List[Dict[str, Any]]:
"""
Poll-managers have full access, even during an active poll.
Non-published polls will be restricted:
- Remove votes* values from the poll
- Remove yes/no/abstain fields from options
- Remove fields given in self.assitional_fields from the poll
"""
# add has_voted for all users to check whether op has voted
for poll in full_data:
poll["user_has_voted"] = user_id in poll["voted_id"]
if await async_has_perm(user_id, self.manage_permission):
data = full_data
else:
data = []
for poll in full_data:
if poll["state"] != BasePoll.STATE_PUBLISHED:
poll = json.loads(
json.dumps(poll)
) # copy, so we can remove some fields.
del poll["votesvalid"]
del poll["votesinvalid"]
del poll["votescast"]
del poll["voted_id"]
for field in self.additional_fields:
del poll[field]
data.append(poll)
return data

View File

@ -1,5 +1,5 @@
from decimal import Decimal from decimal import Decimal
from typing import Iterable, Optional, Set, Tuple, Type from typing import Iterable, Optional, Tuple, Type
from django.conf import settings from django.conf import settings
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
@ -35,8 +35,7 @@ class BaseVote(models.Model):
class BaseOption(models.Model): class BaseOption(models.Model):
""" """
All subclasses must have poll attribute with the related name "options". Also All subclasses must have poll attribute with the related name "options"
they must have a "voted" relation to users.
""" """
vote_class: Optional[Type["BaseVote"]] = None vote_class: Optional[Type["BaseVote"]] = None
@ -86,8 +85,6 @@ class BaseOption(models.Model):
vote.save() vote.save()
def reset(self): def reset(self):
self.voted.clear()
# Delete votes # Delete votes
votes = self.get_votes() votes = self.get_votes()
votes_id = [vote.id for vote in votes] votes_id = [vote.id for vote in votes]
@ -126,6 +123,7 @@ class BasePoll(models.Model):
title = models.CharField(max_length=255, blank=True, null=False) title = models.CharField(max_length=255, blank=True, null=False)
groups = models.ManyToManyField(settings.AUTH_GROUP_MODEL, blank=True) groups = models.ManyToManyField(settings.AUTH_GROUP_MODEL, blank=True)
voted = models.ManyToManyField(settings.AUTH_USER_MODEL, blank=True)
db_votesvalid = models.DecimalField( db_votesvalid = models.DecimalField(
null=True, null=True,
@ -186,7 +184,7 @@ class BasePoll(models.Model):
if self.type == self.TYPE_ANALOG: if self.type == self.TYPE_ANALOG:
return self.db_votesvalid return self.db_votesvalid
else: else:
return Decimal(self.amount_valid_votes()) return Decimal(self.amount_users_voted())
def set_votesvalid(self, value): def set_votesvalid(self, value):
if self.type != self.TYPE_ANALOG: if self.type != self.TYPE_ANALOG:
@ -199,7 +197,7 @@ class BasePoll(models.Model):
if self.type == self.TYPE_ANALOG: if self.type == self.TYPE_ANALOG:
return self.db_votesinvalid return self.db_votesinvalid
else: else:
return Decimal(self.amount_invalid_votes()) return Decimal(0)
def set_votesinvalid(self, value): def set_votesinvalid(self, value):
if self.type != self.TYPE_ANALOG: if self.type != self.TYPE_ANALOG:
@ -212,7 +210,7 @@ class BasePoll(models.Model):
if self.type == self.TYPE_ANALOG: if self.type == self.TYPE_ANALOG:
return self.db_votescast return self.db_votescast
else: else:
return Decimal(self.amount_voted_users()) return Decimal(self.amount_users_voted())
def set_votescast(self, value): def set_votescast(self, value):
if self.type != self.TYPE_ANALOG: if self.type != self.TYPE_ANALOG:
@ -221,32 +219,8 @@ class BasePoll(models.Model):
votescast = property(get_votescast, set_votescast) votescast = property(get_votescast, set_votescast)
def get_user_ids_with_valid_votes(self): def amount_users_voted(self):
if self.get_options().count(): return len(self.voted.all())
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):
user_ids: Set[int] = set()
for option in self.get_options():
user_ids.update(map(lambda u: u.id, option.voted.all()))
return list(user_ids)
def amount_valid_votes(self):
return len(self.get_user_ids_with_valid_votes())
def amount_invalid_votes(self):
return self.amount_voted_users() - self.amount_valid_votes()
def amount_voted_users(self):
return len(self.get_all_voted_user_ids())
def create_options(self): def create_options(self):
""" Should be called after creation of this model. """ """ Should be called after creation of this model. """
@ -284,6 +258,8 @@ class BasePoll(models.Model):
for option in self.get_options(): for option in self.get_options():
option.reset() option.reset()
self.voted.clear()
# Reset state # Reset state
self.state = BasePoll.STATE_CREATED self.state = BasePoll.STATE_CREATED
if self.type == self.TYPE_ANALOG: if self.type == self.TYPE_ANALOG:

View File

@ -22,7 +22,7 @@ class BaseVoteSerializer(ModelSerializer):
return vote.option.poll.state return vote.option.poll.state
BASE_OPTION_FIELDS = ("id", "yes", "no", "abstain", "poll_id", "pollstate", "voted") BASE_OPTION_FIELDS = ("id", "yes", "no", "abstain", "poll_id", "pollstate")
class BaseOptionSerializer(ModelSerializer): class BaseOptionSerializer(ModelSerializer):
@ -31,7 +31,6 @@ class BaseOptionSerializer(ModelSerializer):
abstain = DecimalField( abstain = DecimalField(
max_digits=15, decimal_places=6, min_value=-2, read_only=True max_digits=15, decimal_places=6, min_value=-2, read_only=True
) )
voted = IdPrimaryKeyRelatedField(many=True, read_only=True)
pollstate = SerializerMethodField() pollstate = SerializerMethodField()
@ -51,6 +50,7 @@ BASE_POLL_FIELDS = (
"id", "id",
"onehundred_percent_base", "onehundred_percent_base",
"majority_method", "majority_method",
"voted",
) )
@ -60,6 +60,7 @@ class BasePollSerializer(ModelSerializer):
many=True, required=False, queryset=get_group_model().objects.all() many=True, required=False, queryset=get_group_model().objects.all()
) )
options = IdPrimaryKeyRelatedField(many=True, read_only=True) options = IdPrimaryKeyRelatedField(many=True, read_only=True)
voted = IdPrimaryKeyRelatedField(many=True, read_only=True)
votesvalid = DecimalField( votesvalid = DecimalField(
max_digits=15, decimal_places=6, min_value=-2, read_only=True max_digits=15, decimal_places=6, min_value=-2, read_only=True

View File

@ -175,12 +175,6 @@ class BasePollViewSet(ModelViewSet):
@detail_route(methods=["POST"]) @detail_route(methods=["POST"])
def reset(self, request, pk): def reset(self, request, pk):
poll = self.get_object() poll = self.get_object()
if poll.state not in (BasePoll.STATE_FINISHED, BasePoll.STATE_PUBLISHED):
raise ValidationError(
{"detail": "You can only reset this poll after it is finished"}
)
poll.reset() poll.reset()
return Response() return Response()

View File

@ -53,12 +53,11 @@ def test_assignment_option_db_queries():
""" """
Tests that only the following db queries are done: Tests that only the following db queries are done:
* 1 request to get the options, * 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, * 1 request to get all votes for all options,
= 3 queries = 2 queries
""" """
create_assignment_polls() create_assignment_polls()
assert count_queries(AssignmentOption.get_elements)() == 3 assert count_queries(AssignmentOption.get_elements)() == 2
def create_assignment_polls(): def create_assignment_polls():
@ -93,13 +92,13 @@ def create_assignment_polls():
username=f"test_username_{i}{j}", username=f"test_username_{i}{j}",
password="test_password_kbzj5L8ZtVxBllZzoW6D", password="test_password_kbzj5L8ZtVxBllZzoW6D",
) )
poll.voted.add(user)
for option in poll.options.all(): for option in poll.options.all():
weight = random.randint(0, 10) weight = random.randint(0, 10)
if weight > 0: if weight > 0:
AssignmentVote.objects.create( AssignmentVote.objects.create(
user=user, option=option, value="Y", weight=Decimal(weight) user=user, option=option, value="Y", weight=Decimal(weight)
) )
option.voted.add(user)
class CreateAssignmentPoll(TestCase): class CreateAssignmentPoll(TestCase):
@ -110,7 +109,7 @@ class CreateAssignmentPoll(TestCase):
self.assignment.add_candidate(self.admin) self.assignment.add_candidate(self.admin)
def test_simple(self): def test_simple(self):
with self.assertNumQueries(50): with self.assertNumQueries(40):
response = self.client.post( response = self.client.post(
reverse("assignmentpoll-list"), reverse("assignmentpoll-list"),
{ {
@ -1008,10 +1007,10 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass):
{"1": "N"}, {"1": "N"},
format="json", format="json",
) )
self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST)
self.assertEqual(AssignmentVote.objects.count(), 1) self.assertEqual(AssignmentVote.objects.count(), 1)
vote = AssignmentVote.objects.get() vote = AssignmentVote.objects.get()
self.assertEqual(vote.value, "N") self.assertEqual(vote.value, "Y")
def test_too_many_options(self): def test_too_many_options(self):
self.start_poll() self.start_poll()
@ -1197,14 +1196,14 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass):
{"1": 0, "2": 1}, {"1": 0, "2": 1},
format="json", format="json",
) )
self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST)
poll = AssignmentPoll.objects.get() poll = AssignmentPoll.objects.get()
option1 = poll.options.get(pk=1) option1 = poll.options.get(pk=1)
option2 = poll.options.get(pk=2) option2 = poll.options.get(pk=2)
self.assertEqual(option1.yes, Decimal("0")) self.assertEqual(option1.yes, Decimal("1"))
self.assertEqual(option1.no, Decimal("0")) self.assertEqual(option1.no, Decimal("0"))
self.assertEqual(option1.abstain, Decimal("0")) self.assertEqual(option1.abstain, Decimal("0"))
self.assertEqual(option2.yes, Decimal("1")) self.assertEqual(option2.yes, Decimal("0"))
self.assertEqual(option2.no, Decimal("0")) self.assertEqual(option2.no, Decimal("0"))
self.assertEqual(option2.abstain, Decimal("0")) self.assertEqual(option2.abstain, Decimal("0"))
@ -1219,9 +1218,9 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass):
poll = AssignmentPoll.objects.get() poll = AssignmentPoll.objects.get()
option = poll.options.get(pk=1) option = poll.options.get(pk=1)
self.assertEqual(option.yes, Decimal("0")) self.assertEqual(option.yes, Decimal("0"))
self.assertEqual(option.no, Decimal("2")) self.assertEqual(option.no, Decimal("0"))
self.assertEqual(option.abstain, Decimal("0")) self.assertEqual(option.abstain, Decimal("0"))
self.assertEqual(poll.amount_global_no, Decimal("2")) self.assertEqual(poll.amount_global_no, Decimal("1"))
self.assertEqual(poll.amount_global_abstain, Decimal("0")) self.assertEqual(poll.amount_global_abstain, Decimal("0"))
def test_global_no_forbidden(self): def test_global_no_forbidden(self):
@ -1247,9 +1246,9 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass):
option = poll.options.get(pk=1) option = poll.options.get(pk=1)
self.assertEqual(option.yes, Decimal("0")) self.assertEqual(option.yes, Decimal("0"))
self.assertEqual(option.no, Decimal("0")) self.assertEqual(option.no, Decimal("0"))
self.assertEqual(option.abstain, Decimal("2")) self.assertEqual(option.abstain, Decimal("0"))
self.assertEqual(poll.amount_global_no, Decimal("0")) self.assertEqual(poll.amount_global_no, Decimal("0"))
self.assertEqual(poll.amount_global_abstain, Decimal("2")) self.assertEqual(poll.amount_global_abstain, Decimal("1"))
def test_global_abstain_forbidden(self): def test_global_abstain_forbidden(self):
self.poll.global_abstain = False self.poll.global_abstain = False
@ -1302,17 +1301,6 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass):
self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST)
self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists())
def test_missing_option(self):
self.add_candidate()
self.start_poll()
response = self.client.post(
reverse("assignmentpoll-vote", args=[self.poll.pk]),
{"1": 1},
format="json",
)
self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST)
self.assertFalse(AssignmentPoll.objects.get().get_votes().exists())
def test_too_many_options(self): def test_too_many_options(self):
self.setup_for_multiple_votes() self.setup_for_multiple_votes()
self.start_poll() self.start_poll()
@ -1370,7 +1358,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass):
def test_missing_data(self): def test_missing_data(self):
self.start_poll() self.start_poll()
response = self.client.post(reverse("assignmentpoll-vote", args=[self.poll.pk])) response = self.client.post(reverse("assignmentpoll-vote", args=[self.poll.pk]))
self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
self.assertFalse(AssignmentVote.objects.exists()) self.assertFalse(AssignmentVote.objects.exists())
def test_wrong_data_format(self): def test_wrong_data_format(self):
@ -1557,9 +1545,7 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass):
def test_missing_data(self): def test_missing_data(self):
self.start_poll() self.start_poll()
response = self.client.post(reverse("assignmentpoll-vote", args=[self.poll.pk])) response = self.client.post(reverse("assignmentpoll-vote", args=[self.poll.pk]))
self.assertHttpStatusVerbose( self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
response, status.HTTP_200_OK
) # new "feature" because of partial requests: empty requests work!
self.assertFalse(AssignmentVote.objects.exists()) self.assertFalse(AssignmentVote.objects.exists())
def test_wrong_data_format(self): def test_wrong_data_format(self):
@ -1718,17 +1704,6 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass):
self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST)
self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists())
def test_missing_option(self):
self.add_candidate()
self.start_poll()
response = self.client.post(
reverse("assignmentpoll-vote", args=[self.poll.pk]),
{"1": 1},
format="json",
)
self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST)
self.assertFalse(AssignmentPoll.objects.get().get_votes().exists())
def test_too_many_options(self): def test_too_many_options(self):
self.setup_for_multiple_votes() self.setup_for_multiple_votes()
self.start_poll() self.start_poll()
@ -1786,7 +1761,7 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass):
def test_missing_data(self): def test_missing_data(self):
self.start_poll() self.start_poll()
response = self.client.post(reverse("assignmentpoll-vote", args=[self.poll.pk])) response = self.client.post(reverse("assignmentpoll-vote", args=[self.poll.pk]))
self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
self.assertFalse(AssignmentVote.objects.exists()) self.assertFalse(AssignmentVote.objects.exists())
def test_wrong_data_format(self): def test_wrong_data_format(self):
@ -1909,6 +1884,8 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass)
"votescast": "1.000000", "votescast": "1.000000",
"votesinvalid": "0.000000", "votesinvalid": "0.000000",
"votesvalid": "1.000000", "votesvalid": "1.000000",
"user_has_voted": False,
"voted_id": [self.user.id],
}, },
"assignments/assignment-option:1": { "assignments/assignment-option:1": {
"abstain": "1.000000", "abstain": "1.000000",
@ -1919,8 +1896,6 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass)
"yes": "0.000000", "yes": "0.000000",
"user_id": 1, "user_id": 1,
"weight": 1, "weight": 1,
"user_has_voted": False,
"voted_id": [self.user.id],
}, },
"assignments/assignment-vote:1": { "assignments/assignment-vote:1": {
"id": 1, "id": 1,
@ -1971,6 +1946,7 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass)
"options_id": [1], "options_id": [1],
"id": 1, "id": 1,
"votes_amount": 1, "votes_amount": 1,
"user_has_voted": user == self.user,
}, },
) )
@ -1984,7 +1960,7 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass)
vote.value = "A" vote.value = "A"
vote.weight = Decimal("1") vote.weight = Decimal("1")
vote.save(no_delete_on_restriction=True, skip_autoupdate=True) vote.save(no_delete_on_restriction=True, skip_autoupdate=True)
option.voted.add(self.user.id) self.poll.voted.add(self.user.id)
self.poll.state = AssignmentPoll.STATE_FINISHED self.poll.state = AssignmentPoll.STATE_FINISHED
self.poll.save(skip_autoupdate=True) self.poll.save(skip_autoupdate=True)
response = self.client.post( response = self.client.post(
@ -2026,6 +2002,8 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass)
"votescast": "1.000000", "votescast": "1.000000",
"votesinvalid": "0.000000", "votesinvalid": "0.000000",
"votesvalid": "1.000000", "votesvalid": "1.000000",
"user_has_voted": user == self.user,
"voted_id": [self.user.id],
}, },
"assignments/assignment-vote:1": { "assignments/assignment-vote:1": {
"pollstate": AssignmentPoll.STATE_PUBLISHED, "pollstate": AssignmentPoll.STATE_PUBLISHED,
@ -2044,8 +2022,6 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass)
"yes": "0.000000", "yes": "0.000000",
"user_id": 1, "user_id": 1,
"weight": 1, "weight": 1,
"user_has_voted": user == self.user,
"voted_id": [self.user.id],
}, },
}, },
) )
@ -2083,6 +2059,8 @@ class VoteAssignmentPollPseudoanonymousAutoupdates(
"title": self.poll.title, "title": self.poll.title,
"description": self.description, "description": self.description,
"type": AssignmentPoll.TYPE_PSEUDOANONYMOUS, "type": AssignmentPoll.TYPE_PSEUDOANONYMOUS,
"user_has_voted": False,
"voted_id": [self.user.id],
"onehundred_percent_base": AssignmentPoll.PERCENT_BASE_CAST, "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_CAST,
"majority_method": AssignmentPoll.MAJORITY_TWO_THIRDS, "majority_method": AssignmentPoll.MAJORITY_TWO_THIRDS,
"votes_amount": 1, "votes_amount": 1,
@ -2099,8 +2077,6 @@ class VoteAssignmentPollPseudoanonymousAutoupdates(
"yes": "0.000000", "yes": "0.000000",
"user_id": 1, "user_id": 1,
"weight": 1, "weight": 1,
"user_has_voted": False,
"voted_id": [self.user.id],
}, },
"assignments/assignment-vote:1": { "assignments/assignment-vote:1": {
"id": 1, "id": 1,
@ -2137,6 +2113,7 @@ class VoteAssignmentPollPseudoanonymousAutoupdates(
"options_id": [1], "options_id": [1],
"id": 1, "id": 1,
"votes_amount": 1, "votes_amount": 1,
"user_has_voted": user == self.user,
}, },
) )
@ -2149,7 +2126,7 @@ class VoteAssignmentPollPseudoanonymousAutoupdates(
vote.value = "A" vote.value = "A"
vote.weight = Decimal("1") vote.weight = Decimal("1")
vote.save(no_delete_on_restriction=True, skip_autoupdate=True) vote.save(no_delete_on_restriction=True, skip_autoupdate=True)
option.voted.add(self.user.id) self.poll.voted.add(self.user.id)
self.poll.state = AssignmentPoll.STATE_FINISHED self.poll.state = AssignmentPoll.STATE_FINISHED
self.poll.save(skip_autoupdate=True) self.poll.save(skip_autoupdate=True)
response = self.client.post( response = self.client.post(
@ -2191,6 +2168,8 @@ class VoteAssignmentPollPseudoanonymousAutoupdates(
"votescast": "1.000000", "votescast": "1.000000",
"votesinvalid": "0.000000", "votesinvalid": "0.000000",
"votesvalid": "1.000000", "votesvalid": "1.000000",
"user_has_voted": user == self.user,
"voted_id": [self.user.id],
}, },
"assignments/assignment-vote:1": { "assignments/assignment-vote:1": {
"pollstate": AssignmentPoll.STATE_PUBLISHED, "pollstate": AssignmentPoll.STATE_PUBLISHED,
@ -2209,8 +2188,6 @@ class VoteAssignmentPollPseudoanonymousAutoupdates(
"yes": "0.000000", "yes": "0.000000",
"user_id": 1, "user_id": 1,
"weight": 1, "weight": 1,
"user_has_voted": user == self.user,
"voted_id": [self.user.id],
}, },
}, },
) )

View File

@ -47,11 +47,10 @@ def test_motion_option_db_queries():
Tests that only the following db queries are done: Tests that only the following db queries are done:
* 1 request to get the options, * 1 request to get the options,
* 1 request to get all votes for all options, * 1 request to get all votes for all options,
* 1 request to get all users that voted on the options = 2 queries
= 5 queries
""" """
create_motion_polls() create_motion_polls()
assert count_queries(MotionOption.get_elements)() == 3 assert count_queries(MotionOption.get_elements)() == 2
def create_motion_polls(): def create_motion_polls():
@ -83,7 +82,7 @@ def create_motion_polls():
value=("Y" if k == 0 else "N"), value=("Y" if k == 0 else "N"),
weight=Decimal(1), weight=Decimal(1),
) )
option.voted.add(user) poll.voted.add(user)
class CreateMotionPoll(TestCase): class CreateMotionPoll(TestCase):
@ -166,6 +165,8 @@ class CreateMotionPoll(TestCase):
"votescast": "0.000000", "votescast": "0.000000",
"options_id": [1], "options_id": [1],
"id": 1, "id": 1,
"voted_id": [],
"user_has_voted": False,
}, },
) )
self.assertEqual(autoupdate[1], []) self.assertEqual(autoupdate[1], [])
@ -910,6 +911,8 @@ class VoteMotionPollNamedAutoupdates(TestCase):
"votescast": "1.000000", "votescast": "1.000000",
"options_id": [1], "options_id": [1],
"id": 1, "id": 1,
"user_has_voted": False,
"voted_id": [self.user.id],
}, },
"motions/motion-vote:1": { "motions/motion-vote:1": {
"pollstate": 2, "pollstate": 2,
@ -926,8 +929,6 @@ class VoteMotionPollNamedAutoupdates(TestCase):
"poll_id": 1, "poll_id": 1,
"pollstate": 2, "pollstate": 2,
"yes": "0.000000", "yes": "0.000000",
"user_has_voted": False,
"voted_id": [self.user.id],
}, },
}, },
) )
@ -948,7 +949,7 @@ class VoteMotionPollNamedAutoupdates(TestCase):
) )
self.assertEqual( self.assertEqual(
autoupdate[0]["motions/motion-option:1"], autoupdate[0]["motions/motion-option:1"],
{"id": 1, "poll_id": 1, "pollstate": 2, "user_has_voted": True}, {"id": 1, "poll_id": 1, "pollstate": 2},
) )
self.assertEqual(autoupdate[1], []) self.assertEqual(autoupdate[1], [])
@ -969,6 +970,7 @@ class VoteMotionPollNamedAutoupdates(TestCase):
"groups_id": [GROUP_DELEGATE_PK], "groups_id": [GROUP_DELEGATE_PK],
"options_id": [1], "options_id": [1],
"id": 1, "id": 1,
"user_has_voted": user == self.user,
}, },
) )
self.assertEqual( self.assertEqual(
@ -977,8 +979,7 @@ class VoteMotionPollNamedAutoupdates(TestCase):
"id": 1, "id": 1,
"poll_id": 1, "poll_id": 1,
"pollstate": 2, "pollstate": 2,
"user_has_voted": user == self.user, }, # noqa black and flake are no friends :(
},
) )
# Other users should not get a vote autoupdate # Other users should not get a vote autoupdate
@ -1049,6 +1050,8 @@ class VoteMotionPollPseudoanonymousAutoupdates(TestCase):
"votescast": "1.000000", "votescast": "1.000000",
"options_id": [1], "options_id": [1],
"id": 1, "id": 1,
"user_has_voted": False,
"voted_id": [self.user.id],
}, },
"motions/motion-vote:1": { "motions/motion-vote:1": {
"pollstate": 2, "pollstate": 2,
@ -1065,8 +1068,6 @@ class VoteMotionPollPseudoanonymousAutoupdates(TestCase):
"poll_id": 1, "poll_id": 1,
"pollstate": 2, "pollstate": 2,
"yes": "0.000000", "yes": "0.000000",
"user_has_voted": False,
"voted_id": [self.user.id],
}, },
}, },
) )
@ -1090,6 +1091,7 @@ class VoteMotionPollPseudoanonymousAutoupdates(TestCase):
"groups_id": [GROUP_DELEGATE_PK], "groups_id": [GROUP_DELEGATE_PK],
"options_id": [1], "options_id": [1],
"id": 1, "id": 1,
"user_has_voted": user == self.user,
}, },
) )
@ -1153,12 +1155,12 @@ class VoteMotionPollPseudoanonymous(TestCase):
self.assertEqual(poll.votesinvalid, Decimal("0")) self.assertEqual(poll.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("1")) self.assertEqual(poll.votescast, Decimal("1"))
self.assertEqual(poll.get_votes().count(), 1) self.assertEqual(poll.get_votes().count(), 1)
self.assertEqual(poll.amount_valid_votes(), 1) self.assertEqual(poll.amount_users_voted(), 1)
option = poll.options.get() option = poll.options.get()
self.assertEqual(option.yes, Decimal("0")) self.assertEqual(option.yes, Decimal("0"))
self.assertEqual(option.no, Decimal("1")) self.assertEqual(option.no, Decimal("1"))
self.assertEqual(option.abstain, Decimal("0")) self.assertEqual(option.abstain, Decimal("0"))
self.assertTrue(self.admin in option.voted.all()) self.assertTrue(self.admin in poll.voted.all())
vote = option.votes.get() vote = option.votes.get()
self.assertEqual(vote.user, None) self.assertEqual(vote.user, None)
@ -1311,6 +1313,8 @@ class PublishMotionPoll(TestCase):
"votescast": "0.000000", "votescast": "0.000000",
"options_id": [1], "options_id": [1],
"id": 1, "id": 1,
"user_has_voted": False,
"voted_id": [],
}, },
"motions/motion-vote:1": { "motions/motion-vote:1": {
"pollstate": 4, "pollstate": 4,
@ -1327,8 +1331,6 @@ class PublishMotionPoll(TestCase):
"poll_id": 1, "poll_id": 1,
"pollstate": 4, "pollstate": 4,
"yes": "0.000000", "yes": "0.000000",
"user_has_voted": False,
"voted_id": [],
}, },
}, },
) )
@ -1362,12 +1364,12 @@ class PseudoanonymizeMotionPoll(TestCase):
self.vote1 = MotionVote.objects.create( self.vote1 = MotionVote.objects.create(
user=self.user1, option=self.option, value="Y", weight=Decimal(1) user=self.user1, option=self.option, value="Y", weight=Decimal(1)
) )
self.option.voted.add(self.user1) self.poll.voted.add(self.user1)
self.user2, _ = self.create_user() self.user2, _ = self.create_user()
self.vote2 = MotionVote.objects.create( self.vote2 = MotionVote.objects.create(
user=self.user2, option=self.option, value="N", weight=Decimal(1) user=self.user2, option=self.option, value="N", weight=Decimal(1)
) )
self.option.voted.add(self.user2) self.poll.voted.add(self.user2)
def test_pseudoanonymize_poll(self): def test_pseudoanonymize_poll(self):
response = self.client.post( response = self.client.post(
@ -1376,7 +1378,7 @@ class PseudoanonymizeMotionPoll(TestCase):
self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = MotionPoll.objects.get() poll = MotionPoll.objects.get()
self.assertEqual(poll.get_votes().count(), 2) self.assertEqual(poll.get_votes().count(), 2)
self.assertEqual(poll.amount_valid_votes(), 2) self.assertEqual(poll.amount_users_voted(), 2)
self.assertEqual(poll.votesvalid, Decimal("2")) self.assertEqual(poll.votesvalid, Decimal("2"))
self.assertEqual(poll.votesinvalid, Decimal("0")) self.assertEqual(poll.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("2")) self.assertEqual(poll.votescast, Decimal("2"))
@ -1384,8 +1386,8 @@ class PseudoanonymizeMotionPoll(TestCase):
self.assertEqual(option.yes, Decimal("1")) self.assertEqual(option.yes, Decimal("1"))
self.assertEqual(option.no, Decimal("1")) self.assertEqual(option.no, Decimal("1"))
self.assertEqual(option.abstain, Decimal("0")) self.assertEqual(option.abstain, Decimal("0"))
self.assertTrue(self.user1 in option.voted.all()) self.assertTrue(self.user1 in poll.voted.all())
self.assertTrue(self.user2 in option.voted.all()) self.assertTrue(self.user2 in poll.voted.all())
for vote in poll.get_votes().all(): for vote in poll.get_votes().all():
self.assertTrue(vote.user is None) self.assertTrue(vote.user is None)
@ -1432,19 +1434,19 @@ class ResetMotionPoll(TestCase):
self.vote1 = MotionVote.objects.create( self.vote1 = MotionVote.objects.create(
user=self.user1, option=self.option, value="Y", weight=Decimal(1) user=self.user1, option=self.option, value="Y", weight=Decimal(1)
) )
self.option.voted.add(self.user1) self.poll.voted.add(self.user1)
self.user2, _ = self.create_user() self.user2, _ = self.create_user()
self.vote2 = MotionVote.objects.create( self.vote2 = MotionVote.objects.create(
user=self.user2, option=self.option, value="N", weight=Decimal(1) user=self.user2, option=self.option, value="N", weight=Decimal(1)
) )
self.option.voted.add(self.user2) self.poll.voted.add(self.user2)
def test_reset_poll(self): def test_reset_poll(self):
response = self.client.post(reverse("motionpoll-reset", args=[self.poll.pk])) response = self.client.post(reverse("motionpoll-reset", args=[self.poll.pk]))
self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = MotionPoll.objects.get() poll = MotionPoll.objects.get()
self.assertEqual(poll.get_votes().count(), 0) self.assertEqual(poll.get_votes().count(), 0)
self.assertEqual(poll.amount_valid_votes(), 0) self.assertEqual(poll.amount_users_voted(), 0)
self.assertEqual(poll.votesvalid, None) self.assertEqual(poll.votesvalid, None)
self.assertEqual(poll.votesinvalid, None) self.assertEqual(poll.votesinvalid, None)
self.assertEqual(poll.votescast, None) self.assertEqual(poll.votescast, None)
@ -1463,12 +1465,3 @@ class ResetMotionPoll(TestCase):
for user in (self.admin, self.user1, self.user2): for user in (self.admin, self.user1, self.user2):
self.assertDeletedAutoupdate(self.vote1, user=user) self.assertDeletedAutoupdate(self.vote1, user=user)
self.assertDeletedAutoupdate(self.vote2, user=user) self.assertDeletedAutoupdate(self.vote2, user=user)
def test_reset_wrong_state(self):
self.poll.state = MotionPoll.STATE_STARTED
self.poll.save()
response = self.client.post(reverse("motionpoll-reset", args=[self.poll.pk]))
self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST)
poll = MotionPoll.objects.get()
self.assertTrue(poll.get_votes().exists())
self.assertEqual(poll.amount_valid_votes(), 2)