WIP: Partial requests

This commit is contained in:
FinnStutzenstein 2020-02-12 17:18:01 +01:00
parent d4599a435b
commit 0b37c5a857
25 changed files with 334 additions and 149 deletions

View File

@ -22,6 +22,12 @@ const AssignmentOptionRelations: RelationDefinition[] = [
ownKey: 'votes', ownKey: 'votes',
foreignViewModel: ViewAssignmentVote foreignViewModel: ViewAssignmentVote
}, },
{
type: 'M2M',
ownIdKey: 'voted_id',
ownKey: 'voted',
foreignViewModel: ViewUser
},
{ {
type: 'M2O', type: 'M2O',
ownIdKey: 'poll_id', ownIdKey: 'poll_id',

View File

@ -14,7 +14,6 @@ import { ViewAssignmentOption } from 'app/site/assignments/models/view-assignmen
import { AssignmentPollTitleInformation, ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll'; import { AssignmentPollTitleInformation, ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll';
import { BasePollRepositoryService } from 'app/site/polls/services/base-poll-repository.service'; import { BasePollRepositoryService } from 'app/site/polls/services/base-poll-repository.service';
import { ViewGroup } from 'app/site/users/models/view-group'; import { ViewGroup } from 'app/site/users/models/view-group';
import { ViewUser } from 'app/site/users/models/view-user';
import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service';
import { DataStoreService } from '../../core-services/data-store.service'; import { DataStoreService } from '../../core-services/data-store.service';
@ -25,12 +24,6 @@ const AssignmentPollRelations: RelationDefinition[] = [
ownKey: 'groups', ownKey: 'groups',
foreignViewModel: ViewGroup foreignViewModel: ViewGroup
}, },
{
type: 'M2M',
ownIdKey: 'voted_id',
ownKey: 'voted',
foreignViewModel: ViewUser
},
{ {
type: 'O2M', type: 'O2M',
ownIdKey: 'options_id', ownIdKey: 'options_id',

View File

@ -10,6 +10,7 @@ import { MotionOption } from 'app/shared/models/motions/motion-option';
import { ViewMotionOption } from 'app/site/motions/models/view-motion-option'; import { ViewMotionOption } from 'app/site/motions/models/view-motion-option';
import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll';
import { ViewMotionVote } from 'app/site/motions/models/view-motion-vote'; import { ViewMotionVote } from 'app/site/motions/models/view-motion-vote';
import { ViewUser } from 'app/site/users/models/view-user';
import { BaseRepository } from '../base-repository'; import { BaseRepository } from '../base-repository';
import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service';
import { DataStoreService } from '../../core-services/data-store.service'; import { DataStoreService } from '../../core-services/data-store.service';
@ -21,6 +22,12 @@ const MotionOptionRelations: RelationDefinition[] = [
ownKey: 'votes', ownKey: 'votes',
foreignViewModel: ViewMotionVote foreignViewModel: ViewMotionVote
}, },
{
type: 'M2M',
ownIdKey: 'voted_id',
ownKey: 'voted',
foreignViewModel: ViewUser
},
{ {
type: 'M2O', type: 'M2O',
ownIdKey: 'poll_id', ownIdKey: 'poll_id',

View File

@ -14,7 +14,6 @@ import { ViewMotionOption } from 'app/site/motions/models/view-motion-option';
import { MotionPollTitleInformation, ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; import { MotionPollTitleInformation, ViewMotionPoll } from 'app/site/motions/models/view-motion-poll';
import { BasePollRepositoryService } from 'app/site/polls/services/base-poll-repository.service'; import { BasePollRepositoryService } from 'app/site/polls/services/base-poll-repository.service';
import { ViewGroup } from 'app/site/users/models/view-group'; import { ViewGroup } from 'app/site/users/models/view-group';
import { ViewUser } from 'app/site/users/models/view-user';
import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service'; import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service';
import { DataStoreService } from '../../core-services/data-store.service'; import { DataStoreService } from '../../core-services/data-store.service';
@ -25,12 +24,6 @@ const MotionPollRelations: RelationDefinition[] = [
ownKey: 'groups', ownKey: 'groups',
foreignViewModel: ViewGroup foreignViewModel: ViewGroup
}, },
{
type: 'M2M',
ownIdKey: 'voted_id',
ownKey: 'voted',
foreignViewModel: ViewUser
},
{ {
type: 'O2M', type: 'O2M',
ownIdKey: 'options_id', ownIdKey: 'options_id',

View File

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

View File

@ -10,7 +10,7 @@ export enum VotingError {
USER_HAS_NO_PERMISSION, USER_HAS_NO_PERMISSION,
USER_IS_ANONYMOUS, USER_IS_ANONYMOUS,
USER_NOT_PRESENT, USER_NOT_PRESENT,
USER_HAS_VOTED USER_HAS_VOTED_VALID
} }
/** /**
@ -60,8 +60,8 @@ export class VotingService {
if (!user.is_present) { if (!user.is_present) {
return VotingError.USER_NOT_PRESENT; return VotingError.USER_NOT_PRESENT;
} }
if (poll.type === PollType.Pseudoanonymous && poll.user_has_voted) { if (poll.type === PollType.Pseudoanonymous && poll.user_has_voted_valid) {
return VotingError.USER_HAS_VOTED; return VotingError.USER_HAS_VOTED_VALID;
} }
} }

View File

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

View File

@ -46,10 +46,8 @@ export abstract class BasePoll<T = any, O extends BaseOption<any> = any> extends
public votesinvalid: number; public votesinvalid: number;
public votescast: number; public votescast: number;
public groups_id: number[]; public groups_id: number[];
public voted_id: number[];
public majority_method: MajorityMethod; public majority_method: MajorityMethod;
public onehundred_percent_base: PercentBase; public onehundred_percent_base: PercentBase;
public user_has_voted: boolean;
public get isCreated(): boolean { public get isCreated(): boolean {
return this.state === PollState.Created; return this.state === PollState.Created;

View File

@ -5,6 +5,12 @@
</div> </div>
<ng-container *ngIf="vmanager.canVote(poll)"> <ng-container *ngIf="vmanager.canVote(poll)">
<!-- TODO: Someone should make this pretty -->
<span *ngIf="poll.user_has_voted_valid">Your vote is valid!</span>
<span *ngIf="poll.user_has_voted_invalid">DANGER: Your vote is invalid!</span>
<span *ngIf="poll.user_has_not_voted">You have not give any voting here!</span>
<!-- Leftover votes --> <!-- Leftover votes -->
<h4 *ngIf="poll.pollmethod === pollMethods.Votes"> <h4 *ngIf="poll.pollmethod === pollMethods.Votes">
{{ 'Votes for this poll' | translate }}: {{ poll.votes_amount }} {{ 'Votes for this poll' | translate }}: {{ poll.votes_amount }}
@ -13,6 +19,9 @@
<!-- Options and Actions --> <!-- Options and Actions -->
<div *ngFor="let option of poll.options; let i = index"> <div *ngFor="let option of poll.options; let i = index">
<div *ngIf="option.user_has_voted">
TODO: DO not show buttons, becuase, the user has already voted for this one
</div>
<div <div
[ngClass]="{ [ngClass]="{
'yna-grid': poll.pollmethod === pollMethods.YNA, 'yna-grid': poll.pollmethod === pollMethods.YNA,

View File

@ -112,21 +112,28 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponent<ViewAssig
} }
public saveSingleVote(optionId: number, vote: 'Y' | 'N' | 'A'): void { public saveSingleVote(optionId: number, vote: 'Y' | 'N' | 'A'): void {
const pollOptionIds = this.getPollOptionIds(); let requestData;
const requestMap = pollOptionIds.reduce((o, n) => { if (this.poll.pollmethod === AssignmentPollMethods.Votes) {
if ((n === optionId && vote === 'Y') !== (this.currentVotes[n] === 'Yes')) { const pollOptionIds = this.getPollOptionIds();
o[n] = 1; requestData = pollOptionIds.reduce((o, n) => {
} else { if ((n === optionId && vote === 'Y') !== (this.currentVotes[n] === 'Yes')) {
o[n] = 0; o[n] = 1; // TODO: allow multiple votes per candidate
} } else {
o[n] = 0;
return o; }
}, {}); return o;
}, {});
this.pollRepo.vote(JSON.stringify(requestMap), this.poll.id).catch(this.raiseError); } else {
// YN/YNA
requestData = {};
requestData[optionId] = vote;
}
this.pollRepo.vote(requestData, this.poll.id).catch(this.raiseError);
} }
public saveGlobalVote(globalVote: 'N' | 'A'): void { public saveGlobalVote(globalVote: 'N' | 'A'): void {
// This may be a bug in angulars HTTP client: A string is not quoted to be valid json.
// Maybe they expect a string to be alrady a jsonified object.
this.pollRepo.vote(`"${globalVote}"`, this.poll.id).catch(this.raiseError); this.pollRepo.vote(`"${globalVote}"`, this.poll.id).catch(this.raiseError);
} }
} }

View File

@ -28,8 +28,8 @@
<span>{{ poll.stateVerbose }}</span> <span>{{ poll.stateVerbose }}</span>
</div> </div>
<div *pblNgridCellDef="'votability'; row as poll;" class="cell-slot fill"> <div *pblNgridCellDef="'votability'; row as poll;" class="cell-slot fill">
<mat-icon *ngIf="poll.user_has_voted" color="accent" matTooltip="{{ 'You have already voted on this poll. Good job!' | translate }}">check_circle</mat-icon> <mat-icon *ngIf="poll.user_has_voted_valid" color="accent" matTooltip="{{ 'You have already voted on this poll. Good job!' | translate }}">check_circle</mat-icon>
<mat-icon *ngIf="!poll.user_has_voted && poll.canBeVotedFor" color="warn" matTooltip="{{ 'You still have to vote on this poll.' | translate }}">warning</mat-icon> <mat-icon *ngIf="!poll.user_has_voted_valid && poll.canBeVotedFor" color="warn" matTooltip="{{ 'You still have to vote on this poll.' | translate }}">warning</mat-icon>
</div> </div>
</os-list-view-table> </os-list-view-table>

View File

@ -1,3 +1,7 @@
<span> {{ 'Cast votes' }}: {{ poll.voted_id.length }} / {{ max }} </span> <span>
{{ 'Casted votes' | translate }}: {{ poll.votescast }} / {{ max }},
{{ 'valid votes' | translate}}: {{ poll.votesvalid }} / {{ max }},
{{ 'invalid votes' | translate}}: {{ poll.votesinvalid }} / {{ max }}
</span>
<mat-progress-bar class="voting-progress-bar" [value]="valueInPercent"></mat-progress-bar> <mat-progress-bar class="voting-progress-bar" [value]="valueInPercent"></mat-progress-bar>

View File

@ -30,7 +30,7 @@ export class PollProgressComponent extends BaseViewComponent implements OnInit {
} }
public get valueInPercent(): number { public get valueInPercent(): number {
return (this.poll.voted_id.length / this.max) * 100; return (this.poll.votesvalid / this.max) * 100;
} }
/** /**

View File

@ -138,6 +138,18 @@ export abstract class ViewBasePoll<M extends BasePoll<M, any> = any> extends Bas
return states; return states;
} }
public get user_has_voted_invalid(): boolean {
return this.options.some(option => option.user_has_voted) && !this.user_has_voted_valid;
}
public get user_has_voted_valid(): boolean {
return this.options.every(option => option.user_has_voted);
}
public get user_has_not_voted(): boolean {
return this.options.every(option => !option.user_has_voted);
}
public abstract getSlide(): ProjectorElementBuildDeskriptor; public abstract getSlide(): ProjectorElementBuildDeskriptor;
public abstract getContentObject(): BaseViewModel; public abstract getContentObject(): BaseViewModel;

View File

@ -87,11 +87,6 @@ class Migration(migrations.Migration):
to=settings.AUTH_USER_MODEL, to=settings.AUTH_USER_MODEL,
), ),
), ),
migrations.AddField(
model_name="assignmentpoll",
name="voted",
field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL),
),
migrations.AddField( migrations.AddField(
model_name="assignmentpoll", model_name="assignmentpoll",
name="allow_multiple_votes_per_candidate", name="allow_multiple_votes_per_candidate",
@ -134,11 +129,38 @@ class Migration(migrations.Migration):
name="number_poll_candidates", name="number_poll_candidates",
field=models.BooleanField(default=False), field=models.BooleanField(default=False),
), ),
migrations.AddField(
model_name="assignmentoption",
name="voted",
field=models.ManyToManyField(
blank=True,
to=settings.AUTH_USER_MODEL,
related_name="assignmentoption_voted",
),
),
migrations.AlterField( migrations.AlterField(
model_name="assignment", model_name="assignment",
name="poll_description_default", name="poll_description_default",
field=models.CharField(blank=True, max_length=255), field=models.CharField(blank=True, max_length=255),
), ),
migrations.AlterField(
model_name="assignmentoption",
name="poll",
field=models.ForeignKey(
on_delete=openslides.utils.models.CASCADE_AND_AUTOUPDATE,
related_name="options",
to="assignments.AssignmentPoll",
),
),
migrations.AlterField(
model_name="assignmentvote",
name="option",
field=models.ForeignKey(
on_delete=openslides.utils.models.CASCADE_AND_AUTOUPDATE,
related_name="votes",
to="assignments.AssignmentOption",
),
),
migrations.RenameField( migrations.RenameField(
model_name="assignment", model_name="assignment",
old_name="poll_description_default", old_name="poll_description_default",
@ -166,6 +188,15 @@ class Migration(migrations.Migration):
validators=[django.core.validators.MinValueValidator(Decimal("-2"))], validators=[django.core.validators.MinValueValidator(Decimal("-2"))],
), ),
), ),
migrations.AlterField(
model_name="assignmentpoll",
name="assignment",
field=models.ForeignKey(
on_delete=openslides.utils.models.CASCADE_AND_AUTOUPDATE,
related_name="polls",
to="assignments.Assignment",
),
),
migrations.RenameField( migrations.RenameField(
model_name="assignmentpoll", old_name="votescast", new_name="db_votescast" model_name="assignmentpoll", old_name="votescast", new_name="db_votescast"
), ),

View File

@ -267,7 +267,7 @@ class AssignmentVote(RESTModelMixin, BaseVote):
objects = AssignmentVoteManager() objects = AssignmentVoteManager()
option = models.ForeignKey( option = models.ForeignKey(
"AssignmentOption", on_delete=models.CASCADE, related_name="votes" "AssignmentOption", on_delete=CASCADE_AND_AUTOUPDATE, related_name="votes"
) )
class Meta: class Meta:
@ -279,11 +279,14 @@ class AssignmentOption(RESTModelMixin, BaseOption):
vote_class = AssignmentVote vote_class = AssignmentVote
poll = models.ForeignKey( poll = models.ForeignKey(
"AssignmentPoll", on_delete=models.CASCADE, related_name="options" "AssignmentPoll", on_delete=CASCADE_AND_AUTOUPDATE, related_name="options"
) )
user = models.ForeignKey( user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=SET_NULL_AND_AUTOUPDATE, null=True settings.AUTH_USER_MODEL, on_delete=SET_NULL_AND_AUTOUPDATE, null=True
) )
voted = models.ManyToManyField(
settings.AUTH_USER_MODEL, blank=True, related_name="assignmentoption_voted"
)
weight = models.IntegerField(default=0) weight = models.IntegerField(default=0)
class Meta: class Meta:
@ -304,9 +307,7 @@ class AssignmentPollManager(BaseManager):
super() super()
.get_prefetched_queryset(*args, **kwargs) .get_prefetched_queryset(*args, **kwargs)
.select_related("assignment") .select_related("assignment")
.prefetch_related( .prefetch_related("options", "options__user", "options__votes", "groups")
"options", "options__user", "options__votes", "groups", "voted"
)
) )
@ -317,7 +318,7 @@ class AssignmentPoll(RESTModelMixin, BasePoll):
option_class = AssignmentOption option_class = AssignmentOption
assignment = models.ForeignKey( assignment = models.ForeignKey(
Assignment, on_delete=models.CASCADE, related_name="polls" Assignment, on_delete=CASCADE_AND_AUTOUPDATE, related_name="polls"
) )
description = models.CharField(max_length=255, blank=True) description = models.CharField(max_length=255, blank=True)

View File

@ -403,11 +403,10 @@ class AssignmentPollViewSet(BasePollViewSet):
- amounts must be integer numbers >= 0. - amounts must be integer numbers >= 0.
- ids should be integers of valid option ids for this poll - ids should be integers of valid option ids for this poll
- amounts must be 0 or 1, if poll.allow_multiple_votes_per_candidate is False - amounts must be 0 or 1, if poll.allow_multiple_votes_per_candidate is False
- The sum of all amounts must be poll.votes_amount votes - The sum of all amounts must be grater then 0 and <= poll.votes_amount
YN/YNA: YN/YNA:
{<option_id>: 'Y' | 'N' [|'A']} {<option_id>: 'Y' | 'N' [|'A']}
- all option_ids must be given TODO: No it must not be that way. Single Votes have to be accepted
- 'A' is only allowed in YNA pollmethod - 'A' is only allowed in YNA pollmethod
Votes for all options have to be given Votes for all options have to be given
@ -474,6 +473,11 @@ class AssignmentPollViewSet(BasePollViewSet):
) )
amount_sum += amount amount_sum += amount
if amount_sum <= 0:
raise ValidationError(
{"detail": "You must give at least one vote"}
)
if amount_sum > poll.votes_amount: if amount_sum > poll.votes_amount:
raise ValidationError( raise ValidationError(
{ {
@ -501,68 +505,118 @@ class AssignmentPollViewSet(BasePollViewSet):
poll.pollmethod == AssignmentPoll.POLLMETHOD_YNA poll.pollmethod == AssignmentPoll.POLLMETHOD_YNA
and value not in ("Y", "N", "A",) and value not in ("Y", "N", "A",)
): ):
raise ValidationError("Every value must be Y, N or A") raise ValidationError(
{"detail": "Every value must be Y, N or A"}
)
elif ( elif (
poll.pollmethod == AssignmentPoll.POLLMETHOD_YN poll.pollmethod == AssignmentPoll.POLLMETHOD_YN
and value not in ("Y", "N",) and value not in ("Y", "N",)
): ):
raise ValidationError("Every value must be Y or N") raise ValidationError({"detail": "Every value must be Y or N"})
options_data = data options_data = data
# Check if all options were given # Just for named/pseudoanonymous with YN/YNA skip the all-options-given check
db_option_ids = set(option.id for option in poll.get_options()) if poll.type not in (
data_option_ids = set(int(option_id) for option_id in options_data.keys()) AssignmentPoll.TYPE_NAMED,
if data_option_ids != db_option_ids: AssignmentPoll.TYPE_PSEUDOANONYMOUS,
raise ValidationError( ) or poll.pollmethod not in (
{"error": "You have to provide values for all options"} AssignmentPoll.POLLMETHOD_YN,
) AssignmentPoll.POLLMETHOD_YNA,
):
# Check if all options were given
db_option_ids = set(option.id for option in poll.get_options())
data_option_ids = set(int(option_id) for option_id in options_data.keys())
if data_option_ids != db_option_ids:
raise ValidationError(
{"error": "You have to provide values for all options"}
)
def create_votes(self, data, poll, user=None): def create_votes_type_votes(self, data, poll, user):
""" """
Helper function for handle_(named|pseudoanonymous)_vote Helper function for handle_(named|pseudoanonymous)_vote
Assumes data is already validated Assumes data is already validated
""" """
options = poll.get_options() options = poll.get_options()
if poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES: if isinstance(data, dict):
if isinstance(data, dict): for option_id, amount in data.items():
for option_id, amount in data.items(): # Add user to the option's voted array
# skip empty votes option = options.get(pk=option_id)
if amount == 0: option.voted.add(user)
continue inform_changed_data(option)
option = options.get(pk=option_id)
vote = AssignmentVote.objects.create( # skip creating votes with empty weights
option=option, user=user, weight=Decimal(amount), value="Y" if amount == 0:
) continue
inform_changed_data(vote, no_delete_on_restriction=True)
else: # global_no or global_abstain
option = options.first()
vote = AssignmentVote.objects.create( vote = AssignmentVote.objects.create(
option=option, option=option, user=user, weight=Decimal(amount), value="Y"
user=user,
weight=Decimal(poll.votes_amount),
value=data,
) )
inform_changed_data(vote, no_delete_on_restriction=True) inform_changed_data(vote, no_delete_on_restriction=True)
else: # global_no or global_abstain
option = options.order_by(
"pk"
).first() # order by is important to always get
# the correct "first" option
option.voted.add(user)
inform_changed_data(option)
vote = AssignmentVote.objects.create(
option=option, user=user, weight=Decimal(poll.votes_amount), value=data,
)
inform_changed_data(vote, no_delete_on_restriction=True)
def create_votes_type_named_pseudoanonymous(
self, data, poll, check_user, vote_user
):
""" check_user is used for the voted-array, vote_user is the one put into the vote """
options = poll.get_options()
for option_id, result in data.items():
option = options.get(pk=option_id)
option.voted.add(check_user)
inform_changed_data(option)
vote = AssignmentVote.objects.create(
option=option, user=vote_user, value=result
)
inform_changed_data(vote, no_delete_on_restriction=True)
def handle_named_vote(self, data, poll, user):
if poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES:
# Instead of reusing all existing votes for the user, delete all previous votes
for vote in poll.get_votes().filter(user=user):
vote.delete()
self.create_votes_type_votes(data, poll, user)
elif poll.pollmethod in ( elif poll.pollmethod in (
AssignmentPoll.POLLMETHOD_YN, AssignmentPoll.POLLMETHOD_YN,
AssignmentPoll.POLLMETHOD_YNA, AssignmentPoll.POLLMETHOD_YNA,
): ):
for option_id, result in data.items(): # Delete all votes for the given options
option_ids = list(data.keys())
for vote in AssignmentVote.objects.filter(
user=user, option_id__in=option_ids
):
vote.delete()
self.create_votes_type_named_pseudoanonymous(data, poll, user, user)
def handle_pseudoanonymous_vote(self, data, poll, user):
if poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES:
# check if the user has already voted
for option in poll.get_options():
if user in option.voted.all():
raise ValidationError({"detail": "You have already voted"})
self.create_votes_type_votes(data, poll, user)
elif poll.pollmethod in (
AssignmentPoll.POLLMETHOD_YN,
AssignmentPoll.POLLMETHOD_YNA,
):
# Ensure, that the user has not voted any of the given options yet.
options = poll.get_options()
for option_id in data.keys():
option = options.get(pk=option_id) option = options.get(pk=option_id)
vote = AssignmentVote.objects.create( if user in option.voted.all():
option=option, user=user, value=result raise ValidationError(
) {"detail": f"You have already voted for option {option.pk}"}
inform_changed_data(vote, no_delete_on_restriction=True) )
self.create_votes_type_named_pseudoanonymous(data, poll, user, None)
def handle_named_vote(self, data, poll, user):
# Instead of reusing all existing votes for the user, delete all previous votes
for vote in poll.get_votes().filter(user=user):
vote.delete()
self.create_votes(data, poll, user)
def handle_pseudoanonymous_vote(self, data, poll):
self.create_votes(data, poll)
def convert_option_data(self, poll, data): def convert_option_data(self, poll, data):
poll_options = poll.get_options() poll_options = poll.get_options()

View File

@ -76,11 +76,6 @@ class Migration(migrations.Migration):
), ),
preserve_default=False, preserve_default=False,
), ),
migrations.AddField(
model_name="motionpoll",
name="voted",
field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL),
),
migrations.AddField( migrations.AddField(
model_name="motionpoll", model_name="motionpoll",
name="majority_method", name="majority_method",
@ -112,11 +107,20 @@ class Migration(migrations.Migration):
), ),
preserve_default=False, preserve_default=False,
), ),
migrations.AddField(
model_name="motionoption",
name="voted",
field=models.ManyToManyField(
blank=True,
to=settings.AUTH_USER_MODEL,
related_name="motionoption_voted",
),
),
migrations.AlterField( migrations.AlterField(
model_name="motionvote", model_name="motionvote",
name="option", name="option",
field=models.ForeignKey( field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, on_delete=openslides.utils.models.CASCADE_AND_AUTOUPDATE,
related_name="votes", related_name="votes",
to="motions.MotionOption", to="motions.MotionOption",
), ),
@ -142,11 +146,20 @@ class Migration(migrations.Migration):
model_name="motionoption", model_name="motionoption",
name="poll", name="poll",
field=models.ForeignKey( field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, on_delete=openslides.utils.models.CASCADE_AND_AUTOUPDATE,
related_name="options", related_name="options",
to="motions.MotionPoll", to="motions.MotionPoll",
), ),
), ),
migrations.AlterField(
model_name="motionpoll",
name="motion",
field=models.ForeignKey(
on_delete=openslides.utils.models.CASCADE_AND_AUTOUPDATE,
related_name="polls",
to="motions.Motion",
),
),
migrations.RenameField( migrations.RenameField(
model_name="motionpoll", old_name="votescast", new_name="db_votescast" model_name="motionpoll", old_name="votescast", new_name="db_votescast"
), ),

View File

@ -873,7 +873,7 @@ class MotionVoteManager(BaseManager):
class MotionVote(RESTModelMixin, BaseVote): class MotionVote(RESTModelMixin, BaseVote):
access_permissions = MotionVoteAccessPermissions() access_permissions = MotionVoteAccessPermissions()
option = models.ForeignKey( option = models.ForeignKey(
"MotionOption", on_delete=models.CASCADE, related_name="votes" "MotionOption", on_delete=CASCADE_AND_AUTOUPDATE, related_name="votes"
) )
objects = MotionVoteManager() objects = MotionVoteManager()
@ -887,7 +887,10 @@ class MotionOption(RESTModelMixin, BaseOption):
vote_class = MotionVote vote_class = MotionVote
poll = models.ForeignKey( poll = models.ForeignKey(
"MotionPoll", related_name="options", on_delete=models.CASCADE "MotionPoll", related_name="options", on_delete=CASCADE_AND_AUTOUPDATE
)
voted = models.ManyToManyField(
settings.AUTH_USER_MODEL, blank=True, related_name="motionoption_voted"
) )
class Meta: class Meta:
@ -908,7 +911,7 @@ class MotionPollManager(BaseManager):
super() super()
.get_prefetched_queryset(*args, **kwargs) .get_prefetched_queryset(*args, **kwargs)
.select_related("motion") .select_related("motion")
.prefetch_related("options", "options__votes", "groups", "voted") .prefetch_related("options", "options__votes", "groups")
) )
@ -918,7 +921,9 @@ class MotionPoll(RESTModelMixin, BasePoll):
objects = MotionPollManager() objects = MotionPollManager()
motion = models.ForeignKey(Motion, on_delete=models.CASCADE, related_name="polls") motion = models.ForeignKey(
Motion, on_delete=CASCADE_AND_AUTOUPDATE, related_name="polls"
)
POLLMETHOD_YN = "YN" POLLMETHOD_YN = "YN"
POLLMETHOD_YNA = "YNA" POLLMETHOD_YNA = "YNA"

View File

@ -20,13 +20,8 @@ class BasePollAccessPermissions(BaseAccessPermissions):
Non-published polls will be restricted: Non-published polls will be restricted:
- Remove votes* values from the poll - Remove votes* values from the poll
- Remove yes/no/abstain fields from options - Remove yes/no/abstain fields from options
- Remove voted_id field from the poll
- Remove fields given in self.assitional_fields from the poll - Remove fields given in self.assitional_fields from the poll
""" """
# add hast_voted for all users to check whether op has voted
for poll in full_data:
poll["user_has_voted"] = user_id in poll["voted_id"]
if await async_has_perm(user_id, self.manage_permission): if await async_has_perm(user_id, self.manage_permission):
data = full_data data = full_data
else: else:
@ -39,7 +34,6 @@ class BasePollAccessPermissions(BaseAccessPermissions):
del poll["votesvalid"] del poll["votesvalid"]
del poll["votesinvalid"] del poll["votesinvalid"]
del poll["votescast"] del poll["votescast"]
del poll["voted_id"]
for field in self.additional_fields: for field in self.additional_fields:
del poll[field] del poll[field]
data.append(poll) data.append(poll)
@ -77,6 +71,10 @@ class BaseOptionAccessPermissions(BaseAccessPermissions):
self, full_data: List[Dict[str, Any]], user_id: int self, full_data: List[Dict[str, Any]], user_id: int
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
# add has_voted for all users to check whether op has voted
for option in full_data:
option["user_has_voted"] = user_id in option["voted_id"]
if await async_has_perm(user_id, self.manage_permission): if await async_has_perm(user_id, self.manage_permission):
data = full_data data = full_data
else: else:
@ -89,5 +87,6 @@ class BaseOptionAccessPermissions(BaseAccessPermissions):
del option["yes"] del option["yes"]
del option["no"] del option["no"]
del option["abstain"] del option["abstain"]
del option["voted_id"]
data.append(option) data.append(option)
return data return data

View File

@ -1,5 +1,5 @@
from decimal import Decimal from decimal import Decimal
from typing import Iterable, Optional, Tuple, Type from typing import Iterable, Optional, Set, Tuple, Type
from django.conf import settings from django.conf import settings
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
@ -35,7 +35,8 @@ class BaseVote(models.Model):
class BaseOption(models.Model): class BaseOption(models.Model):
""" """
All subclasses must have poll attribute with the related name "options" All subclasses must have poll attribute with the related name "options". Also
they must have a "voted" relation to users.
""" """
vote_class: Optional[Type["BaseVote"]] = None vote_class: Optional[Type["BaseVote"]] = None
@ -73,6 +74,30 @@ class BaseOption(models.Model):
) )
return cls.vote_class return cls.vote_class
def get_votes(self):
"""
Return a QuerySet with all vote objects related to this option.
"""
return self.get_vote_class().objects.filter(option=self)
def pseudoanonymize(self):
for vote in self.get_votes():
vote.user = None
vote.save()
def reset(self):
self.voted.clear()
# Delete votes
votes = self.get_votes()
votes_id = [vote.id for vote in votes]
votes.delete()
collection = self.get_vote_class().get_collection_string()
inform_deleted_data((collection, id) for id in votes_id)
# update self because the changed voted relation
inform_changed_data(self)
class BasePoll(models.Model): class BasePoll(models.Model):
option_class: Optional[Type["BaseOption"]] = None option_class: Optional[Type["BaseOption"]] = None
@ -101,7 +126,6 @@ class BasePoll(models.Model):
title = models.CharField(max_length=255, blank=True, null=False) title = models.CharField(max_length=255, blank=True, null=False)
groups = models.ManyToManyField(settings.AUTH_GROUP_MODEL, blank=True) groups = models.ManyToManyField(settings.AUTH_GROUP_MODEL, blank=True)
voted = models.ManyToManyField(settings.AUTH_USER_MODEL, blank=True)
db_votesvalid = models.DecimalField( db_votesvalid = models.DecimalField(
null=True, null=True,
@ -162,7 +186,7 @@ class BasePoll(models.Model):
if self.type == self.TYPE_ANALOG: if self.type == self.TYPE_ANALOG:
return self.db_votesvalid return self.db_votesvalid
else: else:
return Decimal(self.count_users_voted()) return Decimal(self.amount_valid_votes())
def set_votesvalid(self, value): def set_votesvalid(self, value):
if self.type != self.TYPE_ANALOG: if self.type != self.TYPE_ANALOG:
@ -175,7 +199,7 @@ class BasePoll(models.Model):
if self.type == self.TYPE_ANALOG: if self.type == self.TYPE_ANALOG:
return self.db_votesinvalid return self.db_votesinvalid
else: else:
return Decimal(0) return Decimal(self.amount_invalid_votes())
def set_votesinvalid(self, value): def set_votesinvalid(self, value):
if self.type != self.TYPE_ANALOG: if self.type != self.TYPE_ANALOG:
@ -188,7 +212,7 @@ class BasePoll(models.Model):
if self.type == self.TYPE_ANALOG: if self.type == self.TYPE_ANALOG:
return self.db_votescast return self.db_votescast
else: else:
return Decimal(self.count_users_voted()) return Decimal(self.amount_voted_users())
def set_votescast(self, value): def set_votescast(self, value):
if self.type != self.TYPE_ANALOG: if self.type != self.TYPE_ANALOG:
@ -197,14 +221,30 @@ class BasePoll(models.Model):
votescast = property(get_votescast, set_votescast) votescast = property(get_votescast, set_votescast)
def count_users_voted(self): def get_user_ids_with_valid_votes(self):
return self.voted.all().count() initial_option = self.get_options().first()
user_ids = set(map(lambda u: u.id, initial_option.voted.all()))
for option in self.get_options():
user_ids = user_ids.intersection(
set(map(lambda u: u.id, option.voted.all()))
)
return list(user_ids)
def get_options(self): def get_all_voted_user_ids(self):
""" # TODO: This might be faster with only one DB query using distinct.
Returns the option objects for the poll. user_ids: Set[int] = set()
""" for option in self.get_options():
return self.get_option_class().objects.filter(poll=self) user_ids.update(option.voted.all().values_list("pk", flat=True))
return list(user_ids)
def amount_valid_votes(self):
return len(self.get_user_ids_with_valid_votes())
def amount_invalid_votes(self):
return self.amount_voted_users() - self.amount_valid_votes()
def amount_voted_users(self):
return len(self.get_all_voted_user_ids())
def create_options(self): def create_options(self):
""" Should be called after creation of this model. """ """ Should be called after creation of this model. """
@ -218,6 +258,12 @@ class BasePoll(models.Model):
) )
return cls.option_class return cls.option_class
def get_options(self):
"""
Returns the option objects for the poll.
"""
return self.get_option_class().objects.filter(poll=self)
@classmethod @classmethod
def get_vote_class(cls): def get_vote_class(cls):
return cls.get_option_class().get_vote_class() return cls.get_option_class().get_vote_class()
@ -229,22 +275,12 @@ class BasePoll(models.Model):
return self.get_vote_class().objects.filter(option__poll__id=self.id) return self.get_vote_class().objects.filter(option__poll__id=self.id)
def pseudoanonymize(self): def pseudoanonymize(self):
for vote in self.get_votes(): for option in self.get_options():
vote.user = None option.pseudoanonymize()
vote.save()
def reset(self): def reset(self):
self.voted.clear() for option in self.get_options():
option.reset()
# Delete votes
votes = self.get_votes()
votes_id = [vote.id for vote in votes]
votes.delete()
collection = self.get_vote_class().get_collection_string()
inform_deleted_data((collection, id) for id in votes_id)
# update options
inform_changed_data(self.get_options())
# Reset state # Reset state
self.state = BasePoll.STATE_CREATED self.state = BasePoll.STATE_CREATED

View File

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

View File

@ -210,13 +210,12 @@ class BasePollViewSet(ModelViewSet):
elif poll.type == BasePoll.TYPE_NAMED: elif poll.type == BasePoll.TYPE_NAMED:
self.handle_named_vote(data, poll, request.user) self.handle_named_vote(data, poll, request.user)
poll.voted.add(request.user)
elif poll.type == BasePoll.TYPE_PSEUDOANONYMOUS: elif poll.type == BasePoll.TYPE_PSEUDOANONYMOUS:
self.handle_pseudoanonymous_vote(data, poll) self.handle_pseudoanonymous_vote(data, poll, request.user)
poll.voted.add(request.user)
inform_changed_data(poll)
inform_changed_data(poll) # needed for the changed voted relation
return Response() return Response()
def assert_can_vote(self, poll, request): def assert_can_vote(self, poll, request):
@ -224,7 +223,7 @@ class BasePollViewSet(ModelViewSet):
Raises a permission denied, if the user is not allowed to vote. Raises a permission denied, if the user is not allowed to vote.
Analog: has to have manage permissions Analog: has to have manage permissions
Named & Pseudoanonymous: has to be in a poll group and present Named & Pseudoanonymous: has to be in a poll group and present
Only pseudoanonymous: has to not have voted yet Note: For pseudoanonymous it is *not* tested, if the user has already voted!
""" """
if poll.type == BasePoll.TYPE_ANALOG: if poll.type == BasePoll.TYPE_ANALOG:
if not self.has_manage_permissions(): if not self.has_manage_permissions():
@ -239,10 +238,6 @@ class BasePollViewSet(ModelViewSet):
): ):
self.permission_denied(request) self.permission_denied(request)
if poll.type == BasePoll.TYPE_PSEUDOANONYMOUS:
if request.user in poll.voted.all():
self.permission_denied(request)
def parse_vote_value(self, obj, key): def parse_vote_value(self, obj, key):
""" Raises a ValidationError on incorrect values, including None """ """ Raises a ValidationError on incorrect values, including None """
if key not in obj: if key not in obj:
@ -278,13 +273,16 @@ class BasePollViewSet(ModelViewSet):
def handle_named_vote(self, data, poll, user): def handle_named_vote(self, data, poll, user):
""" """
To be implemented by subclass. Handles the named vote. Assumes data is validated To be implemented by subclass. Handles the named vote. Assumes data is validated.
Needs to manage the voted-array per option.
""" """
raise NotImplementedError() raise NotImplementedError()
def handle_pseudoanonymous_vote(self, data, poll): def handle_pseudoanonymous_vote(self, data, poll, user):
""" """
To be implemented by subclass. Handles the pseudoanonymous vote. Assumes data is validated To be implemented by subclass. Handles the pseudoanonymous vote. Assumes data
is validated. Needs to check, if the vote is allowed by the voted-array per poll.
Needs to add the user to the voted-array.
""" """
raise NotImplementedError() raise NotImplementedError()

View File

@ -48,6 +48,15 @@ def test_assignment_vote_db_queries():
assert count_queries(AssignmentVote.get_elements)() == 1 assert count_queries(AssignmentVote.get_elements)() == 1
@pytest.mark.django_db(transaction=False)
def test_assignment_option_db_queries():
"""
Tests that only 1 query is done when fetching AssignmentOptions
"""
create_assignment_polls()
assert count_queries(AssignmentOption.get_elements)() == 1
def create_assignment_polls(): def create_assignment_polls():
""" """
Creates 1 assignment with 3 candidates which has 5 polls in which each candidate got a random amount of votes between 0 and 10 from 3 users Creates 1 assignment with 3 candidates which has 5 polls in which each candidate got a random amount of votes between 0 and 10 from 3 users

View File

@ -41,6 +41,15 @@ def test_motion_vote_db_queries():
assert count_queries(MotionVote.get_elements)() == 1 assert count_queries(MotionVote.get_elements)() == 1
@pytest.mark.django_db(transaction=False)
def test_motion_option_db_queries():
"""
Tests that only 1 query is done when fetching MotionOptions
"""
create_motion_polls()
assert count_queries(MotionOption.get_elements)() == 1
def create_motion_polls(): def create_motion_polls():
""" """
Creates 1 Motion with 5 polls with 5 options each which have 2 votes each Creates 1 Motion with 5 polls with 5 options each which have 2 votes each