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',
foreignViewModel: ViewAssignmentVote
},
{
type: 'M2M',
ownIdKey: 'voted_id',
ownKey: 'voted',
foreignViewModel: ViewUser
},
{
type: 'M2O',
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 { BasePollRepositoryService } from 'app/site/polls/services/base-poll-repository.service';
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 { DataStoreService } from '../../core-services/data-store.service';
@ -25,12 +24,6 @@ const AssignmentPollRelations: RelationDefinition[] = [
ownKey: 'groups',
foreignViewModel: ViewGroup
},
{
type: 'M2M',
ownIdKey: 'voted_id',
ownKey: 'voted',
foreignViewModel: ViewUser
},
{
type: 'O2M',
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 { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll';
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 { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service';
import { DataStoreService } from '../../core-services/data-store.service';
@ -21,6 +22,12 @@ const MotionOptionRelations: RelationDefinition[] = [
ownKey: 'votes',
foreignViewModel: ViewMotionVote
},
{
type: 'M2M',
ownIdKey: 'voted_id',
ownKey: 'voted',
foreignViewModel: ViewUser
},
{
type: 'M2O',
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 { BasePollRepositoryService } from 'app/site/polls/services/base-poll-repository.service';
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 { DataStoreService } from '../../core-services/data-store.service';
@ -25,12 +24,6 @@ const MotionPollRelations: RelationDefinition[] = [
ownKey: 'groups',
foreignViewModel: ViewGroup
},
{
type: 'M2M',
ownIdKey: 'voted_id',
ownKey: 'voted',
foreignViewModel: ViewUser
},
{
type: 'O2M',
ownIdKey: 'options_id',

View File

@ -31,7 +31,7 @@ export class VotingBannerService {
*/
private checkForVotablePolls(polls: ViewBasePoll[]): void {
// 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) {
this.sliceBanner();
return;

View File

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

View File

@ -6,6 +6,8 @@ export abstract class BaseOption<T> extends BaseDecimalModel<T> {
public no: number;
public abstain: number;
public poll_id: number;
public user_has_voted: boolean;
public voted_id: number[];
protected getDecimalFields(): (keyof BaseOption<T>)[] {
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 votescast: number;
public groups_id: number[];
public voted_id: number[];
public majority_method: MajorityMethod;
public onehundred_percent_base: PercentBase;
public user_has_voted: boolean;
public get isCreated(): boolean {
return this.state === PollState.Created;

View File

@ -5,6 +5,12 @@
</div>
<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 -->
<h4 *ngIf="poll.pollmethod === pollMethods.Votes">
{{ 'Votes for this poll' | translate }}: {{ poll.votes_amount }}
@ -13,6 +19,9 @@
<!-- Options and Actions -->
<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
[ngClass]="{
'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 {
const pollOptionIds = this.getPollOptionIds();
const requestMap = pollOptionIds.reduce((o, n) => {
if ((n === optionId && vote === 'Y') !== (this.currentVotes[n] === 'Yes')) {
o[n] = 1;
} else {
o[n] = 0;
}
return o;
}, {});
this.pollRepo.vote(JSON.stringify(requestMap), this.poll.id).catch(this.raiseError);
let requestData;
if (this.poll.pollmethod === AssignmentPollMethods.Votes) {
const pollOptionIds = this.getPollOptionIds();
requestData = pollOptionIds.reduce((o, n) => {
if ((n === optionId && vote === 'Y') !== (this.currentVotes[n] === 'Yes')) {
o[n] = 1; // TODO: allow multiple votes per candidate
} else {
o[n] = 0;
}
return o;
}, {});
} else {
// YN/YNA
requestData = {};
requestData[optionId] = vote;
}
this.pollRepo.vote(requestData, this.poll.id).catch(this.raiseError);
}
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);
}
}

View File

@ -28,8 +28,8 @@
<span>{{ poll.stateVerbose }}</span>
</div>
<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 && 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" 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 && poll.canBeVotedFor" color="warn" matTooltip="{{ 'You still have to vote on this poll.' | translate }}">warning</mat-icon>
</div>
</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>

View File

@ -30,7 +30,7 @@ export class PollProgressComponent extends BaseViewComponent implements OnInit {
}
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;
}
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 getContentObject(): BaseViewModel;

View File

@ -87,11 +87,6 @@ class Migration(migrations.Migration):
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="assignmentpoll",
name="voted",
field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name="assignmentpoll",
name="allow_multiple_votes_per_candidate",
@ -134,11 +129,38 @@ class Migration(migrations.Migration):
name="number_poll_candidates",
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(
model_name="assignment",
name="poll_description_default",
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(
model_name="assignment",
old_name="poll_description_default",
@ -166,6 +188,15 @@ class Migration(migrations.Migration):
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(
model_name="assignmentpoll", old_name="votescast", new_name="db_votescast"
),

View File

@ -267,7 +267,7 @@ class AssignmentVote(RESTModelMixin, BaseVote):
objects = AssignmentVoteManager()
option = models.ForeignKey(
"AssignmentOption", on_delete=models.CASCADE, related_name="votes"
"AssignmentOption", on_delete=CASCADE_AND_AUTOUPDATE, related_name="votes"
)
class Meta:
@ -279,11 +279,14 @@ class AssignmentOption(RESTModelMixin, BaseOption):
vote_class = AssignmentVote
poll = models.ForeignKey(
"AssignmentPoll", on_delete=models.CASCADE, related_name="options"
"AssignmentPoll", on_delete=CASCADE_AND_AUTOUPDATE, related_name="options"
)
user = models.ForeignKey(
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)
class Meta:
@ -304,9 +307,7 @@ class AssignmentPollManager(BaseManager):
super()
.get_prefetched_queryset(*args, **kwargs)
.select_related("assignment")
.prefetch_related(
"options", "options__user", "options__votes", "groups", "voted"
)
.prefetch_related("options", "options__user", "options__votes", "groups")
)
@ -317,7 +318,7 @@ class AssignmentPoll(RESTModelMixin, BasePoll):
option_class = AssignmentOption
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)

View File

@ -403,11 +403,10 @@ class AssignmentPollViewSet(BasePollViewSet):
- amounts must be integer numbers >= 0.
- 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
- 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:
{<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
Votes for all options have to be given
@ -474,6 +473,11 @@ class AssignmentPollViewSet(BasePollViewSet):
)
amount_sum += amount
if amount_sum <= 0:
raise ValidationError(
{"detail": "You must give at least one vote"}
)
if amount_sum > poll.votes_amount:
raise ValidationError(
{
@ -501,68 +505,118 @@ class AssignmentPollViewSet(BasePollViewSet):
poll.pollmethod == AssignmentPoll.POLLMETHOD_YNA
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 (
poll.pollmethod == AssignmentPoll.POLLMETHOD_YN
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
# 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"}
)
# Just for named/pseudoanonymous with YN/YNA skip the all-options-given check
if poll.type not in (
AssignmentPoll.TYPE_NAMED,
AssignmentPoll.TYPE_PSEUDOANONYMOUS,
) or poll.pollmethod not in (
AssignmentPoll.POLLMETHOD_YN,
AssignmentPoll.POLLMETHOD_YNA,
):
# Check if all options were given
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
Assumes data is already validated
"""
options = poll.get_options()
if poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES:
if isinstance(data, dict):
for option_id, amount in data.items():
# skip empty votes
if amount == 0:
continue
option = options.get(pk=option_id)
vote = AssignmentVote.objects.create(
option=option, user=user, weight=Decimal(amount), value="Y"
)
inform_changed_data(vote, no_delete_on_restriction=True)
else: # global_no or global_abstain
option = options.first()
if isinstance(data, dict):
for option_id, amount in data.items():
# Add user to the option's voted array
option = options.get(pk=option_id)
option.voted.add(user)
inform_changed_data(option)
# skip creating votes with empty weights
if amount == 0:
continue
vote = AssignmentVote.objects.create(
option=option,
user=user,
weight=Decimal(poll.votes_amount),
value=data,
option=option, user=user, weight=Decimal(amount), value="Y"
)
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 (
AssignmentPoll.POLLMETHOD_YN,
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)
vote = AssignmentVote.objects.create(
option=option, user=user, value=result
)
inform_changed_data(vote, no_delete_on_restriction=True)
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)
if user in option.voted.all():
raise ValidationError(
{"detail": f"You have already voted for option {option.pk}"}
)
self.create_votes_type_named_pseudoanonymous(data, poll, user, None)
def convert_option_data(self, poll, data):
poll_options = poll.get_options()

View File

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

View File

@ -873,7 +873,7 @@ class MotionVoteManager(BaseManager):
class MotionVote(RESTModelMixin, BaseVote):
access_permissions = MotionVoteAccessPermissions()
option = models.ForeignKey(
"MotionOption", on_delete=models.CASCADE, related_name="votes"
"MotionOption", on_delete=CASCADE_AND_AUTOUPDATE, related_name="votes"
)
objects = MotionVoteManager()
@ -887,7 +887,10 @@ class MotionOption(RESTModelMixin, BaseOption):
vote_class = MotionVote
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:
@ -908,7 +911,7 @@ class MotionPollManager(BaseManager):
super()
.get_prefetched_queryset(*args, **kwargs)
.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()
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_YNA = "YNA"

View File

@ -20,13 +20,8 @@ class BasePollAccessPermissions(BaseAccessPermissions):
Non-published polls will be restricted:
- Remove votes* values from the poll
- Remove yes/no/abstain fields from options
- Remove voted_id field 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):
data = full_data
else:
@ -39,7 +34,6 @@ class BasePollAccessPermissions(BaseAccessPermissions):
del poll["votesvalid"]
del poll["votesinvalid"]
del poll["votescast"]
del poll["voted_id"]
for field in self.additional_fields:
del poll[field]
data.append(poll)
@ -77,6 +71,10 @@ class BaseOptionAccessPermissions(BaseAccessPermissions):
self, full_data: List[Dict[str, Any]], user_id: int
) -> 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):
data = full_data
else:
@ -89,5 +87,6 @@ class BaseOptionAccessPermissions(BaseAccessPermissions):
del option["yes"]
del option["no"]
del option["abstain"]
del option["voted_id"]
data.append(option)
return data

View File

@ -1,5 +1,5 @@
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.core.validators import MinValueValidator
@ -35,7 +35,8 @@ class BaseVote(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
@ -73,6 +74,30 @@ class BaseOption(models.Model):
)
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):
option_class: Optional[Type["BaseOption"]] = None
@ -101,7 +126,6 @@ class BasePoll(models.Model):
title = models.CharField(max_length=255, blank=True, null=False)
groups = models.ManyToManyField(settings.AUTH_GROUP_MODEL, blank=True)
voted = models.ManyToManyField(settings.AUTH_USER_MODEL, blank=True)
db_votesvalid = models.DecimalField(
null=True,
@ -162,7 +186,7 @@ class BasePoll(models.Model):
if self.type == self.TYPE_ANALOG:
return self.db_votesvalid
else:
return Decimal(self.count_users_voted())
return Decimal(self.amount_valid_votes())
def set_votesvalid(self, value):
if self.type != self.TYPE_ANALOG:
@ -175,7 +199,7 @@ class BasePoll(models.Model):
if self.type == self.TYPE_ANALOG:
return self.db_votesinvalid
else:
return Decimal(0)
return Decimal(self.amount_invalid_votes())
def set_votesinvalid(self, value):
if self.type != self.TYPE_ANALOG:
@ -188,7 +212,7 @@ class BasePoll(models.Model):
if self.type == self.TYPE_ANALOG:
return self.db_votescast
else:
return Decimal(self.count_users_voted())
return Decimal(self.amount_voted_users())
def set_votescast(self, value):
if self.type != self.TYPE_ANALOG:
@ -197,14 +221,30 @@ class BasePoll(models.Model):
votescast = property(get_votescast, set_votescast)
def count_users_voted(self):
return self.voted.all().count()
def get_user_ids_with_valid_votes(self):
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):
"""
Returns the option objects for the poll.
"""
return self.get_option_class().objects.filter(poll=self)
def get_all_voted_user_ids(self):
# TODO: This might be faster with only one DB query using distinct.
user_ids: Set[int] = set()
for option in self.get_options():
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):
""" Should be called after creation of this model. """
@ -218,6 +258,12 @@ class BasePoll(models.Model):
)
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
def get_vote_class(cls):
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)
def pseudoanonymize(self):
for vote in self.get_votes():
vote.user = None
vote.save()
for option in self.get_options():
option.pseudoanonymize()
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 options
inform_changed_data(self.get_options())
for option in self.get_options():
option.reset()
# Reset state
self.state = BasePoll.STATE_CREATED

View File

@ -22,7 +22,7 @@ class BaseVoteSerializer(ModelSerializer):
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):
@ -31,6 +31,7 @@ class BaseOptionSerializer(ModelSerializer):
abstain = DecimalField(
max_digits=15, decimal_places=6, min_value=-2, read_only=True
)
voted = IdPrimaryKeyRelatedField(many=True, read_only=True)
pollstate = SerializerMethodField()
@ -47,7 +48,6 @@ BASE_POLL_FIELDS = (
"votesinvalid",
"votescast",
"options",
"voted",
"id",
"onehundred_percent_base",
"majority_method",
@ -59,7 +59,6 @@ class BasePollSerializer(ModelSerializer):
groups = IdPrimaryKeyRelatedField(
many=True, required=False, queryset=get_group_model().objects.all()
)
voted = IdPrimaryKeyRelatedField(many=True, read_only=True)
options = IdPrimaryKeyRelatedField(many=True, read_only=True)
votesvalid = DecimalField(

View File

@ -210,13 +210,12 @@ class BasePollViewSet(ModelViewSet):
elif poll.type == BasePoll.TYPE_NAMED:
self.handle_named_vote(data, poll, request.user)
poll.voted.add(request.user)
elif poll.type == BasePoll.TYPE_PSEUDOANONYMOUS:
self.handle_pseudoanonymous_vote(data, poll)
poll.voted.add(request.user)
self.handle_pseudoanonymous_vote(data, poll, request.user)
inform_changed_data(poll)
inform_changed_data(poll) # needed for the changed voted relation
return Response()
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.
Analog: has to have manage permissions
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 not self.has_manage_permissions():
@ -239,10 +238,6 @@ class BasePollViewSet(ModelViewSet):
):
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):
""" Raises a ValidationError on incorrect values, including None """
if key not in obj:
@ -278,13 +273,16 @@ class BasePollViewSet(ModelViewSet):
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()
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()

View File

@ -48,6 +48,15 @@ def test_assignment_vote_db_queries():
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():
"""
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
@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():
"""
Creates 1 Motion with 5 polls with 5 options each which have 2 votes each