Merge pull request #5978 from jsangmeister/voting-changes

Implement voting changes
This commit is contained in:
jsangmeister 2021-04-01 16:12:31 +02:00 committed by GitHub
commit 787390c899
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 1055 additions and 268 deletions

View File

@ -4,15 +4,20 @@ build-dev:
docker-compose -f docker/docker-compose.dev.yml build docker-compose -f docker/docker-compose.dev.yml build
run-dev: | build-dev run-dev: | build-dev
UID=$$(id -u $${USER}) GID=$$(id -g $${USER}) docker-compose -f docker/docker-compose.dev.yml up USER_ID=$$(id -u $${USER}) GROUP_ID=$$(id -g $${USER}) docker-compose -f docker/docker-compose.dev.yml up
stop-dev: stop-dev:
docker-compose -f docker/docker-compose.dev.yml down docker-compose -f docker/docker-compose.dev.yml down
server-shell: server-shell:
docker-compose -f docker/docker-compose.dev.yml run --entrypoint="" server docker/wait-for-dev-dependencies.sh docker-compose -f docker/docker-compose.dev.yml run --entrypoint="" server docker/wait-for-dev-dependencies.sh
UID=$$(id -u $${USER}) GID=$$(id -g $${USER}) docker-compose -f docker/docker-compose.dev.yml run --entrypoint="" server bash USER_ID=$$(id -u $${USER}) GROUP_ID=$$(id -g $${USER}) docker-compose -f docker/docker-compose.dev.yml run --entrypoint="" server bash
docker-compose -f docker/docker-compose.dev.yml down docker-compose -f docker/docker-compose.dev.yml down
reload-proxy: reload-proxy:
docker-compose -f docker/docker-compose.dev.yml exec -w /etc/caddy proxy caddy reload docker-compose -f docker/docker-compose.dev.yml exec -w /etc/caddy proxy caddy reload
clear-cache:
docker-compose -f docker/docker-compose.dev.yml exec redis redis-cli flushall
docker-compose -f docker/docker-compose.dev.yml restart autoupdate
docker-compose -f docker/docker-compose.dev.yml restart server

@ -1 +1 @@
Subproject commit 0a2eb0fce664bdb76eb47beadf0f7c383e40e709 Subproject commit c1211219d81965b10780ecfa5a1de31f9e30d31e

View File

@ -49,6 +49,19 @@
</div> </div>
</td> </td>
</tr> </tr>
<tr *ngIf="isPercentBaseEntitled" class="entitled-users-row">
<td>{{ 'Entitled users' | translate }}</td>
<td class="result">
<div class="single-result">
<span>
{{ poll.entitled_users_at_stop.length | pollPercentBase: poll:'assignment' }}
</span>
<span>
{{ poll.entitled_users_at_stop.length }}
</span>
</div>
</td>
</tr>
</tbody> </tbody>
</table> </table>

View File

@ -46,4 +46,9 @@
.single-result { .single-result {
white-space: pre; white-space: pre;
} }
.entitled-users-row {
border-bottom: none;
height: 0;
}
} }

View File

@ -6,7 +6,7 @@ import { TranslateService } from '@ngx-translate/core';
import { BaseComponent } from 'app/base.component'; import { BaseComponent } from 'app/base.component';
import { OperatorService } from 'app/core/core-services/operator.service'; import { OperatorService } from 'app/core/core-services/operator.service';
import { AssignmentPollMethod } from 'app/shared/models/assignments/assignment-poll'; import { AssignmentPollMethod } from 'app/shared/models/assignments/assignment-poll';
import { PollState } from 'app/shared/models/poll/base-poll'; import { PercentBase, PollState } from 'app/shared/models/poll/base-poll';
import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll'; import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll';
import { AssignmentPollService } from 'app/site/assignments/modules/assignment-poll/services/assignment-poll.service'; import { AssignmentPollService } from 'app/site/assignments/modules/assignment-poll/services/assignment-poll.service';
import { PollData, PollTableData, VotingResult } from 'app/site/polls/services/poll.service'; import { PollData, PollTableData, VotingResult } from 'app/site/polls/services/poll.service';
@ -72,6 +72,10 @@ export class AssignmentPollDetailContentComponent extends BaseComponent {
return this.operator.hasPerms(this.permission.assignmentsCanManage) || this.isPublished; return this.operator.hasPerms(this.permission.assignmentsCanManage) || this.isPublished;
} }
public get isPercentBaseEntitled(): boolean {
return this.poll.onehundred_percent_base === PercentBase.Entitled;
}
public constructor( public constructor(
titleService: Title, titleService: Title,
translateService: TranslateService, translateService: TranslateService,

View File

@ -30,6 +30,15 @@
{{ row.value[0].amount | parsePollNumber }} {{ row.value[0].amount | parsePollNumber }}
</td> </td>
</tr> </tr>
<tr *ngIf="isPercentBaseEntitled" class="entitled-users-row">
<td>{{ 'Entitled users' | translate }}</td>
<td class="result-cell-definition">
{{ poll.entitled_users_at_stop.length | pollPercentBase: poll:'motion' }}
</td>
<td class="result-cell-definition">
{{ poll.entitled_users_at_stop.length }}
</td>
</tr>
</tbody> </tbody>
</table> </table>

View File

@ -5,7 +5,7 @@ import { TranslateService } from '@ngx-translate/core';
import { BaseComponent } from 'app/base.component'; import { BaseComponent } from 'app/base.component';
import { OperatorService } from 'app/core/core-services/operator.service'; import { OperatorService } from 'app/core/core-services/operator.service';
import { PollState } from 'app/shared/models/poll/base-poll'; import { PercentBase, PollState } from 'app/shared/models/poll/base-poll';
import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll';
import { MotionPollService } from 'app/site/motions/services/motion-poll.service'; import { MotionPollService } from 'app/site/motions/services/motion-poll.service';
import { PollData, PollTableData } from 'app/site/polls/services/poll.service'; import { PollData, PollTableData } from 'app/site/polls/services/poll.service';
@ -58,6 +58,10 @@ export class MotionPollDetailContentComponent extends BaseComponent {
return this.operator.hasPerms(this.permission.motionsCanManagePolls) || this.isPublished; return this.operator.hasPerms(this.permission.motionsCanManagePolls) || this.isPublished;
} }
public get isPercentBaseEntitled(): boolean {
return this.poll.onehundred_percent_base === PercentBase.Entitled;
}
public constructor( public constructor(
titleService: Title, titleService: Title,
translate: TranslateService, translate: TranslateService,

View File

@ -15,6 +15,7 @@ export enum AssignmentPollPercentBase {
YNA = 'YNA', YNA = 'YNA',
Valid = 'valid', Valid = 'valid',
Cast = 'cast', Cast = 'cast',
Entitled = 'entitled',
Disabled = 'disabled' Disabled = 'disabled'
} }

View File

@ -35,9 +35,16 @@ export enum PercentBase {
YNA = 'YNA', YNA = 'YNA',
Valid = 'valid', Valid = 'valid',
Cast = 'cast', Cast = 'cast',
Entitled = 'entitled',
Disabled = 'disabled' Disabled = 'disabled'
} }
export interface EntitledUsersEntry {
user_id: number;
voted: boolean;
vote_delegated_to_id?: number;
}
export const VOTE_MAJORITY = -1; export const VOTE_MAJORITY = -1;
export const VOTE_UNDOCUMENTED = -2; export const VOTE_UNDOCUMENTED = -2;
export const LOWEST_VOTE_VALUE = VOTE_UNDOCUMENTED; export const LOWEST_VOTE_VALUE = VOTE_UNDOCUMENTED;
@ -61,6 +68,8 @@ export abstract class BasePoll<
public user_has_voted_for_delegations: number[]; public user_has_voted_for_delegations: number[];
public pollmethod: PM; public pollmethod: PM;
public onehundred_percent_base: PB; public onehundred_percent_base: PB;
public is_pseudoanonymized: boolean;
public entitled_users_at_stop: EntitledUsersEntry[];
public get isCreated(): boolean { public get isCreated(): boolean {
return this.state === PollState.Created; return this.state === PollState.Created;

View File

@ -28,6 +28,7 @@ export abstract class BaseVote<T = any> extends BaseDecimalModel<T> {
public value: VoteValue; public value: VoteValue;
public option_id: number; public option_id: number;
public user_id?: number; public user_id?: number;
public user_token: string;
public get valueVerbose(): string { public get valueVerbose(): string {
return VoteValueVerbose[this.value]; return VoteValueVerbose[this.value];

View File

@ -115,6 +115,7 @@ import { ChartsComponent } from './components/charts/charts.component';
import { CheckInputComponent } from './components/check-input/check-input.component'; import { CheckInputComponent } from './components/check-input/check-input.component';
import { BannerComponent } from './components/banner/banner.component'; import { BannerComponent } from './components/banner/banner.component';
import { PollFormComponent } from 'app/site/polls/components/poll-form/poll-form.component'; import { PollFormComponent } from 'app/site/polls/components/poll-form/poll-form.component';
import { EntitledUsersTableComponent } from 'app/site/polls/components/entitled-users-table/entitled-users-table.component';
import { MotionPollDialogComponent } from 'app/site/motions/modules/motion-poll/motion-poll-dialog/motion-poll-dialog.component'; import { MotionPollDialogComponent } from 'app/site/motions/modules/motion-poll/motion-poll-dialog/motion-poll-dialog.component';
import { ParsePollNumberPipe } from './pipes/parse-poll-number.pipe'; import { ParsePollNumberPipe } from './pipes/parse-poll-number.pipe';
import { ReversePipe } from './pipes/reverse.pipe'; import { ReversePipe } from './pipes/reverse.pipe';
@ -293,6 +294,7 @@ import { ApplauseParticleDisplayComponent } from './components/applause-particle
CheckInputComponent, CheckInputComponent,
BannerComponent, BannerComponent,
PollFormComponent, PollFormComponent,
EntitledUsersTableComponent,
MotionPollDialogComponent, MotionPollDialogComponent,
ParsePollNumberPipe, ParsePollNumberPipe,
ReversePipe, ReversePipe,
@ -356,6 +358,7 @@ import { ApplauseParticleDisplayComponent } from './components/applause-particle
CheckInputComponent, CheckInputComponent,
BannerComponent, BannerComponent,
PollFormComponent, PollFormComponent,
EntitledUsersTableComponent,
MotionPollDialogComponent, MotionPollDialogComponent,
ParsePollNumberPipe, ParsePollNumberPipe,
ReversePipe, ReversePipe,

View File

@ -30,6 +30,7 @@ export const AssignmentPollPercentBaseVerbose = {
YNA: _('Yes/No/Abstain per candidate'), YNA: _('Yes/No/Abstain per candidate'),
valid: _('All valid ballots'), valid: _('All valid ballots'),
cast: _('All casted ballots'), cast: _('All casted ballots'),
entitled: _('All entitled users'),
disabled: _('Disabled (no percents)') disabled: _('Disabled (no percents)')
}; };

View File

@ -37,9 +37,10 @@
<os-charts class="assignment-result-chart" [labels]="candidatesLabels" [data]="chartData"></os-charts> <os-charts class="assignment-result-chart" [labels]="candidatesLabels" [data]="chartData"></os-charts>
</div> </div>
<mat-tab-group *ngIf="showResults && poll.stateHasVotes && poll.isEVoting">
<mat-tab label="{{ 'Single votes' | translate }}">
<!-- Single Votes Table --> <!-- Single Votes Table -->
<div class="named-result-table" *ngIf="showResults && poll.stateHasVotes && poll.type === 'named'"> <div class="named-result-table">
<h3>{{ 'Single votes' | translate }}</h3>
<os-list-view-table <os-list-view-table
class="single-votes-table" class="single-votes-table"
*ngIf="votesDataObservable" *ngIf="votesDataObservable"
@ -86,7 +87,8 @@
</div> </div>
</div> </div>
<div *ngIf="!vote.user"> <div *ngIf="!vote.user">
{{ 'Anonymous' | translate }} <i *ngIf="poll.is_pseudoanonymized">{{ "Anonymous" | translate }}</i>
<i *ngIf="!poll.is_pseudoanonymized">{{ "Deleted user" | translate }}</i>
</div> </div>
</div> </div>
@ -94,10 +96,15 @@
<div class="single-vote-result" *ngFor="let candidate of vote.votes">{{ candidate }}</div> <div class="single-vote-result" *ngFor="let candidate of vote.votes">{{ candidate }}</div>
</div> </div>
</os-list-view-table> </os-list-view-table>
<div *ngIf="!votesDataObservable">
{{ 'The individual votes were anonymized.' | translate }}
</div>
</div> </div>
</mat-tab>
<mat-tab label="{{ 'Entitled users' | translate }}">
<os-entitled-users-table
[entitledUsersObservable]="entitledUsersObservable"
[listStorageKey]="assignment-poll-entitled-users"
></os-entitled-users-table>
</mat-tab>
</mat-tab-group>
</div> </div>
</div> </div>

View File

@ -3,6 +3,7 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { PblColumnDefinition } from '@pebula/ngrid'; import { PblColumnDefinition } from '@pebula/ngrid';
@ -10,6 +11,7 @@ import { OperatorService, Permission } from 'app/core/core-services/operator.ser
import { AssignmentPollRepositoryService } from 'app/core/repositories/assignments/assignment-poll-repository.service'; import { AssignmentPollRepositoryService } from 'app/core/repositories/assignments/assignment-poll-repository.service';
import { AssignmentVoteRepositoryService } from 'app/core/repositories/assignments/assignment-vote-repository.service'; import { AssignmentVoteRepositoryService } from 'app/core/repositories/assignments/assignment-vote-repository.service';
import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service'; import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service';
import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
import { ConfigService } from 'app/core/ui-services/config.service'; import { ConfigService } from 'app/core/ui-services/config.service';
import { PromptService } from 'app/core/ui-services/prompt.service'; import { PromptService } from 'app/core/ui-services/prompt.service';
import { ChartData } from 'app/shared/components/charts/charts.component'; import { ChartData } from 'app/shared/components/charts/charts.component';
@ -62,7 +64,8 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponentDirect
votesRepo: AssignmentVoteRepositoryService, votesRepo: AssignmentVoteRepositoryService,
protected operator: OperatorService, protected operator: OperatorService,
private router: Router, private router: Router,
protected cd: ChangeDetectorRef protected cd: ChangeDetectorRef,
protected userRepo: UserRepositoryService
) { ) {
super( super(
title, title,
@ -76,7 +79,8 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponentDirect
pollService, pollService,
votesRepo, votesRepo,
operator, operator,
cd cd,
userRepo
); );
configService configService
.get<boolean>('users_activate_vote_weight') .get<boolean>('users_activate_vote_weight')
@ -100,14 +104,14 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponentDirect
]; ];
const votes = {}; const votes = {};
let isPseudoanonymized = true;
for (const option of this.poll.options) { for (const option of this.poll.options) {
for (const vote of option.votes) { for (const vote of option.votes) {
const userId = vote.user_id; const token = vote.user_token;
if (userId) { if (!token) {
isPseudoanonymized = false; throw new Error(`assignment_vote/${vote.id} does not contain a user_token`);
if (!votes[userId]) { }
votes[userId] = { if (!votes[token]) {
votes[token] = {
user: vote.user, user: vote.user,
votes: [] votes: []
}; };
@ -116,31 +120,17 @@ export class AssignmentPollDetailComponent extends BasePollDetailComponentDirect
if (vote.weight > 0) { if (vote.weight > 0) {
if (this.poll.isMethodY) { if (this.poll.isMethodY) {
if (vote.value === 'Y') { if (vote.value === 'Y') {
votes[userId].votes.push(option.user.getFullName()); votes[token].votes.push(option.user.getFullName());
} else { } else {
votes[userId].votes.push(this.voteValueToLabel(vote.value)); votes[token].votes.push(this.voteValueToLabel(vote.value));
} }
} else { } else {
votes[userId].votes.push( const candidate_name = option.user?.getShortName() ?? this.translate.instant('Deleted user');
`${option.user.getShortName()}: ${this.voteValueToLabel(vote.value)}` votes[token].votes.push(`${candidate_name}: ${this.voteValueToLabel(vote.value)}`);
);
} }
} }
} }
} }
}
// if the poll was not pseudoanonymized, add all other users as empty votes
if (!isPseudoanonymized) {
for (const user of this.poll.voted) {
if (!votes[user.id]) {
votes[user.id] = {
user: user,
votes: [this.translate.instant('empty vote')]
};
}
}
}
this.setVotesData(Object.values(votes)); this.setVotesData(Object.values(votes));
this.candidatesLabels = this.pollService.getChartLabels(this.poll); this.candidatesLabels = this.pollService.getChartLabels(this.poll);
this.columnDefinitionSingleVotes = definitions; this.columnDefinitionSingleVotes = definitions;

View File

@ -11,7 +11,7 @@ import {
AssignmentPollMethod, AssignmentPollMethod,
AssignmentPollPercentBase AssignmentPollPercentBase
} from 'app/shared/models/assignments/assignment-poll'; } from 'app/shared/models/assignments/assignment-poll';
import { MajorityMethod, PollType, VOTE_UNDOCUMENTED } from 'app/shared/models/poll/base-poll'; import { MajorityMethod, PercentBase, PollType, VOTE_UNDOCUMENTED } from 'app/shared/models/poll/base-poll';
import { ParsePollNumberPipe } from 'app/shared/pipes/parse-poll-number.pipe'; import { ParsePollNumberPipe } from 'app/shared/pipes/parse-poll-number.pipe';
import { PollKeyVerbosePipe } from 'app/shared/pipes/poll-key-verbose.pipe'; import { PollKeyVerbosePipe } from 'app/shared/pipes/poll-key-verbose.pipe';
import { ViewAssignmentOption } from 'app/site/assignments/models/view-assignment-option'; import { ViewAssignmentOption } from 'app/site/assignments/models/view-assignment-option';
@ -227,6 +227,9 @@ export class AssignmentPollService extends PollService {
case AssignmentPollPercentBase.Valid: case AssignmentPollPercentBase.Valid:
totalByBase = poll.votesvalid; totalByBase = poll.votesvalid;
break; break;
case AssignmentPollPercentBase.Entitled:
totalByBase = poll.entitled_users_at_stop.length;
break;
case AssignmentPollPercentBase.Cast: case AssignmentPollPercentBase.Cast:
totalByBase = poll.votescast; totalByBase = poll.votescast;
break; break;

View File

@ -20,6 +20,7 @@ export const MotionPollPercentBaseVerbose = {
YNA: 'Yes/No/Abstain', YNA: 'Yes/No/Abstain',
valid: 'All valid ballots', valid: 'All valid ballots',
cast: 'All casted ballots', cast: 'All casted ballots',
entitled: 'All entitled users',
disabled: 'Disabled (no percents)' disabled: 'Disabled (no percents)'
}; };

View File

@ -59,7 +59,7 @@ export class MotionDetailOriginalChangeRecommendationsComponent implements OnIni
@Output() @Output()
public gotoChangeRecommendation: EventEmitter<ViewMotionChangeRecommendation> = new EventEmitter< public gotoChangeRecommendation: EventEmitter<ViewMotionChangeRecommendation> = new EventEmitter<
ViewMotionChangeRecommendation ViewMotionChangeRecommendation
>(); >(); // prettier-ignore
@Input() @Input()
public html: string; public html: string;

View File

@ -32,14 +32,14 @@
<os-motion-poll-detail-content [poll]="poll"></os-motion-poll-detail-content> <os-motion-poll-detail-content [poll]="poll"></os-motion-poll-detail-content>
<!-- Named table: only show if votes are present --> <!-- Named table: only show if votes are present -->
<div class="named-result-table" *ngIf="showResults && poll.stateHasVotes && poll.type === 'named'"> <mat-tab-group *ngIf="showResults && poll.stateHasVotes && poll.isEVoting">
<h2>{{ 'Single votes' | translate }}</h2> <mat-tab label="{{ 'Single votes' | translate }}">
<div class="named-result-table">
<os-list-view-table <os-list-view-table
*ngIf="votesDataObservable"
class="single-votes-table" class="single-votes-table"
[listObservable]="votesDataObservable" [listObservable]="votesDataObservable"
[columns]="columnDefinition" [columns]="columnDefinitionSingleVotesTable"
[filterProps]="filterProps" [filterProps]="filterPropsSingleVotesTable"
[allowProjector]="false" [allowProjector]="false"
[fullScreen]="true" [fullScreen]="true"
[vScrollFixed]="-1" [vScrollFixed]="-1"
@ -75,7 +75,10 @@
</div> </div>
</div> </div>
</div> </div>
<div *ngIf="!vote.user">{{ 'Anonymous' | translate }}</div> <div *ngIf="!vote.user">
<i *ngIf="poll.is_pseudoanonymized">{{ "Anonymous" | translate }}</i>
<i *ngIf="!poll.is_pseudoanonymized">{{ "Deleted user" | translate }}</i>
</div>
</div> </div>
<div *pblNgridCellDef="'vote'; row as vote" class="vote-cell"> <div *pblNgridCellDef="'vote'; row as vote" class="vote-cell">
<div class="vote-cell-icon-container" [ngClass]="voteOptionStyle[vote.value].css"> <div class="vote-cell-icon-container" [ngClass]="voteOptionStyle[vote.value].css">
@ -84,10 +87,15 @@
<div>{{ vote.valueVerbose | translate }}</div> <div>{{ vote.valueVerbose | translate }}</div>
</div> </div>
</os-list-view-table> </os-list-view-table>
<div *ngIf="!votesDataObservable">
{{ 'The individual votes were anonymized.' | translate }}
</div>
</div> </div>
</mat-tab>
<mat-tab label="{{ 'Entitled users' | translate }}">
<os-entitled-users-table
[entitledUsersObservable]="entitledUsersObservable"
[listStorageKey]="motion-poll-entitled-users"
></os-entitled-users-table>
</mat-tab>
</mat-tab-group>
<div class="poll-content"> <div class="poll-content">
<small *ngIf="poll.groups && poll.type && poll.type !== 'analog'"> <small *ngIf="poll.groups && poll.type && poll.type !== 'analog'">

View File

@ -10,6 +10,7 @@ import { OperatorService, Permission } from 'app/core/core-services/operator.ser
import { MotionPollRepositoryService } from 'app/core/repositories/motions/motion-poll-repository.service'; import { MotionPollRepositoryService } from 'app/core/repositories/motions/motion-poll-repository.service';
import { MotionVoteRepositoryService } from 'app/core/repositories/motions/motion-vote-repository.service'; import { MotionVoteRepositoryService } from 'app/core/repositories/motions/motion-vote-repository.service';
import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service'; import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service';
import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
import { ConfigService } from 'app/core/ui-services/config.service'; import { ConfigService } from 'app/core/ui-services/config.service';
import { PromptService } from 'app/core/ui-services/prompt.service'; import { PromptService } from 'app/core/ui-services/prompt.service';
import { ViewMotion } from 'app/site/motions/models/view-motion'; import { ViewMotion } from 'app/site/motions/models/view-motion';
@ -27,7 +28,7 @@ import { BasePollDetailComponentDirective } from 'app/site/polls/components/base
}) })
export class MotionPollDetailComponent extends BasePollDetailComponentDirective<ViewMotionPoll, MotionPollService> { export class MotionPollDetailComponent extends BasePollDetailComponentDirective<ViewMotionPoll, MotionPollService> {
public motion: ViewMotion; public motion: ViewMotion;
public columnDefinition: PblColumnDefinition[] = [ public columnDefinitionSingleVotesTable: PblColumnDefinition[] = [
{ {
prop: 'user', prop: 'user',
width: 'auto', width: 'auto',
@ -40,7 +41,7 @@ export class MotionPollDetailComponent extends BasePollDetailComponentDirective<
} }
]; ];
public filterProps = ['user.getFullName', 'valueVerbose']; public filterPropsSingleVotesTable = ['user.getFullName', 'valueVerbose'];
public isVoteWeightActive: boolean; public isVoteWeightActive: boolean;
@ -62,7 +63,8 @@ export class MotionPollDetailComponent extends BasePollDetailComponentDirective<
configService: ConfigService, configService: ConfigService,
protected operator: OperatorService, protected operator: OperatorService,
private router: Router, private router: Router,
protected cd: ChangeDetectorRef protected cd: ChangeDetectorRef,
protected userRepo: UserRepositoryService
) { ) {
super( super(
title, title,
@ -76,7 +78,8 @@ export class MotionPollDetailComponent extends BasePollDetailComponentDirective<
pollService, pollService,
votesRepo, votesRepo,
operator, operator,
cd cd,
userRepo
); );
configService configService
.get<boolean>('users_activate_vote_weight') .get<boolean>('users_activate_vote_weight')

View File

@ -137,6 +137,9 @@ export class MotionPollService extends PollService {
case PercentBase.Cast: case PercentBase.Cast:
totalByBase = poll.votescast; totalByBase = poll.votescast;
break; break;
case PercentBase.Entitled:
totalByBase = poll.entitled_users_at_stop.length;
break;
case PercentBase.Disabled: case PercentBase.Disabled:
break; break;
default: default:

View File

@ -1,25 +1,29 @@
import { ChangeDetectorRef, Directive, OnInit } from '@angular/core'; import { ChangeDetectorRef, Directive, OnDestroy, OnInit } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { PblColumnDefinition } from '@pebula/ngrid';
import { Label } from 'ng2-charts'; import { Label } from 'ng2-charts';
import { BehaviorSubject, from, Observable } from 'rxjs'; import { BehaviorSubject, from, Observable, Subscription } from 'rxjs';
import { filter, map } from 'rxjs/operators'; import { filter, map } from 'rxjs/operators';
import { OperatorService } from 'app/core/core-services/operator.service'; import { OperatorService } from 'app/core/core-services/operator.service';
import { Deferred } from 'app/core/promises/deferred'; import { Deferred } from 'app/core/promises/deferred';
import { BaseRepository } from 'app/core/repositories/base-repository'; import { BaseRepository } from 'app/core/repositories/base-repository';
import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service'; import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service';
import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
import { BasePollDialogService } from 'app/core/ui-services/base-poll-dialog.service'; import { BasePollDialogService } from 'app/core/ui-services/base-poll-dialog.service';
import { PromptService } from 'app/core/ui-services/prompt.service'; import { PromptService } from 'app/core/ui-services/prompt.service';
import { ChartData } from 'app/shared/components/charts/charts.component'; import { ChartData } from 'app/shared/components/charts/charts.component';
import { EntitledUsersEntry } from 'app/shared/models/poll/base-poll';
import { BaseVote } from 'app/shared/models/poll/base-vote'; import { BaseVote } from 'app/shared/models/poll/base-vote';
import { BaseViewComponentDirective } from 'app/site/base/base-view'; import { BaseViewComponentDirective } from 'app/site/base/base-view';
import { ViewGroup } from 'app/site/users/models/view-group'; import { ViewGroup } from 'app/site/users/models/view-group';
import { ViewUser } from 'app/site/users/models/view-user'; import { ViewUser } from 'app/site/users/models/view-user';
import { BasePollRepositoryService } from '../services/base-poll-repository.service'; import { BasePollRepositoryService } from '../services/base-poll-repository.service';
import { EntitledUsersTableEntry } from './entitled-users-table/entitled-users-table.component';
import { PollService } from '../services/poll.service'; import { PollService } from '../services/poll.service';
import { ViewBasePoll } from '../models/view-base-poll'; import { ViewBasePoll } from '../models/view-base-poll';
import { ViewBaseVote } from '../models/view-base-vote'; import { ViewBaseVote } from '../models/view-base-vote';
@ -31,7 +35,7 @@ export interface BaseVoteData {
@Directive() @Directive()
export abstract class BasePollDetailComponentDirective<V extends ViewBasePoll, S extends PollService> export abstract class BasePollDetailComponentDirective<V extends ViewBasePoll, S extends PollService>
extends BaseViewComponentDirective extends BaseViewComponentDirective
implements OnInit { implements OnInit, OnDestroy {
/** /**
* All the groups of users. * All the groups of users.
*/ */
@ -73,8 +77,13 @@ export abstract class BasePollDetailComponentDirective<V extends ViewBasePoll, S
// The observable for the votes-per-user table // The observable for the votes-per-user table
public votesDataObservable: Observable<BaseVoteData[]>; public votesDataObservable: Observable<BaseVoteData[]>;
// The observable for the entitled-users-table
public entitledUsersObservable: Observable<EntitledUsersTableEntry[]>;
protected optionsLoaded = new Deferred(); protected optionsLoaded = new Deferred();
private entitledUsersSubscription: Subscription;
/** /**
* Constructor * Constructor
* *
@ -102,7 +111,8 @@ export abstract class BasePollDetailComponentDirective<V extends ViewBasePoll, S
protected pollService: S, protected pollService: S,
protected votesRepo: BaseRepository<ViewBaseVote, BaseVote, object>, protected votesRepo: BaseRepository<ViewBaseVote, BaseVote, object>,
protected operator: OperatorService, protected operator: OperatorService,
protected cd: ChangeDetectorRef protected cd: ChangeDetectorRef,
protected userRepo: UserRepositoryService
) { ) {
super(title, translate, matSnackbar); super(title, translate, matSnackbar);
this.setup(); this.setup();
@ -168,15 +178,11 @@ export abstract class BasePollDetailComponentDirective<V extends ViewBasePoll, S
} }
/** /**
* sets the votes data only if the poll wasn't pseudoanonymized * Set the votes data.
*/ */
protected setVotesData(data: BaseVoteData[]): void { protected setVotesData(data: BaseVoteData[]): void {
if (data.every(voteDate => !voteDate.user)) {
this.votesDataObservable = null;
} else {
this.votesDataObservable = from([data]); this.votesDataObservable = from([data]);
} }
}
/** /**
* Is called when the underlying vote data changes. Is supposed to call setVotesData * Is called when the underlying vote data changes. Is supposed to call setVotesData
@ -196,12 +202,46 @@ export abstract class BasePollDetailComponentDirective<V extends ViewBasePoll, S
this.createVotesData(); this.createVotesData();
this.optionsLoaded.resolve(); this.optionsLoaded.resolve();
this.cd.markForCheck(); this.cd.markForCheck();
this.setEntitledUsersData();
} }
}) })
); );
} }
} }
private setEntitledUsersData(): void {
if (this.entitledUsersSubscription) {
this.entitledUsersSubscription.unsubscribe();
}
const userIds = new Set<number>();
for (const entry of this.poll.entitled_users_at_stop) {
userIds.add(entry.user_id);
if (entry.vote_delegated_to_id) {
userIds.add(entry.vote_delegated_to_id);
}
}
this.entitledUsersSubscription = this.userRepo
.getViewModelListObservable()
.pipe(
filter(users => !!users.length),
map(users => users.filter(user => userIds.has(user.id)))
)
.subscribe(users => {
const entries = [];
for (const entry of this.poll.entitled_users_at_stop) {
entries.push({
...entry,
user: users.find(user => user.id === entry.user_id),
voted_verbose: `voted:${entry.voted}`,
vote_delegated_to: entry.vote_delegated_to_id
? users.find(user => user.id === entry.vote_delegated_to_id)
: null
});
}
this.entitledUsersObservable = from([entries]);
});
}
protected userHasVoteDelegation(user: ViewUser): boolean { protected userHasVoteDelegation(user: ViewUser): boolean {
/** /**
* This will be false if the operator does not have "can_see_extra_data" * This will be false if the operator does not have "can_see_extra_data"
@ -227,4 +267,10 @@ export abstract class BasePollDetailComponentDirective<V extends ViewBasePoll, S
return this.operator.viewUser; return this.operator.viewUser;
} }
} }
public ngOnDestroy(): void {
super.ngOnDestroy();
this.entitledUsersSubscription.unsubscribe();
this.entitledUsersSubscription = null;
}
} }

View File

@ -0,0 +1,35 @@
<div *ngIf="canSeeUsers">
<os-list-view-table
[listObservable]="entitledUsersObservable"
[columns]="columnDefinitionEntitledUsersTable"
[filterProps]="filterPropsEntitledUsersTable"
[allowProjector]="false"
[fullScreen]="false"
[vScrollFixed]="-1"
[listStorageKey]="list-storage-keys"
>
<!-- Header -->
<div *pblNgridHeaderCellDef="'*'; col as col">
{{ col.label | translate }}
</div>
<!-- Content -->
<div *pblNgridCellDef="'user_id'; row as entry">
<span *ngIf="entry.user">{{ entry.user.getFullName() }}</span>
<i *ngIf="!entry.user">{{ 'Deleted user' | translate }}</i>
</div>
<div *pblNgridCellDef="'voted'; row as entry">
<mat-icon *ngIf="entry.voted">check_box</mat-icon>
</div>
<div *pblNgridCellDef="'delegation'; row as entry">
<div *ngIf="entry.vote_delegated_to_id">
<span class="repr-prefix">represented by</span>&nbsp;
<span *ngIf="entry.vote_delegated_to">{{ entry.vote_delegated_to.getFullName() }}</span>
<i *ngIf="!entry.vote_delegated_to">{{ 'Deleted user' | translate }}</i>
</div>
</div>
</os-list-view-table>
</div>
<div class="no-can-see-users" *ngIf="!canSeeUsers">
<i>{{ 'You are not allowed to see all entitled users.' | translate }}</i>
</div>

View File

@ -0,0 +1,9 @@
.repr-prefix {
color: #888;
font-size: smaller;
}
.no-can-see-users {
margin: 1em;
text-align: center;
}

View File

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

View File

@ -0,0 +1,85 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Directive,
Input,
OnDestroy,
OnInit,
ViewEncapsulation
} from '@angular/core';
import { Title } from '@angular/platform-browser';
import { ActivatedRoute } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { PblColumnDefinition } from '@pebula/ngrid';
import { Label } from 'ng2-charts';
import { BehaviorSubject, from, Observable, Subscription } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { BaseComponent } from 'app/base.component';
import { OperatorService } from 'app/core/core-services/operator.service';
import { Deferred } from 'app/core/promises/deferred';
import { BaseRepository } from 'app/core/repositories/base-repository';
import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service';
import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
import { BasePollDialogService } from 'app/core/ui-services/base-poll-dialog.service';
import { PromptService } from 'app/core/ui-services/prompt.service';
import { ChartData } from 'app/shared/components/charts/charts.component';
import { EntitledUsersEntry } from 'app/shared/models/poll/base-poll';
import { BaseVote } from 'app/shared/models/poll/base-vote';
import { BaseViewComponentDirective } from 'app/site/base/base-view';
import { ViewGroup } from 'app/site/users/models/view-group';
import { ViewUser } from 'app/site/users/models/view-user';
export interface EntitledUsersTableEntry extends EntitledUsersEntry {
user_id: number;
user?: ViewUser;
voted: boolean;
voted_verbose: string;
vote_delegated_to_id?: number;
vote_delegated_to?: ViewUser;
}
@Component({
selector: 'os-entitled-users-table',
templateUrl: './entitled-users-table.component.html',
styleUrls: ['./entitled-users-table.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None
})
export class EntitledUsersTableComponent extends BaseComponent {
@Input()
public entitledUsersObservable: Observable<EntitledUsersTableEntry[]>;
@Input()
public listStorageKey: string;
public columnDefinitionEntitledUsersTable: PblColumnDefinition[] = [
{
prop: 'user_id',
width: 'auto',
label: 'Participant'
},
{
prop: 'voted',
width: 'auto',
label: 'Voted'
},
{
prop: 'delegation',
width: 'auto',
label: 'Delegated to'
}
];
public filterPropsEntitledUsersTable = ['user.getFullName', 'vote_delegated_to.getFullName', 'voted_verbose'];
public get canSeeUsers(): boolean {
return this.operator.hasPerms(this.permission.usersCanSeeName);
}
public constructor(title: Title, translate: TranslateService, private operator: OperatorService) {
super(title, translate);
}
}

View File

@ -65,6 +65,7 @@ export const PercentBaseVerbose = {
YNA: 'Yes/No/Abstain', YNA: 'Yes/No/Abstain',
valid: 'Valid votes', valid: 'Valid votes',
cast: 'Total votes cast', cast: 'Total votes cast',
entitled: 'All entitled users',
disabled: 'Disabled' disabled: 'Disabled'
}; };

View File

@ -7,6 +7,7 @@ import { ChartData, ChartDate } from 'app/shared/components/charts/charts.compon
import { AssignmentPollMethod } from 'app/shared/models/assignments/assignment-poll'; import { AssignmentPollMethod } from 'app/shared/models/assignments/assignment-poll';
import { import {
BasePoll, BasePoll,
EntitledUsersEntry,
MajorityMethod, MajorityMethod,
PercentBase, PercentBase,
PollColor, PollColor,
@ -111,6 +112,7 @@ export interface PollData {
votesvalid: number; votesvalid: number;
votesinvalid: number; votesinvalid: number;
votescast: number; votescast: number;
entitled_users_at_stop: EntitledUsersEntry[];
amount_global_yes?: number; amount_global_yes?: number;
amount_global_no?: number; amount_global_no?: number;
amount_global_abstain?: number; amount_global_abstain?: number;
@ -286,7 +288,11 @@ export abstract class PollService {
} }
public showPercentOfValidOrCast(poll: PollData | ViewBasePoll): boolean { public showPercentOfValidOrCast(poll: PollData | ViewBasePoll): boolean {
return poll.onehundred_percent_base === PercentBase.Valid || poll.onehundred_percent_base === PercentBase.Cast; return (
poll.onehundred_percent_base === PercentBase.Valid ||
poll.onehundred_percent_base === PercentBase.Cast ||
poll.onehundred_percent_base === PercentBase.Entitled
);
} }
public getSumTableKeys(poll: PollData | ViewBasePoll): VotingResult[] { public getSumTableKeys(poll: PollData | ViewBasePoll): VotingResult[] {

View File

@ -3,6 +3,7 @@ import { Component, Input } from '@angular/core';
import { BaseSlideComponentDirective } from 'app/slides/base-slide-component'; import { BaseSlideComponentDirective } from 'app/slides/base-slide-component';
import { CommonListOfSpeakersSlideData, SlideSpeaker } from '../common/common-list-of-speakers-slide-data'; import { CommonListOfSpeakersSlideData, SlideSpeaker } from '../common/common-list-of-speakers-slide-data';
// prettier-ignore
@Component({ @Component({
selector: 'os-current-list-of-speakers-overlay-slide', selector: 'os-current-list-of-speakers-overlay-slide',
templateUrl: './current-list-of-speakers-overlay-slide.component.html', templateUrl: './current-list-of-speakers-overlay-slide.component.html',

View File

@ -1,5 +1,5 @@
import { AssignmentPollMethod } from 'app/shared/models/assignments/assignment-poll'; import { AssignmentPollMethod } from 'app/shared/models/assignments/assignment-poll';
import { MajorityMethod, PercentBase, PollState, PollType } from 'app/shared/models/poll/base-poll'; import { EntitledUsersEntry, MajorityMethod, PercentBase, PollState, PollType } from 'app/shared/models/poll/base-poll';
import { AssignmentTitleInformation } from 'app/site/assignments/models/view-assignment'; import { AssignmentTitleInformation } from 'app/site/assignments/models/view-assignment';
import { BasePollSlideData } from 'app/slides/polls/base-poll-slide-data'; import { BasePollSlideData } from 'app/slides/polls/base-poll-slide-data';
@ -25,6 +25,8 @@ export interface AssignmentPollSlideData extends BasePollSlideData {
abstain?: number; abstain?: number;
}[]; }[];
entitled_users_at_stop: EntitledUsersEntry[];
// optional for published polls: // optional for published polls:
amount_global_yes?: number; amount_global_yes?: number;
amount_global_no?: number; amount_global_no?: number;

View File

@ -1,5 +1,5 @@
import { MotionPollMethod } from 'app/shared/models/motions/motion-poll'; import { MotionPollMethod } from 'app/shared/models/motions/motion-poll';
import { MajorityMethod, PercentBase, PollState, PollType } from 'app/shared/models/poll/base-poll'; import { EntitledUsersEntry, MajorityMethod, PercentBase, PollState, PollType } from 'app/shared/models/poll/base-poll';
import { MotionTitleInformation } from 'app/site/motions/models/view-motion'; import { MotionTitleInformation } from 'app/site/motions/models/view-motion';
import { BasePollSlideData } from 'app/slides/polls/base-poll-slide-data'; import { BasePollSlideData } from 'app/slides/polls/base-poll-slide-data';
@ -19,6 +19,8 @@ export interface MotionPollSlideData extends BasePollSlideData {
abstain?: number; abstain?: number;
}[]; }[];
entitled_users_at_stop: EntitledUsersEntry[];
// optional for published polls: // optional for published polls:
votesvalid: number; votesvalid: number;
votesinvalid: number; votesinvalid: number;

View File

@ -1,4 +1,4 @@
import { MajorityMethod, PercentBase, PollState, PollType } from 'app/shared/models/poll/base-poll'; import { EntitledUsersEntry, MajorityMethod, PercentBase, PollState, PollType } from 'app/shared/models/poll/base-poll';
export interface BasePollSlideData { export interface BasePollSlideData {
poll: { poll: {
@ -15,6 +15,8 @@ export interface BasePollSlideData {
abstain?: number; abstain?: number;
}[]; }[];
entitled_users_at_stop: EntitledUsersEntry[];
votesvalid: number; votesvalid: number;
votesinvalid: number; votesinvalid: number;
votescast: number; votescast: number;

View File

@ -10,7 +10,7 @@ services:
server: server:
image: os3-server-dev image: os3-server-dev
user: $UID:$GID user: $USER_ID:$GROUP_ID
build: build:
context: ../server context: ../server
dockerfile: docker/Dockerfile.dev dockerfile: docker/Dockerfile.dev

View File

@ -19,8 +19,9 @@ RUN rm -rf /var/lib/apt/lists/*
COPY requirements /app/requirements COPY requirements /app/requirements
COPY requirements.txt /app/requirements.txt COPY requirements.txt /app/requirements.txt
COPY make/requirements.txt /app/requirements/make_requirements.txt
RUN pip install -r requirements.txt -r requirements/saml.txt && \ RUN pip install -r requirements.txt -r requirements/saml.txt -r requirements/make_requirements.txt && \
rm -rf /root/.cache/pip rm -rf /root/.cache/pip
EXPOSE 8000 EXPOSE 8000

View File

@ -0,0 +1,18 @@
# Generated by jsangmeister on 2021-03-18 16:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("assignments", "0018_votes_amount"),
]
operations = [
migrations.AddField(
model_name="assignmentvote",
name="user_token",
field=models.CharField(null=True, max_length=16),
),
]

View File

@ -0,0 +1,16 @@
# Generated by jsangmeister on 2021-03-25 10:41
from django.db import migrations
from ...poll.migrations.vote_migration_helper import set_user_tokens
class Migration(migrations.Migration):
dependencies = [
("assignments", "0019_assignmentvote_user_token_1"),
]
operations = [
migrations.RunPython(set_user_tokens("assignments", "AssignmentVote")),
]

View File

@ -0,0 +1,24 @@
# Generated by jsangmeister on 2021-03-25 10:41
from django.db import migrations, models
import openslides.poll.models
class Migration(migrations.Migration):
dependencies = [
("assignments", "0020_assignmentvote_user_token_2"),
]
operations = [
migrations.AlterField(
model_name="assignmentvote",
name="user_token",
field=models.CharField(
null=False,
default=openslides.poll.models.generate_user_token,
max_length=16,
),
),
]

View File

@ -0,0 +1,63 @@
# Generated by jsangmeister on 2021-03-22 12:44
import jsonfield.encoder
import jsonfield.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("assignments", "0021_assignmentvote_user_token_3"),
]
operations = [
migrations.AddField(
model_name="assignmentpoll",
name="is_pseudoanonymized",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="assignmentpoll",
name="entitled_users_at_stop",
field=jsonfield.fields.JSONField(
dump_kwargs={
"cls": jsonfield.encoder.JSONEncoder,
"separators": (",", ":"),
},
load_kwargs={},
null=True,
),
),
migrations.AlterField(
model_name="assignmentpoll",
name="onehundred_percent_base",
field=models.CharField(
choices=[
("YN", "Yes/No per candidate"),
("YNA", "Yes/No/Abstain per candidate"),
("Y", "Sum of votes including general No/Abstain"),
("valid", "All valid ballots"),
("cast", "All casted ballots"),
("entitled", "All entitled users"),
("disabled", "Disabled (no percents)"),
],
max_length=8,
),
),
migrations.RenameField(
model_name="assignmentpoll",
old_name="db_votescast",
new_name="votescast",
),
migrations.RenameField(
model_name="assignmentpoll",
old_name="db_votesinvalid",
new_name="votesinvalid",
),
migrations.RenameField(
model_name="assignmentpoll",
old_name="db_votesvalid",
new_name="votesvalid",
),
]

View File

@ -0,0 +1,20 @@
# Generated by jsangmeister on 2021-03-22 12:44
from django.db import migrations
from ...poll.migrations.poll_migration_helper import (
calculate_vote_fields,
set_is_pseudoanonymized,
)
class Migration(migrations.Migration):
dependencies = [
("assignments", "0022_assignmentpoll_change_fields_1"),
]
operations = [
migrations.RunPython(set_is_pseudoanonymized("assignments", "AssignmentPoll")),
migrations.RunPython(calculate_vote_fields("assignments", "AssignmentPoll")),
]

View File

@ -9,7 +9,7 @@ from openslides.agenda.models import Speaker
from openslides.core.config import config from openslides.core.config import config
from openslides.core.models import Tag from openslides.core.models import Tag
from openslides.mediafiles.models import Mediafile from openslides.mediafiles.models import Mediafile
from openslides.poll.models import BaseOption, BasePoll, BaseVote from openslides.poll.models import BaseOption, BasePoll, BaseVote, BaseVoteManager
from openslides.utils.autoupdate import inform_changed_data from openslides.utils.autoupdate import inform_changed_data
from openslides.utils.exceptions import OpenSlidesError from openslides.utils.exceptions import OpenSlidesError
from openslides.utils.manager import BaseManager from openslides.utils.manager import BaseManager
@ -212,7 +212,7 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model
return {"title": self.title} return {"title": self.title}
class AssignmentVoteManager(BaseManager): class AssignmentVoteManager(BaseVoteManager):
""" """
Customized model manager to support our get_prefetched_queryset method. Customized model manager to support our get_prefetched_queryset method.
""" """
@ -325,6 +325,7 @@ class AssignmentPoll(RESTModelMixin, BasePoll):
PERCENT_BASE_YNA = "YNA" PERCENT_BASE_YNA = "YNA"
PERCENT_BASE_VALID = "valid" PERCENT_BASE_VALID = "valid"
PERCENT_BASE_CAST = "cast" PERCENT_BASE_CAST = "cast"
PERCENT_BASE_ENTITLED = "entitled"
PERCENT_BASE_DISABLED = "disabled" PERCENT_BASE_DISABLED = "disabled"
PERCENT_BASES = ( PERCENT_BASES = (
(PERCENT_BASE_YN, "Yes/No per candidate"), (PERCENT_BASE_YN, "Yes/No per candidate"),
@ -332,6 +333,7 @@ class AssignmentPoll(RESTModelMixin, BasePoll):
(PERCENT_BASE_Y, "Sum of votes including general No/Abstain"), (PERCENT_BASE_Y, "Sum of votes including general No/Abstain"),
(PERCENT_BASE_VALID, "All valid ballots"), (PERCENT_BASE_VALID, "All valid ballots"),
(PERCENT_BASE_CAST, "All casted ballots"), (PERCENT_BASE_CAST, "All casted ballots"),
(PERCENT_BASE_ENTITLED, "All entitled users"),
(PERCENT_BASE_DISABLED, "Disabled (no percents)"), (PERCENT_BASE_DISABLED, "Disabled (no percents)"),
) )
onehundred_percent_base = models.CharField( onehundred_percent_base = models.CharField(

View File

@ -522,6 +522,7 @@ class AssignmentPollViewSet(BasePollViewSet):
""" """
options = poll.get_options() options = poll.get_options()
if isinstance(data, dict): if isinstance(data, dict):
user_token = AssignmentVote.objects.generate_user_token()
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)
@ -540,6 +541,7 @@ class AssignmentPollViewSet(BasePollViewSet):
delegated_user=request_user, delegated_user=request_user,
weight=weight, weight=weight,
value=value, value=value,
user_token=user_token,
) )
inform_changed_data(vote) inform_changed_data(vote)
else: # global_no or global_abstain else: # global_no or global_abstain
@ -566,6 +568,7 @@ class AssignmentPollViewSet(BasePollViewSet):
request_user is the user who gives the vote, may be a delegate request_user is the user who gives the vote, may be a delegate
""" """
options = poll.get_options() options = poll.get_options()
user_token = AssignmentVote.objects.generate_user_token()
weight = vote_weight if config["users_activate_vote_weight"] else Decimal(1) weight = vote_weight if config["users_activate_vote_weight"] else Decimal(1)
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)
@ -575,6 +578,7 @@ class AssignmentPollViewSet(BasePollViewSet):
delegated_user=request_user, delegated_user=request_user,
value=result, value=result,
weight=weight, weight=weight,
user_token=user_token,
) )
inform_changed_data(vote) inform_changed_data(vote)
inform_changed_data(option) inform_changed_data(option)

View File

@ -0,0 +1,18 @@
# Generated by jsangmeister on 2021-03-18 16:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("motions", "0037_motionvote_delegated_user"),
]
operations = [
migrations.AddField(
model_name="motionvote",
name="user_token",
field=models.CharField(null=True, max_length=16),
),
]

View File

@ -0,0 +1,16 @@
# Generated by jsangmeister on 2021-03-18 16:27
from django.db import migrations
from ...poll.migrations.vote_migration_helper import set_user_tokens
class Migration(migrations.Migration):
dependencies = [
("motions", "0038_motionvote_user_token_1"),
]
operations = [
migrations.RunPython(set_user_tokens("motions", "MotionVote")),
]

View File

@ -0,0 +1,24 @@
# Generated by jsangmeister on 2021-03-18 16:27
from django.db import migrations, models
import openslides.poll.models
class Migration(migrations.Migration):
dependencies = [
("motions", "0039_motionvote_user_token_2"),
]
operations = [
migrations.AlterField(
model_name="motionvote",
name="user_token",
field=models.CharField(
null=False,
default=openslides.poll.models.generate_user_token,
max_length=16,
),
),
]

View File

@ -0,0 +1,62 @@
# Generated by jsangmeister on 2021-03-22 12:44
import jsonfield.encoder
import jsonfield.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("motions", "0040_motionvote_user_token_3"),
]
operations = [
migrations.AddField(
model_name="motionpoll",
name="is_pseudoanonymized",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="motionpoll",
name="entitled_users_at_stop",
field=jsonfield.fields.JSONField(
dump_kwargs={
"cls": jsonfield.encoder.JSONEncoder,
"separators": (",", ":"),
},
load_kwargs={},
null=True,
),
),
migrations.AlterField(
model_name="motionpoll",
name="onehundred_percent_base",
field=models.CharField(
choices=[
("YN", "Yes/No"),
("YNA", "Yes/No/Abstain"),
("valid", "All valid ballots"),
("cast", "All casted ballots"),
("entitled", "All entitled users"),
("disabled", "Disabled (no percents)"),
],
max_length=8,
),
),
migrations.RenameField(
model_name="motionpoll",
old_name="db_votescast",
new_name="votescast",
),
migrations.RenameField(
model_name="motionpoll",
old_name="db_votesinvalid",
new_name="votesinvalid",
),
migrations.RenameField(
model_name="motionpoll",
old_name="db_votesvalid",
new_name="votesvalid",
),
]

View File

@ -0,0 +1,20 @@
# Generated by jsangmeister on 2021-03-22 12:44
from django.db import migrations
from ...poll.migrations.poll_migration_helper import (
calculate_vote_fields,
set_is_pseudoanonymized,
)
class Migration(migrations.Migration):
dependencies = [
("motions", "0041_motionpoll_change_fields_1"),
]
operations = [
migrations.RunPython(set_is_pseudoanonymized("motions", "MotionPoll")),
migrations.RunPython(calculate_vote_fields("motions", "MotionPoll")),
]

View File

@ -8,7 +8,7 @@ from openslides.agenda.mixins import AgendaItemWithListOfSpeakersMixin
from openslides.core.config import config from openslides.core.config import config
from openslides.core.models import Tag from openslides.core.models import Tag
from openslides.mediafiles.models import Mediafile from openslides.mediafiles.models import Mediafile
from openslides.poll.models import BaseOption, BasePoll, BaseVote from openslides.poll.models import BaseOption, BasePoll, BaseVote, BaseVoteManager
from openslides.utils.autoupdate import inform_changed_data from openslides.utils.autoupdate import inform_changed_data
from openslides.utils.exceptions import OpenSlidesError from openslides.utils.exceptions import OpenSlidesError
from openslides.utils.manager import BaseManager from openslides.utils.manager import BaseManager
@ -828,7 +828,7 @@ class MotionBlock(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Mode
return {"title": self.title} return {"title": self.title}
class MotionVoteManager(BaseManager): class MotionVoteManager(BaseVoteManager):
""" """
Customized model manager to support our get_prefetched_queryset method. Customized model manager to support our get_prefetched_queryset method.
""" """

View File

@ -0,0 +1,36 @@
from ..models import BasePoll
def set_is_pseudoanonymized(poll_model_collection, poll_model_name):
"""
Takes all polls of the given model and updates is_pseudoanonymized, if necessary.
"""
def _set_is_pseudoanonymized(apps, schema_editor):
PollModel = apps.get_model(poll_model_collection, poll_model_name)
for poll in PollModel.objects.all():
if poll.type == BasePoll.TYPE_PSEUDOANONYMOUS or all(
not vote.user_id
for option in poll.options.all()
for vote in option.votes.all()
):
poll.is_pseudoanonymized = True
poll.save(skip_autoupdate=True)
return _set_is_pseudoanonymized
def calculate_vote_fields(poll_model_collection, poll_model_name):
"""
Takes all polls of the given model and updates votes*, if necessary.
"""
def _calculate_vote_fields(apps, schema_editor):
PollModel = apps.get_model(poll_model_collection, poll_model_name)
for poll in PollModel.objects.all():
if poll.state in (BasePoll.STATE_FINISHED, BasePoll.STATE_PUBLISHED):
BasePoll.calculate_votes(poll)
BasePoll.calculate_entitled_users(poll)
poll.save(skip_autoupdate=True)
return _calculate_vote_fields

View File

@ -0,0 +1,24 @@
from ..models import generate_user_token
def set_user_tokens(vote_model_collection, vote_model_name):
"""
Takes all votes of the given model and checks their tokens. For named polls,
multiple votes with the same user_id will get the same token.
"""
def _set_user_token(apps, schema_editor):
user_token_map = {}
VoteModel = apps.get_model(vote_model_collection, vote_model_name)
for vote in VoteModel.objects.all():
if vote.user is not None:
key = (vote.user_id, vote.option.poll_id)
if key not in user_token_map:
user_token_map[key] = generate_user_token()
token = user_token_map[key]
else:
token = generate_user_token()
vote.user_token = token
vote.save(skip_autoupdate=True)
return _set_user_token

View File

@ -4,12 +4,21 @@ 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
from django.db import models from django.db import models
from django.utils.crypto import get_random_string
from jsonfield import JSONField
from openslides.utils.manager import BaseManager
from ..core.config import config from ..core.config import config
from ..utils.autoupdate import inform_changed_data, inform_deleted_data from ..utils.autoupdate import inform_changed_data, inform_deleted_data
from ..utils.models import SET_NULL_AND_AUTOUPDATE from ..utils.models import SET_NULL_AND_AUTOUPDATE
def generate_user_token():
""" Generates a 16 character alphanumeric token. """
return get_random_string(16)
class BaseVote(models.Model): class BaseVote(models.Model):
""" """
All subclasses must have option attribute with the related name "votes" All subclasses must have option attribute with the related name "votes"
@ -37,11 +46,21 @@ class BaseVote(models.Model):
on_delete=SET_NULL_AND_AUTOUPDATE, on_delete=SET_NULL_AND_AUTOUPDATE,
related_name="%(class)s_delegated_votes", related_name="%(class)s_delegated_votes",
) )
user_token = models.CharField(default=generate_user_token, max_length=16)
class Meta: class Meta:
abstract = True abstract = True
class BaseVoteManager(BaseManager):
"""
Base vote manager that supplies the generate_user_token method.
"""
def generate_user_token(self):
return generate_user_token()
class BaseOption(models.Model): class BaseOption(models.Model):
""" """
All subclasses must have poll attribute with the related name "options" All subclasses must have poll attribute with the related name "options"
@ -134,21 +153,21 @@ class BasePoll(models.Model):
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) voted = models.ManyToManyField(settings.AUTH_USER_MODEL, blank=True)
db_votesvalid = models.DecimalField( votesvalid = models.DecimalField(
null=True, null=True,
blank=True, blank=True,
validators=[MinValueValidator(Decimal("-2"))], validators=[MinValueValidator(Decimal("-2"))],
max_digits=15, max_digits=15,
decimal_places=6, decimal_places=6,
) )
db_votesinvalid = models.DecimalField( votesinvalid = models.DecimalField(
null=True, null=True,
blank=True, blank=True,
validators=[MinValueValidator(Decimal("-2"))], validators=[MinValueValidator(Decimal("-2"))],
max_digits=15, max_digits=15,
decimal_places=6, decimal_places=6,
) )
db_votescast = models.DecimalField( votescast = models.DecimalField(
null=True, null=True,
blank=True, blank=True,
validators=[MinValueValidator(Decimal("-2"))], validators=[MinValueValidator(Decimal("-2"))],
@ -160,12 +179,14 @@ class BasePoll(models.Model):
PERCENT_BASE_YNA = "YNA" PERCENT_BASE_YNA = "YNA"
PERCENT_BASE_VALID = "valid" PERCENT_BASE_VALID = "valid"
PERCENT_BASE_CAST = "cast" PERCENT_BASE_CAST = "cast"
PERCENT_BASE_ENTITLED = "entitled"
PERCENT_BASE_DISABLED = "disabled" PERCENT_BASE_DISABLED = "disabled"
PERCENT_BASES: Iterable[Tuple[str, str]] = ( PERCENT_BASES: Iterable[Tuple[str, str]] = (
(PERCENT_BASE_YN, "Yes/No"), (PERCENT_BASE_YN, "Yes/No"),
(PERCENT_BASE_YNA, "Yes/No/Abstain"), (PERCENT_BASE_YNA, "Yes/No/Abstain"),
(PERCENT_BASE_VALID, "All valid ballots"), (PERCENT_BASE_VALID, "All valid ballots"),
(PERCENT_BASE_CAST, "All casted ballots"), (PERCENT_BASE_CAST, "All casted ballots"),
(PERCENT_BASE_ENTITLED, "All entitled users"),
(PERCENT_BASE_DISABLED, "Disabled (no percents)"), (PERCENT_BASE_DISABLED, "Disabled (no percents)"),
) # type: ignore ) # type: ignore
onehundred_percent_base = models.CharField( onehundred_percent_base = models.CharField(
@ -186,57 +207,13 @@ class BasePoll(models.Model):
max_length=14, blank=False, null=False, choices=MAJORITY_METHODS max_length=14, blank=False, null=False, choices=MAJORITY_METHODS
) )
is_pseudoanonymized = models.BooleanField(default=False)
entitled_users_at_stop = JSONField(null=True)
class Meta: class Meta:
abstract = True abstract = True
def get_votesvalid(self):
if self.type == self.TYPE_ANALOG:
return self.db_votesvalid
else:
return Decimal(self.amount_users_voted_with_individual_weight())
def set_votesvalid(self, value):
if self.type != self.TYPE_ANALOG:
raise ValueError("Do not set votesvalid for non analog polls")
self.db_votesvalid = value
votesvalid = property(get_votesvalid, set_votesvalid)
def get_votesinvalid(self):
if self.type == self.TYPE_ANALOG:
return self.db_votesinvalid
else:
return Decimal(0)
def set_votesinvalid(self, value):
if self.type != self.TYPE_ANALOG:
raise ValueError("Do not set votesinvalid for non analog polls")
self.db_votesinvalid = value
votesinvalid = property(get_votesinvalid, set_votesinvalid)
def get_votescast(self):
if self.type == self.TYPE_ANALOG:
return self.db_votescast
else:
return Decimal(self.amount_users_voted())
def set_votescast(self, value):
if self.type != self.TYPE_ANALOG:
raise ValueError("Do not set votescast for non analog polls")
self.db_votescast = value
votescast = property(get_votescast, set_votescast)
def amount_users_voted(self):
return len(self.voted.all())
def amount_users_voted_with_individual_weight(self):
if config["users_activate_vote_weight"]:
return sum(user.vote_weight for user in self.voted.all())
else:
return self.amount_users_voted()
def create_options(self): def create_options(self):
""" Should be called after creation of this model. """ """ Should be called after creation of this model. """
raise NotImplementedError() raise NotImplementedError()
@ -268,6 +245,8 @@ class BasePoll(models.Model):
def pseudoanonymize(self): def pseudoanonymize(self):
for option in self.get_options(): for option in self.get_options():
option.pseudoanonymize() option.pseudoanonymize()
self.is_pseudoanonymized = True
self.save()
def reset(self): def reset(self):
for option in self.get_options(): for option in self.get_options():
@ -281,4 +260,38 @@ class BasePoll(models.Model):
self.votesvalid = None self.votesvalid = None
self.votesinvalid = None self.votesinvalid = None
self.votescast = None self.votescast = None
if self.type != self.TYPE_PSEUDOANONYMOUS:
self.is_pseudoanonymized = False
self.save()
def calculate_votes(self):
if self.type != BasePoll.TYPE_ANALOG:
self.votescast = self.voted.count()
if config["users_activate_vote_weight"]:
self.votesvalid = sum(self.voted.values_list("vote_weight", flat=True))
else:
self.votesvalid = self.votescast
self.votesinvalid = Decimal(0)
def calculate_entitled_users(self):
entitled_users = []
for group in self.groups.all():
for user in group.user_set.all():
if user.is_present:
entitled_users.append(
{
"user_id": user.id,
"voted": user in self.voted.all(),
"vote_delegated_to_id": user.vote_delegated_to_id,
}
)
self.entitled_users_at_stop = entitled_users
def stop(self):
"""
Saves a snapshot of the current voted users into the relevant fields and stops the poll.
"""
self.calculate_votes()
self.calculate_entitled_users()
self.state = self.STATE_FINISHED
self.save() self.save()

View File

@ -5,6 +5,7 @@ from ..utils.rest_api import (
CharField, CharField,
DecimalField, DecimalField,
IdPrimaryKeyRelatedField, IdPrimaryKeyRelatedField,
JSONField,
ModelSerializer, ModelSerializer,
SerializerMethodField, SerializerMethodField,
ValidationError, ValidationError,
@ -18,6 +19,7 @@ BASE_VOTE_FIELDS = (
"value", "value",
"user", "user",
"delegated_user", "delegated_user",
"user_token",
"option", "option",
"pollstate", "pollstate",
) )
@ -58,7 +60,9 @@ BASE_POLL_FIELDS = (
"id", "id",
"onehundred_percent_base", "onehundred_percent_base",
"majority_method", "majority_method",
"is_pseudoanonymized",
"voted", "voted",
"entitled_users_at_stop",
) )
@ -69,27 +73,21 @@ class BasePollSerializer(ModelSerializer):
) )
options = IdPrimaryKeyRelatedField(many=True, read_only=True) options = IdPrimaryKeyRelatedField(many=True, read_only=True)
voted = IdPrimaryKeyRelatedField(many=True, read_only=True) voted = IdPrimaryKeyRelatedField(many=True, read_only=True)
entitled_users_at_stop = JSONField(required=False)
votesvalid = DecimalField(
max_digits=15, decimal_places=6, min_value=-2, read_only=True
)
votesinvalid = DecimalField(
max_digits=15, decimal_places=6, min_value=-2, read_only=True
)
votescast = DecimalField(
max_digits=15, decimal_places=6, min_value=-2, read_only=True
)
def create(self, validated_data): def create(self, validated_data):
""" """
Match the 100 percent base to the pollmethod. Change the base, if it does not Match the 100 percent base to the pollmethod. Change the base, if it does not
fit to the pollmethod fit to the pollmethod.
Set is_pseudoanonymized if type is pseudoanonymous.
""" """
new_100_percent_base = self.norm_100_percent_base_to_pollmethod( new_100_percent_base = self.norm_100_percent_base_to_pollmethod(
validated_data["onehundred_percent_base"], validated_data["pollmethod"] validated_data["onehundred_percent_base"], validated_data["pollmethod"]
) )
if new_100_percent_base is not None: if new_100_percent_base is not None:
validated_data["onehundred_percent_base"] = new_100_percent_base validated_data["onehundred_percent_base"] = new_100_percent_base
if validated_data["type"] == BasePoll.TYPE_PSEUDOANONYMOUS:
validated_data["is_pseudoanonymized"] = True
return super().create(validated_data) return super().create(validated_data)
def update(self, instance, validated_data): def update(self, instance, validated_data):
@ -100,8 +98,15 @@ class BasePollSerializer(ModelSerializer):
E.g. the pollmethod is YN, but the 100%-base is YNA, this might not be E.g. the pollmethod is YN, but the 100%-base is YNA, this might not be
possible (see implementing serializers to see forbidden combinations) possible (see implementing serializers to see forbidden combinations)
Also updates is_pseudoanonymized, if needed.
""" """
old_100_percent_base = instance.onehundred_percent_base old_100_percent_base = instance.onehundred_percent_base
if "type" in validated_data:
if validated_data["type"] == BasePoll.TYPE_PSEUDOANONYMOUS:
validated_data["is_pseudoanonymized"] = True
else:
validated_data["is_pseudoanonymized"] = False
instance = super().update(instance, validated_data) instance = super().update(instance, validated_data)
new_100_percent_base = self.norm_100_percent_base_to_pollmethod( new_100_percent_base = self.norm_100_percent_base_to_pollmethod(

View File

@ -146,8 +146,7 @@ class BasePollViewSet(ModelViewSet):
if poll.state != BasePoll.STATE_STARTED: if poll.state != BasePoll.STATE_STARTED:
raise ValidationError({"detail": "Wrong poll state"}) raise ValidationError({"detail": "Wrong poll state"})
poll.state = BasePoll.STATE_FINISHED poll.stop()
poll.save()
inform_changed_data(poll.get_votes()) inform_changed_data(poll.get_votes())
inform_changed_data(poll.get_options()) inform_changed_data(poll.get_options())
self.extend_history_information(["Voting stopped"]) self.extend_history_information(["Voting stopped"])

View File

@ -6,6 +6,7 @@ from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.urls import reverse from django.urls import reverse
from rest_framework import status from rest_framework import status
from rest_framework.test import APIClient
from openslides.assignments.models import ( from openslides.assignments.models import (
Assignment, Assignment,
@ -108,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(40): with self.assertNumQueries(38):
response = self.client.post( response = self.client.post(
reverse("assignmentpoll-list"), reverse("assignmentpoll-list"),
{ {
@ -886,6 +887,7 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass):
self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
self.assertEqual(AssignmentVote.objects.count(), 6) self.assertEqual(AssignmentVote.objects.count(), 6)
poll = AssignmentPoll.objects.get() poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, Decimal("4.64")) self.assertEqual(poll.votesvalid, Decimal("4.64"))
self.assertEqual(poll.votesinvalid, Decimal("-2")) self.assertEqual(poll.votesinvalid, Decimal("-2"))
self.assertEqual(poll.votescast, Decimal("-2")) self.assertEqual(poll.votescast, Decimal("-2"))
@ -1056,6 +1058,7 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass):
) )
self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = AssignmentPoll.objects.get() poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, Decimal("4.64")) self.assertEqual(poll.votesvalid, Decimal("4.64"))
self.assertEqual(poll.votesinvalid, Decimal("-2")) self.assertEqual(poll.votesinvalid, Decimal("-2"))
self.assertEqual(poll.votescast, Decimal("3")) self.assertEqual(poll.votescast, Decimal("3"))
@ -1081,6 +1084,7 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass):
) )
self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = AssignmentPoll.objects.get() poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED) self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED)
self.assertEqual(poll.votesvalid, Decimal("0")) self.assertEqual(poll.votesvalid, Decimal("0"))
self.assertEqual(poll.votesinvalid, Decimal("0")) self.assertEqual(poll.votesinvalid, Decimal("0"))
@ -1099,11 +1103,11 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass):
self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
self.assertEqual(AssignmentVote.objects.count(), 3) self.assertEqual(AssignmentVote.objects.count(), 3)
poll = AssignmentPoll.objects.get() poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, Decimal("1")) self.assertEqual(poll.votesvalid, Decimal("1"))
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.state, AssignmentPoll.STATE_STARTED) self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED)
self.assertEqual(poll.amount_users_voted_with_individual_weight(), Decimal("1"))
self.assertTrue(self.admin in poll.voted.all()) self.assertTrue(self.admin in poll.voted.all())
option1 = poll.options.get(pk=1) option1 = poll.options.get(pk=1)
option2 = poll.options.get(pk=2) option2 = poll.options.get(pk=2)
@ -1132,11 +1136,11 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass):
self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
self.assertEqual(AssignmentVote.objects.count(), 3) self.assertEqual(AssignmentVote.objects.count(), 3)
poll = AssignmentPoll.objects.get() poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, weight) self.assertEqual(poll.votesvalid, weight)
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.state, AssignmentPoll.STATE_STARTED) self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED)
self.assertEqual(poll.amount_users_voted_with_individual_weight(), weight)
option1 = poll.options.get(pk=1) option1 = poll.options.get(pk=1)
option2 = poll.options.get(pk=2) option2 = poll.options.get(pk=2)
option3 = poll.options.get(pk=3) option3 = poll.options.get(pk=3)
@ -1321,6 +1325,51 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass):
self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST)
self.assertFalse(AssignmentVote.objects.exists()) self.assertFalse(AssignmentVote.objects.exists())
def test_same_user_token(self):
self.add_candidate()
self.add_candidate()
self.start_poll()
response = self.client.post(
reverse("assignmentpoll-vote", args=[self.poll.pk]),
{"data": {"1": "Y", "2": "N", "3": "A"}},
format="json",
)
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
self.assertEqual(AssignmentVote.objects.count(), 3)
votes = AssignmentVote.objects.all()
user_token = votes[0].user_token
for vote in votes[1:2]:
assert vote.user_token == user_token
def test_valid_votes_count_with_deleted_user(self):
self.add_candidate()
self.start_poll()
user, user_password = self.create_user()
user.groups.add(GROUP_ADMIN_PK)
user.is_present = True
user.save()
user_client = APIClient()
user_client.login(username=user.username, password=user_password)
response = self.client.post(
reverse("assignmentpoll-vote", args=[self.poll.pk]),
{"data": {"1": "Y", "2": "N"}},
format="json",
)
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
response = user_client.post(
reverse("assignmentpoll-vote", args=[self.poll.pk]),
{"data": {"1": "N", "2": "Y"}},
format="json",
)
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
self.poll.stop()
response = self.client.delete(reverse("user-detail", args=[user.pk]))
self.assertHttpStatusVerbose(response, status.HTTP_204_NO_CONTENT)
poll = AssignmentPoll.objects.get()
self.assertEqual(poll.votesvalid, Decimal("2"))
self.assertEqual(poll.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("2"))
class VoteAssignmentPollNamedY(VoteAssignmentPollBaseTestClass): class VoteAssignmentPollNamedY(VoteAssignmentPollBaseTestClass):
def create_poll(self): def create_poll(self):
@ -1343,6 +1392,7 @@ class VoteAssignmentPollNamedY(VoteAssignmentPollBaseTestClass):
) )
self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = AssignmentPoll.objects.get() poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED) self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED)
self.assertEqual(poll.votesvalid, Decimal("0")) self.assertEqual(poll.votesvalid, Decimal("0"))
self.assertEqual(poll.votesinvalid, Decimal("0")) self.assertEqual(poll.votesinvalid, Decimal("0"))
@ -1360,6 +1410,7 @@ class VoteAssignmentPollNamedY(VoteAssignmentPollBaseTestClass):
self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
self.assertEqual(AssignmentVote.objects.count(), 1) self.assertEqual(AssignmentVote.objects.count(), 1)
poll = AssignmentPoll.objects.get() poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, Decimal("1")) self.assertEqual(poll.votesvalid, Decimal("1"))
self.assertEqual(poll.votesinvalid, Decimal("0")) self.assertEqual(poll.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("1")) self.assertEqual(poll.votescast, Decimal("1"))
@ -1671,6 +1722,7 @@ class VoteAssignmentPollNamedN(VoteAssignmentPollBaseTestClass):
) )
self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = AssignmentPoll.objects.get() poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED) self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED)
self.assertEqual(poll.votesvalid, Decimal("0")) self.assertEqual(poll.votesvalid, Decimal("0"))
self.assertEqual(poll.votesinvalid, Decimal("0")) self.assertEqual(poll.votesinvalid, Decimal("0"))
@ -1688,6 +1740,7 @@ class VoteAssignmentPollNamedN(VoteAssignmentPollBaseTestClass):
self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
self.assertEqual(AssignmentVote.objects.count(), 1) self.assertEqual(AssignmentVote.objects.count(), 1)
poll = AssignmentPoll.objects.get() poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, Decimal("1")) self.assertEqual(poll.votesvalid, Decimal("1"))
self.assertEqual(poll.votesinvalid, Decimal("0")) self.assertEqual(poll.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("1")) self.assertEqual(poll.votescast, Decimal("1"))
@ -1986,6 +2039,7 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass):
) )
self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = AssignmentPoll.objects.get() poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED) self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED)
self.assertEqual(poll.votesvalid, Decimal("0")) self.assertEqual(poll.votesvalid, Decimal("0"))
self.assertEqual(poll.votesinvalid, Decimal("0")) self.assertEqual(poll.votesinvalid, Decimal("0"))
@ -2004,6 +2058,7 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass):
self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
self.assertEqual(AssignmentVote.objects.count(), 3) self.assertEqual(AssignmentVote.objects.count(), 3)
poll = AssignmentPoll.objects.get() poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, Decimal("1")) self.assertEqual(poll.votesvalid, Decimal("1"))
self.assertEqual(poll.votesinvalid, Decimal("0")) self.assertEqual(poll.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("1")) self.assertEqual(poll.votescast, Decimal("1"))
@ -2163,6 +2218,22 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass):
self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST)
self.assertFalse(AssignmentVote.objects.exists()) self.assertFalse(AssignmentVote.objects.exists())
def test_same_user_token(self):
self.add_candidate()
self.add_candidate()
self.start_poll()
response = self.client.post(
reverse("assignmentpoll-vote", args=[self.poll.pk]),
{"data": {"1": "Y", "2": "N", "3": "A"}},
format="json",
)
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
self.assertEqual(AssignmentVote.objects.count(), 3)
votes = AssignmentVote.objects.all()
user_token = votes[0].user_token
for vote in votes[1:2]:
assert vote.user_token == user_token
class VoteAssignmentPollPseudoanonymousY(VoteAssignmentPollBaseTestClass): class VoteAssignmentPollPseudoanonymousY(VoteAssignmentPollBaseTestClass):
def create_poll(self): def create_poll(self):
@ -2185,6 +2256,7 @@ class VoteAssignmentPollPseudoanonymousY(VoteAssignmentPollBaseTestClass):
) )
self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = AssignmentPoll.objects.get() poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED) self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED)
self.assertEqual(poll.votesvalid, Decimal("0")) self.assertEqual(poll.votesvalid, Decimal("0"))
self.assertEqual(poll.votesinvalid, Decimal("0")) self.assertEqual(poll.votesinvalid, Decimal("0"))
@ -2202,6 +2274,7 @@ class VoteAssignmentPollPseudoanonymousY(VoteAssignmentPollBaseTestClass):
self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
self.assertEqual(AssignmentVote.objects.count(), 1) self.assertEqual(AssignmentVote.objects.count(), 1)
poll = AssignmentPoll.objects.get() poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, Decimal("1")) self.assertEqual(poll.votesvalid, Decimal("1"))
self.assertEqual(poll.votesinvalid, Decimal("0")) self.assertEqual(poll.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("1")) self.assertEqual(poll.votescast, Decimal("1"))
@ -2433,6 +2506,7 @@ class VoteAssignmentPollPseudoanonymousN(VoteAssignmentPollBaseTestClass):
) )
self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = AssignmentPoll.objects.get() poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED) self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED)
self.assertEqual(poll.votesvalid, Decimal("0")) self.assertEqual(poll.votesvalid, Decimal("0"))
self.assertEqual(poll.votesinvalid, Decimal("0")) self.assertEqual(poll.votesinvalid, Decimal("0"))
@ -2450,6 +2524,7 @@ class VoteAssignmentPollPseudoanonymousN(VoteAssignmentPollBaseTestClass):
self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
self.assertEqual(AssignmentVote.objects.count(), 1) self.assertEqual(AssignmentVote.objects.count(), 1)
poll = AssignmentPoll.objects.get() poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, Decimal("1")) self.assertEqual(poll.votesvalid, Decimal("1"))
self.assertEqual(poll.votesinvalid, Decimal("0")) self.assertEqual(poll.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("1")) self.assertEqual(poll.votescast, Decimal("1"))
@ -2681,8 +2756,9 @@ class PseudoanonymizeAssignmentPoll(TestCase):
) )
self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = AssignmentPoll.objects.get() poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.is_pseudoanonymized, True)
self.assertEqual(poll.get_votes().count(), 2) self.assertEqual(poll.get_votes().count(), 2)
self.assertEqual(poll.amount_users_voted_with_individual_weight(), 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"))

View File

@ -112,6 +112,7 @@ class CreateMotionPoll(TestCase):
self.assertHttpStatusVerbose(response, status.HTTP_201_CREATED) self.assertHttpStatusVerbose(response, status.HTTP_201_CREATED)
self.assertTrue(MotionPoll.objects.exists()) self.assertTrue(MotionPoll.objects.exists())
poll = MotionPoll.objects.get() poll = MotionPoll.objects.get()
self.assertEqual(poll.is_pseudoanonymized, False)
self.assertEqual(poll.title, "test_title_ailai4toogh3eefaa2Vo") self.assertEqual(poll.title, "test_title_ailai4toogh3eefaa2Vo")
self.assertEqual(poll.pollmethod, "YNA") self.assertEqual(poll.pollmethod, "YNA")
self.assertEqual(poll.type, "named") self.assertEqual(poll.type, "named")
@ -394,6 +395,27 @@ class UpdateMotionPoll(TestCase):
poll = MotionPoll.objects.get() poll = MotionPoll.objects.get()
self.assertEqual(poll.type, "analog") self.assertEqual(poll.type, "analog")
def test_patch_type_to_pseudoanonymous(self):
response = self.client.patch(
reverse("motionpoll-detail", args=[self.poll.pk]),
{"type": BasePoll.TYPE_PSEUDOANONYMOUS},
)
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = MotionPoll.objects.get()
self.assertEqual(poll.type, BasePoll.TYPE_PSEUDOANONYMOUS)
self.assertTrue(poll.is_pseudoanonymized)
def test_patch_type_to_named(self):
self.test_patch_type_to_pseudoanonymous()
response = self.client.patch(
reverse("motionpoll-detail", args=[self.poll.pk]),
{"type": BasePoll.TYPE_NAMED},
)
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = MotionPoll.objects.get()
self.assertEqual(poll.type, BasePoll.TYPE_NAMED)
self.assertFalse(poll.is_pseudoanonymized)
def test_patch_invalid_type(self): def test_patch_invalid_type(self):
response = self.client.patch( response = self.client.patch(
reverse("motionpoll-detail", args=[self.poll.pk]), {"type": "invalid"} reverse("motionpoll-detail", args=[self.poll.pk]), {"type": "invalid"}
@ -585,6 +607,7 @@ class VoteMotionPollAnalog(TestCase):
) )
self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = MotionPoll.objects.get() poll = MotionPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, Decimal("4.64")) self.assertEqual(poll.votesvalid, Decimal("4.64"))
self.assertEqual(poll.votesinvalid, Decimal("-2")) self.assertEqual(poll.votesinvalid, Decimal("-2"))
self.assertEqual(poll.votescast, Decimal("-2")) self.assertEqual(poll.votescast, Decimal("-2"))
@ -668,6 +691,7 @@ class VoteMotionPollAnalog(TestCase):
) )
self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = MotionPoll.objects.get() poll = MotionPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, Decimal("4.64")) self.assertEqual(poll.votesvalid, Decimal("4.64"))
self.assertEqual(poll.votesinvalid, Decimal("-2")) self.assertEqual(poll.votesinvalid, Decimal("-2"))
self.assertEqual(poll.votescast, Decimal("3")) self.assertEqual(poll.votescast, Decimal("3"))
@ -715,6 +739,7 @@ class VoteMotionPollNamed(TestCase):
response = self.client.post(reverse("motionpoll-start", args=[self.poll.pk])) response = self.client.post(reverse("motionpoll-start", 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()
poll.calculate_votes()
self.assertEqual(poll.state, MotionPoll.STATE_STARTED) self.assertEqual(poll.state, MotionPoll.STATE_STARTED)
self.assertEqual(poll.votesvalid, Decimal("0")) self.assertEqual(poll.votesvalid, Decimal("0"))
self.assertEqual(poll.votesinvalid, Decimal("0")) self.assertEqual(poll.votesinvalid, Decimal("0"))
@ -730,6 +755,7 @@ class VoteMotionPollNamed(TestCase):
) )
self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = MotionPoll.objects.get() poll = MotionPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, Decimal("1")) self.assertEqual(poll.votesvalid, Decimal("1"))
self.assertEqual(poll.votesinvalid, Decimal("0")) self.assertEqual(poll.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("1")) self.assertEqual(poll.votescast, Decimal("1"))
@ -754,11 +780,11 @@ class VoteMotionPollNamed(TestCase):
) )
self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = MotionPoll.objects.get() poll = MotionPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, weight) self.assertEqual(poll.votesvalid, weight)
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_users_voted_with_individual_weight(), weight)
option = poll.options.get() option = poll.options.get()
self.assertEqual(option.yes, Decimal("0")) self.assertEqual(option.yes, Decimal("0"))
self.assertEqual(option.no, Decimal("0")) self.assertEqual(option.no, Decimal("0"))
@ -784,6 +810,7 @@ class VoteMotionPollNamed(TestCase):
) )
self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST)
poll = MotionPoll.objects.get() poll = MotionPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, Decimal("1")) self.assertEqual(poll.votesvalid, Decimal("1"))
self.assertEqual(poll.votesinvalid, Decimal("0")) self.assertEqual(poll.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("1")) self.assertEqual(poll.votescast, Decimal("1"))
@ -874,6 +901,7 @@ class VoteMotionPollNamed(TestCase):
) )
self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = MotionPoll.objects.get() poll = MotionPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, Decimal("1")) self.assertEqual(poll.votesvalid, Decimal("1"))
self.assertEqual(poll.votesinvalid, Decimal("0")) self.assertEqual(poll.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("1")) self.assertEqual(poll.votescast, Decimal("1"))
@ -893,6 +921,7 @@ class VoteMotionPollNamed(TestCase):
) )
self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = MotionPoll.objects.get() poll = MotionPoll.objects.get()
poll.calculate_votes()
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"))
@ -1007,6 +1036,7 @@ class VoteMotionPollPseudoanonymous(TestCase):
response = self.client.post(reverse("motionpoll-start", args=[self.poll.pk])) response = self.client.post(reverse("motionpoll-start", 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()
poll.calculate_votes()
self.assertEqual(poll.state, MotionPoll.STATE_STARTED) self.assertEqual(poll.state, MotionPoll.STATE_STARTED)
self.assertEqual(poll.votesvalid, Decimal("0")) self.assertEqual(poll.votesvalid, Decimal("0"))
self.assertEqual(poll.votesinvalid, Decimal("0")) self.assertEqual(poll.votesinvalid, Decimal("0"))
@ -1022,11 +1052,11 @@ class VoteMotionPollPseudoanonymous(TestCase):
) )
self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = MotionPoll.objects.get() poll = MotionPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, Decimal("1")) self.assertEqual(poll.votesvalid, Decimal("1"))
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_users_voted_with_individual_weight(), 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"))
@ -1142,6 +1172,42 @@ class StopMotionPoll(TestCase):
self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST)
self.assertEqual(MotionPoll.objects.get().state, MotionPoll.STATE_CREATED) self.assertEqual(MotionPoll.objects.get().state, MotionPoll.STATE_CREATED)
def setup_entitled_users(self):
self.poll.state = MotionPoll.STATE_STARTED
self.poll.save()
self.admin = get_user_model().objects.get(username="admin")
self.admin.is_present = True
self.admin.save()
self.group = get_group_model().objects.get(pk=GROUP_ADMIN_PK)
self.poll.groups.add(self.group)
def test_stop_poll_with_entitled_users(self):
self.setup_entitled_users()
response = self.client.post(reverse("motionpoll-stop", args=[self.poll.pk]))
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
self.assertEqual(
MotionPoll.objects.get().entitled_users_at_stop,
[{"user_id": self.admin.id, "voted": False, "vote_delegated_to_id": None}],
)
def test_stop_poll_with_entitled_users_and_vote_delegation(self):
self.setup_entitled_users()
user, _ = self.create_user()
self.admin.vote_delegated_to = user
self.admin.save()
response = self.client.post(reverse("motionpoll-stop", args=[self.poll.pk]))
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
self.assertEqual(
MotionPoll.objects.get().entitled_users_at_stop,
[
{
"user_id": self.admin.id,
"voted": False,
"vote_delegated_to_id": user.id,
}
],
)
class PublishMotionPoll(TestCase): class PublishMotionPoll(TestCase):
def advancedSetUp(self): def advancedSetUp(self):
@ -1213,8 +1279,9 @@ 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()
poll.calculate_votes()
self.assertEqual(poll.is_pseudoanonymized, True)
self.assertEqual(poll.get_votes().count(), 2) self.assertEqual(poll.get_votes().count(), 2)
self.assertEqual(poll.amount_users_voted_with_individual_weight(), 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"))
@ -1282,7 +1349,6 @@ class ResetMotionPoll(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(), 0) self.assertEqual(poll.get_votes().count(), 0)
self.assertEqual(poll.amount_users_voted_with_individual_weight(), 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)
@ -1292,6 +1358,24 @@ class ResetMotionPoll(TestCase):
self.assertEqual(option.abstain, Decimal("0")) self.assertEqual(option.abstain, Decimal("0"))
self.assertFalse(option.votes.exists()) self.assertFalse(option.votes.exists())
def test_reset_pseudoanonymized(self):
self.poll.type = BasePoll.TYPE_NAMED
self.poll.is_pseudoanonymized = True
self.poll.save()
response = self.client.post(reverse("motionpoll-reset", args=[self.poll.pk]))
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = MotionPoll.objects.get()
self.assertFalse(poll.is_pseudoanonymized)
def test_reset_pseudoanonymous(self):
self.poll.type = BasePoll.TYPE_PSEUDOANONYMOUS
self.poll.is_pseudoanonymized = True
self.poll.save()
response = self.client.post(reverse("motionpoll-reset", args=[self.poll.pk]))
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = MotionPoll.objects.get()
self.assertTrue(poll.is_pseudoanonymized)
class TestMotionPollWithVoteDelegationAutoupdate(TestCase): class TestMotionPollWithVoteDelegationAutoupdate(TestCase):
def advancedSetUp(self): def advancedSetUp(self):

View File

@ -1,7 +1,6 @@
from decimal import Decimal
from unittest import TestCase from unittest import TestCase
from openslides.motions.models import Motion, MotionChangeRecommendation, MotionPoll from openslides.motions.models import Motion, MotionChangeRecommendation
# TODO: test for MotionPoll.set_options() # TODO: test for MotionPoll.set_options()
@ -51,25 +50,3 @@ class MotionChangeRecommendationTest(TestCase):
other_recommendations other_recommendations
) )
self.assertFalse(collides) self.assertFalse(collides)
class MotionPollAnalogFieldsTest(TestCase):
def setUp(self):
self.motion = Motion(
title="test_title_OoK9IeChe2Jeib9Deeji",
text="test_text_eichui1oobiSeit9aifo",
)
self.poll = MotionPoll(
motion=self.motion,
title="test_title_tho8PhiePh8upaex6phi",
pollmethod="YNA",
type=MotionPoll.TYPE_NAMED,
)
def test_not_set_vote_values(self):
with self.assertRaises(ValueError):
self.poll.votesvalid = Decimal("1")
with self.assertRaises(ValueError):
self.poll.votesinvalid = Decimal("1")
with self.assertRaises(ValueError):
self.poll.votescast = Decimal("1")