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
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:
docker-compose -f docker/docker-compose.dev.yml down
server-shell:
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
reload-proxy:
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>
</td>
</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>
</table>

View File

@ -46,4 +46,9 @@
.single-result {
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 { OperatorService } from 'app/core/core-services/operator.service';
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 { AssignmentPollService } from 'app/site/assignments/modules/assignment-poll/services/assignment-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;
}
public get isPercentBaseEntitled(): boolean {
return this.poll.onehundred_percent_base === PercentBase.Entitled;
}
public constructor(
titleService: Title,
translateService: TranslateService,

View File

@ -30,6 +30,15 @@
{{ row.value[0].amount | parsePollNumber }}
</td>
</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>
</table>

View File

@ -5,7 +5,7 @@ import { TranslateService } from '@ngx-translate/core';
import { BaseComponent } from 'app/base.component';
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 { MotionPollService } from 'app/site/motions/services/motion-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;
}
public get isPercentBaseEntitled(): boolean {
return this.poll.onehundred_percent_base === PercentBase.Entitled;
}
public constructor(
titleService: Title,
translate: TranslateService,

View File

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

View File

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

View File

@ -28,6 +28,7 @@ export abstract class BaseVote<T = any> extends BaseDecimalModel<T> {
public value: VoteValue;
public option_id: number;
public user_id?: number;
public user_token: string;
public get valueVerbose(): string {
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 { BannerComponent } from './components/banner/banner.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 { ParsePollNumberPipe } from './pipes/parse-poll-number.pipe';
import { ReversePipe } from './pipes/reverse.pipe';
@ -293,6 +294,7 @@ import { ApplauseParticleDisplayComponent } from './components/applause-particle
CheckInputComponent,
BannerComponent,
PollFormComponent,
EntitledUsersTableComponent,
MotionPollDialogComponent,
ParsePollNumberPipe,
ReversePipe,
@ -356,6 +358,7 @@ import { ApplauseParticleDisplayComponent } from './components/applause-particle
CheckInputComponent,
BannerComponent,
PollFormComponent,
EntitledUsersTableComponent,
MotionPollDialogComponent,
ParsePollNumberPipe,
ReversePipe,

View File

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

View File

@ -37,67 +37,74 @@
<os-charts class="assignment-result-chart" [labels]="candidatesLabels" [data]="chartData"></os-charts>
</div>
<!-- Single Votes Table -->
<div class="named-result-table" *ngIf="showResults && poll.stateHasVotes && poll.type === 'named'">
<h3>{{ 'Single votes' | translate }}</h3>
<os-list-view-table
class="single-votes-table"
*ngIf="votesDataObservable"
[listObservable]="votesDataObservable"
[columns]="columnDefinitionSingleVotes"
[filterProps]="filterProps"
[allowProjector]="false"
[fullScreen]="false"
[vScrollFixed]="-1"
listStorageKey="assignment-poll-vote"
[showListOfSpeakers]="false"
[showMenu]="false"
>
<!-- Header -->
<div *pblNgridHeaderCellDef="'user'; col as col">
{{ col.label | translate }}
</div>
<div *pblNgridHeaderCellDef="'votes'; col as col">
{{ col.label | translate }}
</div>
<mat-tab-group *ngIf="showResults && poll.stateHasVotes && poll.isEVoting">
<mat-tab label="{{ 'Single votes' | translate }}">
<!-- Single Votes Table -->
<div class="named-result-table">
<os-list-view-table
class="single-votes-table"
*ngIf="votesDataObservable"
[listObservable]="votesDataObservable"
[columns]="columnDefinitionSingleVotes"
[filterProps]="filterProps"
[allowProjector]="false"
[fullScreen]="false"
[vScrollFixed]="-1"
listStorageKey="assignment-poll-vote"
[showListOfSpeakers]="false"
[showMenu]="false"
>
<!-- Header -->
<div *pblNgridHeaderCellDef="'user'; col as col">
{{ col.label | translate }}
</div>
<div *pblNgridHeaderCellDef="'votes'; col as col">
{{ col.label | translate }}
</div>
<!-- Content -->
<div *pblNgridCellDef="'user'; row as vote">
<div *ngIf="vote.user">
{{ vote.user.getShortName() }}
<div class="user-subtitle">
<!-- Level and number -->
<div *ngIf="vote.user.getLevelAndNumber()">
{{ vote.user.getLevelAndNumber() }}
<!-- Content -->
<div *pblNgridCellDef="'user'; row as vote">
<div *ngIf="vote.user">
{{ vote.user.getShortName() }}
<div class="user-subtitle">
<!-- Level and number -->
<div *ngIf="vote.user.getLevelAndNumber()">
{{ vote.user.getLevelAndNumber() }}
</div>
<!-- Vote weight -->
<div *ngIf="isVoteWeightActive">
{{ 'Vote weight' | translate }}: {{ vote.user.vote_weight }}
</div>
<!-- Delegation -->
<div *ngIf="userHasVoteDelegation(vote.user)">
<span>
({{ 'represented by' | translate }}
{{ getUsersVoteDelegation(vote.user).getShortName().trim() }})
</span>
</div>
</div>
</div>
<!-- Vote weight -->
<div *ngIf="isVoteWeightActive">
{{ 'Vote weight' | translate }}: {{ vote.user.vote_weight }}
</div>
<!-- Delegation -->
<div *ngIf="userHasVoteDelegation(vote.user)">
<span>
({{ 'represented by' | translate }}
{{ getUsersVoteDelegation(vote.user).getShortName().trim() }})
</span>
<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 *ngIf="!vote.user">
{{ 'Anonymous' | translate }}
</div>
</div>
<div *pblNgridCellDef="'votes'; row as vote">
<div class="single-vote-result" *ngFor="let candidate of vote.votes">{{ candidate }}</div>
<div *pblNgridCellDef="'votes'; row as vote">
<div class="single-vote-result" *ngFor="let candidate of vote.votes">{{ candidate }}</div>
</div>
</os-list-view-table>
</div>
</os-list-view-table>
<div *ngIf="!votesDataObservable">
{{ 'The individual votes were anonymized.' | translate }}
</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>

View File

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

View File

@ -11,7 +11,7 @@ import {
AssignmentPollMethod,
AssignmentPollPercentBase
} 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 { PollKeyVerbosePipe } from 'app/shared/pipes/poll-key-verbose.pipe';
import { ViewAssignmentOption } from 'app/site/assignments/models/view-assignment-option';
@ -227,6 +227,9 @@ export class AssignmentPollService extends PollService {
case AssignmentPollPercentBase.Valid:
totalByBase = poll.votesvalid;
break;
case AssignmentPollPercentBase.Entitled:
totalByBase = poll.entitled_users_at_stop.length;
break;
case AssignmentPollPercentBase.Cast:
totalByBase = poll.votescast;
break;

View File

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

View File

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

View File

@ -32,62 +32,70 @@
<os-motion-poll-detail-content [poll]="poll"></os-motion-poll-detail-content>
<!-- Named table: only show if votes are present -->
<div class="named-result-table" *ngIf="showResults && poll.stateHasVotes && poll.type === 'named'">
<h2>{{ 'Single votes' | translate }}</h2>
<os-list-view-table
*ngIf="votesDataObservable"
class="single-votes-table"
[listObservable]="votesDataObservable"
[columns]="columnDefinition"
[filterProps]="filterProps"
[allowProjector]="false"
[fullScreen]="true"
[vScrollFixed]="-1"
listStorageKey="motion-poll-vote"
>
<!-- Header -->
<div *pblNgridHeaderCellDef="'*'; col as col">
{{ col.label | translate }}
</div>
<mat-tab-group *ngIf="showResults && poll.stateHasVotes && poll.isEVoting">
<mat-tab label="{{ 'Single votes' | translate }}">
<div class="named-result-table">
<os-list-view-table
class="single-votes-table"
[listObservable]="votesDataObservable"
[columns]="columnDefinitionSingleVotesTable"
[filterProps]="filterPropsSingleVotesTable"
[allowProjector]="false"
[fullScreen]="true"
[vScrollFixed]="-1"
listStorageKey="motion-poll-vote"
>
<!-- Header -->
<div *pblNgridHeaderCellDef="'*'; col as col">
{{ col.label | translate }}
</div>
<!-- Content -->
<div *pblNgridCellDef="'user'; row as vote">
<div *ngIf="vote.user">
{{ vote.user.getShortName() }}
<!-- Content -->
<div *pblNgridCellDef="'user'; row as vote">
<div *ngIf="vote.user">
{{ vote.user.getShortName() }}
<div class="user-subtitle">
<!-- Level and number -->
<div *ngIf="vote.user.getLevelAndNumber()">
{{ vote.user.getLevelAndNumber() }}
<div class="user-subtitle">
<!-- Level and number -->
<div *ngIf="vote.user.getLevelAndNumber()">
{{ vote.user.getLevelAndNumber() }}
</div>
<!-- Vote weight -->
<div *ngIf="isVoteWeightActive">
{{ 'Vote weight' | translate }}: {{ vote.user.vote_weight }}
</div>
<!-- Delegation -->
<div *ngIf="userHasVoteDelegation(vote.user)">
<span>
({{ 'represented by' | translate }}
{{ getUsersVoteDelegation(vote.user).getShortName().trim() }})
</span>
</div>
</div>
</div>
<!-- Vote weight -->
<div *ngIf="isVoteWeightActive">
{{ 'Vote weight' | translate }}: {{ vote.user.vote_weight }}
</div>
<!-- Delegation -->
<div *ngIf="userHasVoteDelegation(vote.user)">
<span>
({{ 'represented by' | translate }}
{{ getUsersVoteDelegation(vote.user).getShortName().trim() }})
</span>
<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 *ngIf="!vote.user">{{ 'Anonymous' | translate }}</div>
<div *pblNgridCellDef="'vote'; row as vote" class="vote-cell">
<div class="vote-cell-icon-container" [ngClass]="voteOptionStyle[vote.value].css">
<mat-icon>{{ voteOptionStyle[vote.value].icon }}</mat-icon>
</div>
<div>{{ vote.valueVerbose | translate }}</div>
</div>
</os-list-view-table>
</div>
<div *pblNgridCellDef="'vote'; row as vote" class="vote-cell">
<div class="vote-cell-icon-container" [ngClass]="voteOptionStyle[vote.value].css">
<mat-icon>{{ voteOptionStyle[vote.value].icon }}</mat-icon>
</div>
<div>{{ vote.valueVerbose | translate }}</div>
</div>
</os-list-view-table>
<div *ngIf="!votesDataObservable">
{{ 'The individual votes were anonymized.' | translate }}
</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">
<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 { MotionVoteRepositoryService } from 'app/core/repositories/motions/motion-vote-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 { PromptService } from 'app/core/ui-services/prompt.service';
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> {
public motion: ViewMotion;
public columnDefinition: PblColumnDefinition[] = [
public columnDefinitionSingleVotesTable: PblColumnDefinition[] = [
{
prop: 'user',
width: 'auto',
@ -40,7 +41,7 @@ export class MotionPollDetailComponent extends BasePollDetailComponentDirective<
}
];
public filterProps = ['user.getFullName', 'valueVerbose'];
public filterPropsSingleVotesTable = ['user.getFullName', 'valueVerbose'];
public isVoteWeightActive: boolean;
@ -62,7 +63,8 @@ export class MotionPollDetailComponent extends BasePollDetailComponentDirective<
configService: ConfigService,
protected operator: OperatorService,
private router: Router,
protected cd: ChangeDetectorRef
protected cd: ChangeDetectorRef,
protected userRepo: UserRepositoryService
) {
super(
title,
@ -76,7 +78,8 @@ export class MotionPollDetailComponent extends BasePollDetailComponentDirective<
pollService,
votesRepo,
operator,
cd
cd,
userRepo
);
configService
.get<boolean>('users_activate_vote_weight')

View File

@ -137,6 +137,9 @@ export class MotionPollService extends PollService {
case PercentBase.Cast:
totalByBase = poll.votescast;
break;
case PercentBase.Entitled:
totalByBase = poll.entitled_users_at_stop.length;
break;
case PercentBase.Disabled:
break;
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 { 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 } from 'rxjs';
import { BehaviorSubject, from, Observable, Subscription } from 'rxjs';
import { filter, map } from 'rxjs/operators';
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';
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 { ViewBasePoll } from '../models/view-base-poll';
import { ViewBaseVote } from '../models/view-base-vote';
@ -31,7 +35,7 @@ export interface BaseVoteData {
@Directive()
export abstract class BasePollDetailComponentDirective<V extends ViewBasePoll, S extends PollService>
extends BaseViewComponentDirective
implements OnInit {
implements OnInit, OnDestroy {
/**
* 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
public votesDataObservable: Observable<BaseVoteData[]>;
// The observable for the entitled-users-table
public entitledUsersObservable: Observable<EntitledUsersTableEntry[]>;
protected optionsLoaded = new Deferred();
private entitledUsersSubscription: Subscription;
/**
* Constructor
*
@ -102,7 +111,8 @@ export abstract class BasePollDetailComponentDirective<V extends ViewBasePoll, S
protected pollService: S,
protected votesRepo: BaseRepository<ViewBaseVote, BaseVote, object>,
protected operator: OperatorService,
protected cd: ChangeDetectorRef
protected cd: ChangeDetectorRef,
protected userRepo: UserRepositoryService
) {
super(title, translate, matSnackbar);
this.setup();
@ -168,14 +178,10 @@ 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 {
if (data.every(voteDate => !voteDate.user)) {
this.votesDataObservable = null;
} else {
this.votesDataObservable = from([data]);
}
this.votesDataObservable = from([data]);
}
/**
@ -196,12 +202,46 @@ export abstract class BasePollDetailComponentDirective<V extends ViewBasePoll, S
this.createVotesData();
this.optionsLoaded.resolve();
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 {
/**
* 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;
}
}
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',
valid: 'Valid votes',
cast: 'Total votes cast',
entitled: 'All entitled users',
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 {
BasePoll,
EntitledUsersEntry,
MajorityMethod,
PercentBase,
PollColor,
@ -111,6 +112,7 @@ export interface PollData {
votesvalid: number;
votesinvalid: number;
votescast: number;
entitled_users_at_stop: EntitledUsersEntry[];
amount_global_yes?: number;
amount_global_no?: number;
amount_global_abstain?: number;
@ -286,7 +288,11 @@ export abstract class PollService {
}
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[] {

View File

@ -3,6 +3,7 @@ import { Component, Input } from '@angular/core';
import { BaseSlideComponentDirective } from 'app/slides/base-slide-component';
import { CommonListOfSpeakersSlideData, SlideSpeaker } from '../common/common-list-of-speakers-slide-data';
// prettier-ignore
@Component({
selector: 'os-current-list-of-speakers-overlay-slide',
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 { 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 { BasePollSlideData } from 'app/slides/polls/base-poll-slide-data';
@ -25,6 +25,8 @@ export interface AssignmentPollSlideData extends BasePollSlideData {
abstain?: number;
}[];
entitled_users_at_stop: EntitledUsersEntry[];
// optional for published polls:
amount_global_yes?: number;
amount_global_no?: number;

View File

@ -1,5 +1,5 @@
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 { BasePollSlideData } from 'app/slides/polls/base-poll-slide-data';
@ -19,6 +19,8 @@ export interface MotionPollSlideData extends BasePollSlideData {
abstain?: number;
}[];
entitled_users_at_stop: EntitledUsersEntry[];
// optional for published polls:
votesvalid: 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 {
poll: {
@ -15,6 +15,8 @@ export interface BasePollSlideData {
abstain?: number;
}[];
entitled_users_at_stop: EntitledUsersEntry[];
votesvalid: number;
votesinvalid: number;
votescast: number;

View File

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

View File

@ -19,8 +19,9 @@ RUN rm -rf /var/lib/apt/lists/*
COPY requirements /app/requirements
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
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.models import Tag
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.exceptions import OpenSlidesError
from openslides.utils.manager import BaseManager
@ -212,7 +212,7 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model
return {"title": self.title}
class AssignmentVoteManager(BaseManager):
class AssignmentVoteManager(BaseVoteManager):
"""
Customized model manager to support our get_prefetched_queryset method.
"""
@ -325,6 +325,7 @@ class AssignmentPoll(RESTModelMixin, BasePoll):
PERCENT_BASE_YNA = "YNA"
PERCENT_BASE_VALID = "valid"
PERCENT_BASE_CAST = "cast"
PERCENT_BASE_ENTITLED = "entitled"
PERCENT_BASE_DISABLED = "disabled"
PERCENT_BASES = (
(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_VALID, "All valid ballots"),
(PERCENT_BASE_CAST, "All casted ballots"),
(PERCENT_BASE_ENTITLED, "All entitled users"),
(PERCENT_BASE_DISABLED, "Disabled (no percents)"),
)
onehundred_percent_base = models.CharField(

View File

@ -522,6 +522,7 @@ class AssignmentPollViewSet(BasePollViewSet):
"""
options = poll.get_options()
if isinstance(data, dict):
user_token = AssignmentVote.objects.generate_user_token()
for option_id, amount in data.items():
# Add user to the option's voted array
option = options.get(pk=option_id)
@ -540,6 +541,7 @@ class AssignmentPollViewSet(BasePollViewSet):
delegated_user=request_user,
weight=weight,
value=value,
user_token=user_token,
)
inform_changed_data(vote)
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
"""
options = poll.get_options()
user_token = AssignmentVote.objects.generate_user_token()
weight = vote_weight if config["users_activate_vote_weight"] else Decimal(1)
for option_id, result in data.items():
option = options.get(pk=option_id)
@ -575,6 +578,7 @@ class AssignmentPollViewSet(BasePollViewSet):
delegated_user=request_user,
value=result,
weight=weight,
user_token=user_token,
)
inform_changed_data(vote)
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.models import Tag
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.exceptions import OpenSlidesError
from openslides.utils.manager import BaseManager
@ -828,7 +828,7 @@ class MotionBlock(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Mode
return {"title": self.title}
class MotionVoteManager(BaseManager):
class MotionVoteManager(BaseVoteManager):
"""
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.core.validators import MinValueValidator
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 ..utils.autoupdate import inform_changed_data, inform_deleted_data
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):
"""
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,
related_name="%(class)s_delegated_votes",
)
user_token = models.CharField(default=generate_user_token, max_length=16)
class Meta:
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):
"""
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)
voted = models.ManyToManyField(settings.AUTH_USER_MODEL, blank=True)
db_votesvalid = models.DecimalField(
votesvalid = models.DecimalField(
null=True,
blank=True,
validators=[MinValueValidator(Decimal("-2"))],
max_digits=15,
decimal_places=6,
)
db_votesinvalid = models.DecimalField(
votesinvalid = models.DecimalField(
null=True,
blank=True,
validators=[MinValueValidator(Decimal("-2"))],
max_digits=15,
decimal_places=6,
)
db_votescast = models.DecimalField(
votescast = models.DecimalField(
null=True,
blank=True,
validators=[MinValueValidator(Decimal("-2"))],
@ -160,12 +179,14 @@ class BasePoll(models.Model):
PERCENT_BASE_YNA = "YNA"
PERCENT_BASE_VALID = "valid"
PERCENT_BASE_CAST = "cast"
PERCENT_BASE_ENTITLED = "entitled"
PERCENT_BASE_DISABLED = "disabled"
PERCENT_BASES: Iterable[Tuple[str, str]] = (
(PERCENT_BASE_YN, "Yes/No"),
(PERCENT_BASE_YNA, "Yes/No/Abstain"),
(PERCENT_BASE_VALID, "All valid ballots"),
(PERCENT_BASE_CAST, "All casted ballots"),
(PERCENT_BASE_ENTITLED, "All entitled users"),
(PERCENT_BASE_DISABLED, "Disabled (no percents)"),
) # type: ignore
onehundred_percent_base = models.CharField(
@ -186,57 +207,13 @@ class BasePoll(models.Model):
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:
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):
""" Should be called after creation of this model. """
raise NotImplementedError()
@ -268,6 +245,8 @@ class BasePoll(models.Model):
def pseudoanonymize(self):
for option in self.get_options():
option.pseudoanonymize()
self.is_pseudoanonymized = True
self.save()
def reset(self):
for option in self.get_options():
@ -281,4 +260,38 @@ class BasePoll(models.Model):
self.votesvalid = None
self.votesinvalid = 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()

View File

@ -5,6 +5,7 @@ from ..utils.rest_api import (
CharField,
DecimalField,
IdPrimaryKeyRelatedField,
JSONField,
ModelSerializer,
SerializerMethodField,
ValidationError,
@ -18,6 +19,7 @@ BASE_VOTE_FIELDS = (
"value",
"user",
"delegated_user",
"user_token",
"option",
"pollstate",
)
@ -58,7 +60,9 @@ BASE_POLL_FIELDS = (
"id",
"onehundred_percent_base",
"majority_method",
"is_pseudoanonymized",
"voted",
"entitled_users_at_stop",
)
@ -69,27 +73,21 @@ class BasePollSerializer(ModelSerializer):
)
options = IdPrimaryKeyRelatedField(many=True, read_only=True)
voted = IdPrimaryKeyRelatedField(many=True, read_only=True)
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
)
entitled_users_at_stop = JSONField(required=False)
def create(self, validated_data):
"""
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(
validated_data["onehundred_percent_base"], validated_data["pollmethod"]
)
if new_100_percent_base is not None:
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)
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
possible (see implementing serializers to see forbidden combinations)
Also updates is_pseudoanonymized, if needed.
"""
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)
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:
raise ValidationError({"detail": "Wrong poll state"})
poll.state = BasePoll.STATE_FINISHED
poll.save()
poll.stop()
inform_changed_data(poll.get_votes())
inform_changed_data(poll.get_options())
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.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from openslides.assignments.models import (
Assignment,
@ -108,7 +109,7 @@ class CreateAssignmentPoll(TestCase):
self.assignment.add_candidate(self.admin)
def test_simple(self):
with self.assertNumQueries(40):
with self.assertNumQueries(38):
response = self.client.post(
reverse("assignmentpoll-list"),
{
@ -886,6 +887,7 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass):
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
self.assertEqual(AssignmentVote.objects.count(), 6)
poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, Decimal("4.64"))
self.assertEqual(poll.votesinvalid, Decimal("-2"))
self.assertEqual(poll.votescast, Decimal("-2"))
@ -1056,6 +1058,7 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass):
)
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, Decimal("4.64"))
self.assertEqual(poll.votesinvalid, Decimal("-2"))
self.assertEqual(poll.votescast, Decimal("3"))
@ -1081,6 +1084,7 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass):
)
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED)
self.assertEqual(poll.votesvalid, Decimal("0"))
self.assertEqual(poll.votesinvalid, Decimal("0"))
@ -1099,11 +1103,11 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass):
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
self.assertEqual(AssignmentVote.objects.count(), 3)
poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, Decimal("1"))
self.assertEqual(poll.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("1"))
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())
option1 = poll.options.get(pk=1)
option2 = poll.options.get(pk=2)
@ -1132,11 +1136,11 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass):
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
self.assertEqual(AssignmentVote.objects.count(), 3)
poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, weight)
self.assertEqual(poll.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("1"))
self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED)
self.assertEqual(poll.amount_users_voted_with_individual_weight(), weight)
option1 = poll.options.get(pk=1)
option2 = poll.options.get(pk=2)
option3 = poll.options.get(pk=3)
@ -1321,6 +1325,51 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass):
self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST)
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):
def create_poll(self):
@ -1343,6 +1392,7 @@ class VoteAssignmentPollNamedY(VoteAssignmentPollBaseTestClass):
)
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED)
self.assertEqual(poll.votesvalid, Decimal("0"))
self.assertEqual(poll.votesinvalid, Decimal("0"))
@ -1360,6 +1410,7 @@ class VoteAssignmentPollNamedY(VoteAssignmentPollBaseTestClass):
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
self.assertEqual(AssignmentVote.objects.count(), 1)
poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, Decimal("1"))
self.assertEqual(poll.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("1"))
@ -1671,6 +1722,7 @@ class VoteAssignmentPollNamedN(VoteAssignmentPollBaseTestClass):
)
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED)
self.assertEqual(poll.votesvalid, Decimal("0"))
self.assertEqual(poll.votesinvalid, Decimal("0"))
@ -1688,6 +1740,7 @@ class VoteAssignmentPollNamedN(VoteAssignmentPollBaseTestClass):
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
self.assertEqual(AssignmentVote.objects.count(), 1)
poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, Decimal("1"))
self.assertEqual(poll.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("1"))
@ -1986,6 +2039,7 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass):
)
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED)
self.assertEqual(poll.votesvalid, Decimal("0"))
self.assertEqual(poll.votesinvalid, Decimal("0"))
@ -2004,6 +2058,7 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass):
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
self.assertEqual(AssignmentVote.objects.count(), 3)
poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, Decimal("1"))
self.assertEqual(poll.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("1"))
@ -2163,6 +2218,22 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass):
self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST)
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):
def create_poll(self):
@ -2185,6 +2256,7 @@ class VoteAssignmentPollPseudoanonymousY(VoteAssignmentPollBaseTestClass):
)
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED)
self.assertEqual(poll.votesvalid, Decimal("0"))
self.assertEqual(poll.votesinvalid, Decimal("0"))
@ -2202,6 +2274,7 @@ class VoteAssignmentPollPseudoanonymousY(VoteAssignmentPollBaseTestClass):
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
self.assertEqual(AssignmentVote.objects.count(), 1)
poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, Decimal("1"))
self.assertEqual(poll.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("1"))
@ -2433,6 +2506,7 @@ class VoteAssignmentPollPseudoanonymousN(VoteAssignmentPollBaseTestClass):
)
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.state, AssignmentPoll.STATE_STARTED)
self.assertEqual(poll.votesvalid, Decimal("0"))
self.assertEqual(poll.votesinvalid, Decimal("0"))
@ -2450,6 +2524,7 @@ class VoteAssignmentPollPseudoanonymousN(VoteAssignmentPollBaseTestClass):
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
self.assertEqual(AssignmentVote.objects.count(), 1)
poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, Decimal("1"))
self.assertEqual(poll.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("1"))
@ -2681,8 +2756,9 @@ class PseudoanonymizeAssignmentPoll(TestCase):
)
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = AssignmentPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.is_pseudoanonymized, True)
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.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("2"))

View File

@ -112,6 +112,7 @@ class CreateMotionPoll(TestCase):
self.assertHttpStatusVerbose(response, status.HTTP_201_CREATED)
self.assertTrue(MotionPoll.objects.exists())
poll = MotionPoll.objects.get()
self.assertEqual(poll.is_pseudoanonymized, False)
self.assertEqual(poll.title, "test_title_ailai4toogh3eefaa2Vo")
self.assertEqual(poll.pollmethod, "YNA")
self.assertEqual(poll.type, "named")
@ -394,6 +395,27 @@ class UpdateMotionPoll(TestCase):
poll = MotionPoll.objects.get()
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):
response = self.client.patch(
reverse("motionpoll-detail", args=[self.poll.pk]), {"type": "invalid"}
@ -585,6 +607,7 @@ class VoteMotionPollAnalog(TestCase):
)
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = MotionPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, Decimal("4.64"))
self.assertEqual(poll.votesinvalid, Decimal("-2"))
self.assertEqual(poll.votescast, Decimal("-2"))
@ -668,6 +691,7 @@ class VoteMotionPollAnalog(TestCase):
)
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = MotionPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, Decimal("4.64"))
self.assertEqual(poll.votesinvalid, Decimal("-2"))
self.assertEqual(poll.votescast, Decimal("3"))
@ -715,6 +739,7 @@ class VoteMotionPollNamed(TestCase):
response = self.client.post(reverse("motionpoll-start", args=[self.poll.pk]))
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = MotionPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.state, MotionPoll.STATE_STARTED)
self.assertEqual(poll.votesvalid, Decimal("0"))
self.assertEqual(poll.votesinvalid, Decimal("0"))
@ -730,6 +755,7 @@ class VoteMotionPollNamed(TestCase):
)
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = MotionPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, Decimal("1"))
self.assertEqual(poll.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("1"))
@ -754,11 +780,11 @@ class VoteMotionPollNamed(TestCase):
)
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = MotionPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, weight)
self.assertEqual(poll.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("1"))
self.assertEqual(poll.get_votes().count(), 1)
self.assertEqual(poll.amount_users_voted_with_individual_weight(), weight)
option = poll.options.get()
self.assertEqual(option.yes, Decimal("0"))
self.assertEqual(option.no, Decimal("0"))
@ -784,6 +810,7 @@ class VoteMotionPollNamed(TestCase):
)
self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST)
poll = MotionPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, Decimal("1"))
self.assertEqual(poll.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("1"))
@ -874,6 +901,7 @@ class VoteMotionPollNamed(TestCase):
)
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = MotionPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, Decimal("1"))
self.assertEqual(poll.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("1"))
@ -893,6 +921,7 @@ class VoteMotionPollNamed(TestCase):
)
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = MotionPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, Decimal("2"))
self.assertEqual(poll.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("2"))
@ -1007,6 +1036,7 @@ class VoteMotionPollPseudoanonymous(TestCase):
response = self.client.post(reverse("motionpoll-start", args=[self.poll.pk]))
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = MotionPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.state, MotionPoll.STATE_STARTED)
self.assertEqual(poll.votesvalid, Decimal("0"))
self.assertEqual(poll.votesinvalid, Decimal("0"))
@ -1022,11 +1052,11 @@ class VoteMotionPollPseudoanonymous(TestCase):
)
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = MotionPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.votesvalid, Decimal("1"))
self.assertEqual(poll.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("1"))
self.assertEqual(poll.get_votes().count(), 1)
self.assertEqual(poll.amount_users_voted_with_individual_weight(), 1)
option = poll.options.get()
self.assertEqual(option.yes, Decimal("0"))
self.assertEqual(option.no, Decimal("1"))
@ -1142,6 +1172,42 @@ class StopMotionPoll(TestCase):
self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST)
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):
def advancedSetUp(self):
@ -1213,8 +1279,9 @@ class PseudoanonymizeMotionPoll(TestCase):
)
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = MotionPoll.objects.get()
poll.calculate_votes()
self.assertEqual(poll.is_pseudoanonymized, True)
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.votesinvalid, Decimal("0"))
self.assertEqual(poll.votescast, Decimal("2"))
@ -1282,7 +1349,6 @@ class ResetMotionPoll(TestCase):
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
poll = MotionPoll.objects.get()
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.votesinvalid, None)
self.assertEqual(poll.votescast, None)
@ -1292,6 +1358,24 @@ class ResetMotionPoll(TestCase):
self.assertEqual(option.abstain, Decimal("0"))
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):
def advancedSetUp(self):

View File

@ -1,7 +1,6 @@
from decimal import Decimal
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()
@ -51,25 +50,3 @@ class MotionChangeRecommendationTest(TestCase):
other_recommendations
)
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")