diff --git a/Makefile b/Makefile index ff17c9fca..85321d5f5 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/autoupdate b/autoupdate index 0a2eb0fce..c1211219d 160000 --- a/autoupdate +++ b/autoupdate @@ -1 +1 @@ -Subproject commit 0a2eb0fce664bdb76eb47beadf0f7c383e40e709 +Subproject commit c1211219d81965b10780ecfa5a1de31f9e30d31e diff --git a/client/src/app/shared/components/assignment-poll-detail-content/assignment-poll-detail-content.component.html b/client/src/app/shared/components/assignment-poll-detail-content/assignment-poll-detail-content.component.html index 54cb8e68f..bc9a20b0f 100644 --- a/client/src/app/shared/components/assignment-poll-detail-content/assignment-poll-detail-content.component.html +++ b/client/src/app/shared/components/assignment-poll-detail-content/assignment-poll-detail-content.component.html @@ -49,6 +49,19 @@ + + {{ 'Entitled users' | translate }} + +
+ + {{ poll.entitled_users_at_stop.length | pollPercentBase: poll:'assignment' }} + + + {{ poll.entitled_users_at_stop.length }} + +
+ + diff --git a/client/src/app/shared/components/assignment-poll-detail-content/assignment-poll-detail-content.component.scss b/client/src/app/shared/components/assignment-poll-detail-content/assignment-poll-detail-content.component.scss index 62680fbc6..901f19c5e 100644 --- a/client/src/app/shared/components/assignment-poll-detail-content/assignment-poll-detail-content.component.scss +++ b/client/src/app/shared/components/assignment-poll-detail-content/assignment-poll-detail-content.component.scss @@ -46,4 +46,9 @@ .single-result { white-space: pre; } + + .entitled-users-row { + border-bottom: none; + height: 0; + } } diff --git a/client/src/app/shared/components/assignment-poll-detail-content/assignment-poll-detail-content.component.ts b/client/src/app/shared/components/assignment-poll-detail-content/assignment-poll-detail-content.component.ts index 7bbaed96a..accd6c3f7 100644 --- a/client/src/app/shared/components/assignment-poll-detail-content/assignment-poll-detail-content.component.ts +++ b/client/src/app/shared/components/assignment-poll-detail-content/assignment-poll-detail-content.component.ts @@ -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, diff --git a/client/src/app/shared/components/motion-poll-detail-content/motion-poll-detail-content.component.html b/client/src/app/shared/components/motion-poll-detail-content/motion-poll-detail-content.component.html index 833cf0698..8922e3cee 100644 --- a/client/src/app/shared/components/motion-poll-detail-content/motion-poll-detail-content.component.html +++ b/client/src/app/shared/components/motion-poll-detail-content/motion-poll-detail-content.component.html @@ -30,6 +30,15 @@ {{ row.value[0].amount | parsePollNumber }} + + {{ 'Entitled users' | translate }} + + {{ poll.entitled_users_at_stop.length | pollPercentBase: poll:'motion' }} + + + {{ poll.entitled_users_at_stop.length }} + + diff --git a/client/src/app/shared/components/motion-poll-detail-content/motion-poll-detail-content.component.ts b/client/src/app/shared/components/motion-poll-detail-content/motion-poll-detail-content.component.ts index 7d0c7fce0..686e58c9e 100644 --- a/client/src/app/shared/components/motion-poll-detail-content/motion-poll-detail-content.component.ts +++ b/client/src/app/shared/components/motion-poll-detail-content/motion-poll-detail-content.component.ts @@ -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, diff --git a/client/src/app/shared/models/assignments/assignment-poll.ts b/client/src/app/shared/models/assignments/assignment-poll.ts index 0a99b254b..4a4f4626e 100644 --- a/client/src/app/shared/models/assignments/assignment-poll.ts +++ b/client/src/app/shared/models/assignments/assignment-poll.ts @@ -15,6 +15,7 @@ export enum AssignmentPollPercentBase { YNA = 'YNA', Valid = 'valid', Cast = 'cast', + Entitled = 'entitled', Disabled = 'disabled' } diff --git a/client/src/app/shared/models/poll/base-poll.ts b/client/src/app/shared/models/poll/base-poll.ts index b5737c374..6aa923f65 100644 --- a/client/src/app/shared/models/poll/base-poll.ts +++ b/client/src/app/shared/models/poll/base-poll.ts @@ -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; diff --git a/client/src/app/shared/models/poll/base-vote.ts b/client/src/app/shared/models/poll/base-vote.ts index 108576a55..09f5edf60 100644 --- a/client/src/app/shared/models/poll/base-vote.ts +++ b/client/src/app/shared/models/poll/base-vote.ts @@ -28,6 +28,7 @@ export abstract class BaseVote extends BaseDecimalModel { public value: VoteValue; public option_id: number; public user_id?: number; + public user_token: string; public get valueVerbose(): string { return VoteValueVerbose[this.value]; diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index d4b1248dc..6a754384f 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -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, diff --git a/client/src/app/site/assignments/models/view-assignment-poll.ts b/client/src/app/site/assignments/models/view-assignment-poll.ts index 227b7b34c..0bec3504b 100644 --- a/client/src/app/site/assignments/models/view-assignment-poll.ts +++ b/client/src/app/site/assignments/models/view-assignment-poll.ts @@ -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)') }; diff --git a/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-detail/assignment-poll-detail.component.html b/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-detail/assignment-poll-detail.component.html index 7cd9e994f..7211e4ab7 100644 --- a/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-detail/assignment-poll-detail.component.html +++ b/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-detail/assignment-poll-detail.component.html @@ -37,67 +37,74 @@ - -
-

{{ 'Single votes' | translate }}

- - -
- {{ col.label | translate }} -
-
- {{ col.label | translate }} -
+ + + +
+ + +
+ {{ col.label | translate }} +
+
+ {{ col.label | translate }} +
- -
-
- {{ vote.user.getShortName() }} -
- -
- {{ vote.user.getLevelAndNumber() }} + +
+
+ {{ vote.user.getShortName() }} +
+ +
+ {{ vote.user.getLevelAndNumber() }} +
+ + +
+ {{ 'Vote weight' | translate }}: {{ vote.user.vote_weight }} +
+ + +
+ + ({{ 'represented by' | translate }} + {{ getUsersVoteDelegation(vote.user).getShortName().trim() }}) + +
+
- - -
- {{ 'Vote weight' | translate }}: {{ vote.user.vote_weight }} -
- - -
- - ({{ 'represented by' | translate }} - {{ getUsersVoteDelegation(vote.user).getShortName().trim() }}) - +
+ {{ "Anonymous" | translate }} + {{ "Deleted user" | translate }}
-
-
- {{ 'Anonymous' | translate }} -
-
-
-
{{ candidate }}
+
+
{{ candidate }}
+
+
- -
- {{ 'The individual votes were anonymized.' | translate }} -
-
+ + + + +
diff --git a/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-detail/assignment-poll-detail.component.ts b/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-detail/assignment-poll-detail.component.ts index 5ee7ea583..8dfd2bf55 100644 --- a/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-detail/assignment-poll-detail.component.ts +++ b/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-detail/assignment-poll-detail.component.ts @@ -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('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; diff --git a/client/src/app/site/assignments/modules/assignment-poll/services/assignment-poll.service.ts b/client/src/app/site/assignments/modules/assignment-poll/services/assignment-poll.service.ts index 2b87fda7a..35c29731a 100644 --- a/client/src/app/site/assignments/modules/assignment-poll/services/assignment-poll.service.ts +++ b/client/src/app/site/assignments/modules/assignment-poll/services/assignment-poll.service.ts @@ -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; diff --git a/client/src/app/site/motions/models/view-motion-poll.ts b/client/src/app/site/motions/models/view-motion-poll.ts index 136837c23..0d9a767ce 100644 --- a/client/src/app/site/motions/models/view-motion-poll.ts +++ b/client/src/app/site/motions/models/view-motion-poll.ts @@ -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)' }; diff --git a/client/src/app/site/motions/modules/motion-detail/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.ts b/client/src/app/site/motions/modules/motion-detail/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.ts index ac4eb5b7d..3f1fb7964 100644 --- a/client/src/app/site/motions/modules/motion-detail/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.ts +++ b/client/src/app/site/motions/modules/motion-detail/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.ts @@ -59,7 +59,7 @@ export class MotionDetailOriginalChangeRecommendationsComponent implements OnIni @Output() public gotoChangeRecommendation: EventEmitter = new EventEmitter< ViewMotionChangeRecommendation - >(); + >(); // prettier-ignore @Input() public html: string; diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.html b/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.html index f4c201569..8034410f0 100644 --- a/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.html +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.html @@ -32,62 +32,70 @@ -
-

{{ 'Single votes' | translate }}

- - -
- {{ col.label | translate }} -
+ + +
+ + +
+ {{ col.label | translate }} +
- -
-
- {{ vote.user.getShortName() }} + +
+
+ {{ vote.user.getShortName() }} -
- -
- {{ vote.user.getLevelAndNumber() }} +
+ +
+ {{ vote.user.getLevelAndNumber() }} +
+ + +
+ {{ 'Vote weight' | translate }}: {{ vote.user.vote_weight }} +
+ + +
+ + ({{ 'represented by' | translate }} + {{ getUsersVoteDelegation(vote.user).getShortName().trim() }}) + +
+
- - -
- {{ 'Vote weight' | translate }}: {{ vote.user.vote_weight }} -
- - -
- - ({{ 'represented by' | translate }} - {{ getUsersVoteDelegation(vote.user).getShortName().trim() }}) - +
+ {{ "Anonymous" | translate }} + {{ "Deleted user" | translate }}
-
-
{{ 'Anonymous' | translate }}
+
+
+ {{ voteOptionStyle[vote.value].icon }} +
+
{{ vote.valueVerbose | translate }}
+
+
-
-
- {{ voteOptionStyle[vote.value].icon }} -
-
{{ vote.valueVerbose | translate }}
-
- -
- {{ 'The individual votes were anonymized.' | translate }} -
-
+ + + + +
diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.ts b/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.ts index 14d9dde30..2c2393722 100644 --- a/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.ts +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.ts @@ -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 { 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('users_activate_vote_weight') diff --git a/client/src/app/site/motions/services/motion-poll.service.ts b/client/src/app/site/motions/services/motion-poll.service.ts index 276f7ef5f..ecaa027c7 100644 --- a/client/src/app/site/motions/services/motion-poll.service.ts +++ b/client/src/app/site/motions/services/motion-poll.service.ts @@ -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: diff --git a/client/src/app/site/polls/components/base-poll-detail.component.ts b/client/src/app/site/polls/components/base-poll-detail.component.ts index 5393b4e87..61843a659 100644 --- a/client/src/app/site/polls/components/base-poll-detail.component.ts +++ b/client/src/app/site/polls/components/base-poll-detail.component.ts @@ -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 extends BaseViewComponentDirective - implements OnInit { + implements OnInit, OnDestroy { /** * All the groups of users. */ @@ -73,8 +77,13 @@ export abstract class BasePollDetailComponentDirective; + // The observable for the entitled-users-table + public entitledUsersObservable: Observable; + protected optionsLoaded = new Deferred(); + private entitledUsersSubscription: Subscription; + /** * Constructor * @@ -102,7 +111,8 @@ export abstract class BasePollDetailComponentDirective, 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 !voteDate.user)) { - this.votesDataObservable = null; - } else { - this.votesDataObservable = from([data]); - } + this.votesDataObservable = from([data]); } /** @@ -196,12 +202,46 @@ export abstract class BasePollDetailComponentDirective(); + 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 + + +
+ {{ col.label | translate }} +
+ + +
+ {{ entry.user.getFullName() }} + {{ 'Deleted user' | translate }} +
+
+ check_box +
+
+
+ represented by  + {{ entry.vote_delegated_to.getFullName() }} + {{ 'Deleted user' | translate }} +
+
+
+
+
+ {{ 'You are not allowed to see all entitled users.' | translate }} +
diff --git a/client/src/app/site/polls/components/entitled-users-table/entitled-users-table.component.scss b/client/src/app/site/polls/components/entitled-users-table/entitled-users-table.component.scss new file mode 100644 index 000000000..1bebd2055 --- /dev/null +++ b/client/src/app/site/polls/components/entitled-users-table/entitled-users-table.component.scss @@ -0,0 +1,9 @@ +.repr-prefix { + color: #888; + font-size: smaller; +} + +.no-can-see-users { + margin: 1em; + text-align: center; +} diff --git a/client/src/app/site/polls/components/entitled-users-table/entitled-users-table.component.spec.ts b/client/src/app/site/polls/components/entitled-users-table/entitled-users-table.component.spec.ts new file mode 100644 index 000000000..b773eb164 --- /dev/null +++ b/client/src/app/site/polls/components/entitled-users-table/entitled-users-table.component.spec.ts @@ -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; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EntitledUsersTableComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/polls/components/entitled-users-table/entitled-users-table.component.ts b/client/src/app/site/polls/components/entitled-users-table/entitled-users-table.component.ts new file mode 100644 index 000000000..e6f4c3f34 --- /dev/null +++ b/client/src/app/site/polls/components/entitled-users-table/entitled-users-table.component.ts @@ -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; + + @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); + } +} diff --git a/client/src/app/site/polls/models/view-base-poll.ts b/client/src/app/site/polls/models/view-base-poll.ts index 3741fb785..e588f341c 100644 --- a/client/src/app/site/polls/models/view-base-poll.ts +++ b/client/src/app/site/polls/models/view-base-poll.ts @@ -65,6 +65,7 @@ export const PercentBaseVerbose = { YNA: 'Yes/No/Abstain', valid: 'Valid votes', cast: 'Total votes cast', + entitled: 'All entitled users', disabled: 'Disabled' }; diff --git a/client/src/app/site/polls/services/poll.service.ts b/client/src/app/site/polls/services/poll.service.ts index a9051198e..3619b1022 100644 --- a/client/src/app/site/polls/services/poll.service.ts +++ b/client/src/app/site/polls/services/poll.service.ts @@ -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[] { diff --git a/client/src/app/slides/agenda/current-list-of-speakers-overlay/current-list-of-speakers-overlay-slide.component.ts b/client/src/app/slides/agenda/current-list-of-speakers-overlay/current-list-of-speakers-overlay-slide.component.ts index 56128519c..4dda68d8f 100644 --- a/client/src/app/slides/agenda/current-list-of-speakers-overlay/current-list-of-speakers-overlay-slide.component.ts +++ b/client/src/app/slides/agenda/current-list-of-speakers-overlay/current-list-of-speakers-overlay-slide.component.ts @@ -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', diff --git a/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide-data.ts b/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide-data.ts index 7b7c87a5b..326991400 100644 --- a/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide-data.ts +++ b/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide-data.ts @@ -1,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; diff --git a/client/src/app/slides/motions/motion-poll/motion-poll-slide-data.ts b/client/src/app/slides/motions/motion-poll/motion-poll-slide-data.ts index 1ca3e479f..85e882ab9 100644 --- a/client/src/app/slides/motions/motion-poll/motion-poll-slide-data.ts +++ b/client/src/app/slides/motions/motion-poll/motion-poll-slide-data.ts @@ -1,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; diff --git a/client/src/app/slides/polls/base-poll-slide-data.ts b/client/src/app/slides/polls/base-poll-slide-data.ts index 1790d84bd..7c80cae70 100644 --- a/client/src/app/slides/polls/base-poll-slide-data.ts +++ b/client/src/app/slides/polls/base-poll-slide-data.ts @@ -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; diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 8aea37414..4bfddc97b 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -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 diff --git a/server/docker/Dockerfile.dev b/server/docker/Dockerfile.dev index d58202842..9e19b4458 100644 --- a/server/docker/Dockerfile.dev +++ b/server/docker/Dockerfile.dev @@ -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 diff --git a/server/openslides/assignments/migrations/0019_assignmentvote_user_token_1.py b/server/openslides/assignments/migrations/0019_assignmentvote_user_token_1.py new file mode 100644 index 000000000..7308f8f9c --- /dev/null +++ b/server/openslides/assignments/migrations/0019_assignmentvote_user_token_1.py @@ -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), + ), + ] diff --git a/server/openslides/assignments/migrations/0020_assignmentvote_user_token_2.py b/server/openslides/assignments/migrations/0020_assignmentvote_user_token_2.py new file mode 100644 index 000000000..74b38876e --- /dev/null +++ b/server/openslides/assignments/migrations/0020_assignmentvote_user_token_2.py @@ -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")), + ] diff --git a/server/openslides/assignments/migrations/0021_assignmentvote_user_token_3.py b/server/openslides/assignments/migrations/0021_assignmentvote_user_token_3.py new file mode 100644 index 000000000..d531f925f --- /dev/null +++ b/server/openslides/assignments/migrations/0021_assignmentvote_user_token_3.py @@ -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, + ), + ), + ] diff --git a/server/openslides/assignments/migrations/0022_assignmentpoll_change_fields_1.py b/server/openslides/assignments/migrations/0022_assignmentpoll_change_fields_1.py new file mode 100644 index 000000000..c775e21bb --- /dev/null +++ b/server/openslides/assignments/migrations/0022_assignmentpoll_change_fields_1.py @@ -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", + ), + ] diff --git a/server/openslides/assignments/migrations/0023_assignmentpoll_change_fields_2.py b/server/openslides/assignments/migrations/0023_assignmentpoll_change_fields_2.py new file mode 100644 index 000000000..492ac18ce --- /dev/null +++ b/server/openslides/assignments/migrations/0023_assignmentpoll_change_fields_2.py @@ -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")), + ] diff --git a/server/openslides/assignments/models.py b/server/openslides/assignments/models.py index d57458f2f..b71633923 100644 --- a/server/openslides/assignments/models.py +++ b/server/openslides/assignments/models.py @@ -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( diff --git a/server/openslides/assignments/views.py b/server/openslides/assignments/views.py index be2164722..e4fb120ff 100644 --- a/server/openslides/assignments/views.py +++ b/server/openslides/assignments/views.py @@ -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) diff --git a/server/openslides/motions/migrations/0038_motionvote_user_token_1.py b/server/openslides/motions/migrations/0038_motionvote_user_token_1.py new file mode 100644 index 000000000..42cc50b2f --- /dev/null +++ b/server/openslides/motions/migrations/0038_motionvote_user_token_1.py @@ -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), + ), + ] diff --git a/server/openslides/motions/migrations/0039_motionvote_user_token_2.py b/server/openslides/motions/migrations/0039_motionvote_user_token_2.py new file mode 100644 index 000000000..f525f3571 --- /dev/null +++ b/server/openslides/motions/migrations/0039_motionvote_user_token_2.py @@ -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")), + ] diff --git a/server/openslides/motions/migrations/0040_motionvote_user_token_3.py b/server/openslides/motions/migrations/0040_motionvote_user_token_3.py new file mode 100644 index 000000000..b70cc5878 --- /dev/null +++ b/server/openslides/motions/migrations/0040_motionvote_user_token_3.py @@ -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, + ), + ), + ] diff --git a/server/openslides/motions/migrations/0041_motionpoll_change_fields_1.py b/server/openslides/motions/migrations/0041_motionpoll_change_fields_1.py new file mode 100644 index 000000000..4158080f4 --- /dev/null +++ b/server/openslides/motions/migrations/0041_motionpoll_change_fields_1.py @@ -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", + ), + ] diff --git a/server/openslides/motions/migrations/0042_motionpoll_change_fields_2.py b/server/openslides/motions/migrations/0042_motionpoll_change_fields_2.py new file mode 100644 index 000000000..4a0237c9a --- /dev/null +++ b/server/openslides/motions/migrations/0042_motionpoll_change_fields_2.py @@ -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")), + ] diff --git a/server/openslides/motions/models.py b/server/openslides/motions/models.py index d95ad23f3..fdbb4be66 100644 --- a/server/openslides/motions/models.py +++ b/server/openslides/motions/models.py @@ -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. """ diff --git a/server/openslides/poll/migrations/__init__.py b/server/openslides/poll/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/server/openslides/poll/migrations/poll_migration_helper.py b/server/openslides/poll/migrations/poll_migration_helper.py new file mode 100644 index 000000000..3e6478ad3 --- /dev/null +++ b/server/openslides/poll/migrations/poll_migration_helper.py @@ -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 diff --git a/server/openslides/poll/migrations/vote_migration_helper.py b/server/openslides/poll/migrations/vote_migration_helper.py new file mode 100644 index 000000000..b6e09c175 --- /dev/null +++ b/server/openslides/poll/migrations/vote_migration_helper.py @@ -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 diff --git a/server/openslides/poll/models.py b/server/openslides/poll/models.py index e362efbe4..d18ff0e12 100644 --- a/server/openslides/poll/models.py +++ b/server/openslides/poll/models.py @@ -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() diff --git a/server/openslides/poll/serializers.py b/server/openslides/poll/serializers.py index b18be672d..6fc9a1be6 100644 --- a/server/openslides/poll/serializers.py +++ b/server/openslides/poll/serializers.py @@ -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( diff --git a/server/openslides/poll/views.py b/server/openslides/poll/views.py index 0a8c18795..e7bb24496 100644 --- a/server/openslides/poll/views.py +++ b/server/openslides/poll/views.py @@ -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"]) diff --git a/server/tests/integration/assignments/test_polls.py b/server/tests/integration/assignments/test_polls.py index d7ad729a2..624013b91 100644 --- a/server/tests/integration/assignments/test_polls.py +++ b/server/tests/integration/assignments/test_polls.py @@ -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")) diff --git a/server/tests/integration/motions/test_polls.py b/server/tests/integration/motions/test_polls.py index fa7186e37..4743c72dd 100644 --- a/server/tests/integration/motions/test_polls.py +++ b/server/tests/integration/motions/test_polls.py @@ -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): diff --git a/server/tests/unit/motions/test_models.py b/server/tests/unit/motions/test_models.py index ede566292..dc3af8163 100644 --- a/server/tests/unit/motions/test_models.py +++ b/server/tests/unit/motions/test_models.py @@ -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")