Sort candidates in assignments

This commit is contained in:
FinnStutzenstein 2016-12-06 12:21:29 +01:00
parent 447fd35f53
commit 3b1ab265eb
9 changed files with 163 additions and 31 deletions

View File

@ -3,6 +3,7 @@ from django.db import transaction
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from openslides.core.config import config from openslides.core.config import config
from openslides.utils.autoupdate import inform_changed_data
from openslides.utils.exceptions import OpenSlidesError from openslides.utils.exceptions import OpenSlidesError
from openslides.utils.rest_api import ( from openslides.utils.rest_api import (
GenericViewSet, GenericViewSet,
@ -211,9 +212,12 @@ class ItemViewSet(ListModelMixin, RetrieveModelMixin, UpdateModelMixin, GenericV
with transaction.atomic(): with transaction.atomic():
for speaker in valid_speakers: for speaker in valid_speakers:
speaker.weight = weight speaker.weight = weight
speaker.save() speaker.save(skip_autoupdate=True)
weight += 1 weight += 1
# send autoupdate
inform_changed_data(item)
# Initiate response. # Initiate response.
return Response({'detail': _('List of speakers successfully sorted.')}) return Response({'detail': _('List of speakers successfully sorted.')})

View File

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2016-12-16 11:18
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assignments', '0002_assignmentpoll_pollmethod'),
]
operations = [
migrations.AddField(
model_name='assignmentrelateduser',
name='weight',
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name='assignmentoption',
name='weight',
field=models.IntegerField(default=0),
),
]

View File

@ -28,14 +28,31 @@ class AssignmentRelatedUser(RESTModelMixin, models.Model):
""" """
Many to Many table between an assignment and user. Many to Many table between an assignment and user.
""" """
assignment = models.ForeignKey( assignment = models.ForeignKey(
'Assignment', 'Assignment',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='assignment_related_users') related_name='assignment_related_users')
"""
ForeinKey to the assignment.
"""
user = models.ForeignKey( user = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
on_delete=models.CASCADE) on_delete=models.CASCADE)
"""
ForeinKey to the user who is related to the assignment.
"""
elected = models.BooleanField(default=False) elected = models.BooleanField(default=False)
"""
Saves the election state of each user
"""
weight = models.IntegerField(default=0)
"""
The sort order of the candidates.
"""
class Meta: class Meta:
default_permissions = () default_permissions = ()
@ -193,9 +210,14 @@ class Assignment(RESTModelMixin, models.Model):
""" """
Adds the user as candidate. Adds the user as candidate.
""" """
weight = self.assignment_related_users.aggregate(
models.Max('weight'))['weight__max'] or 0
defaults = {
'elected': False,
'weight': weight + 1}
related_user, __ = self.assignment_related_users.update_or_create( related_user, __ = self.assignment_related_users.update_or_create(
user=user, user=user,
defaults={'elected': False}) defaults=defaults)
def set_elected(self, user): def set_elected(self, user):
""" """
@ -249,7 +271,13 @@ class Assignment(RESTModelMixin, models.Model):
poll = self.polls.create( poll = self.polls.create(
description=self.poll_description_default, description=self.poll_description_default,
pollmethod=pollmethod) pollmethod=pollmethod)
poll.set_options({'candidate': user} for user in candidates) options = []
related_users = AssignmentRelatedUser.objects.filter(assignment__id=self.id)
for related_user in related_users:
options.append({
'candidate': related_user.user,
'weight': related_user.weight})
poll.set_options(options)
# Add all candidates to list of speakers of related agenda item # Add all candidates to list of speakers of related agenda item
# TODO: Try to do this in a bulk create # TODO: Try to do this in a bulk create
@ -360,6 +388,8 @@ class AssignmentOption(RESTModelMixin, BaseOption):
candidate = models.ForeignKey( candidate = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
on_delete=models.CASCADE) on_delete=models.CASCADE)
weight = models.IntegerField(default=0)
vote_class = AssignmentVote vote_class = AssignmentVote
class Meta: class Meta:

View File

@ -32,7 +32,8 @@ class AssignmentRelatedUserSerializer(ModelSerializer):
'id', 'id',
'user', 'user',
'elected', 'elected',
'assignment') # js-data needs the assignment-id in the nested object to define relations. 'assignment',
'weight') # js-data needs the assignment-id in the nested object to define relations.
class AssignmentVoteSerializer(ModelSerializer): class AssignmentVoteSerializer(ModelSerializer):
@ -53,7 +54,7 @@ class AssignmentOptionSerializer(ModelSerializer):
class Meta: class Meta:
model = AssignmentOption model = AssignmentOption
fields = ('id', 'candidate', 'is_elected', 'votes', 'poll') fields = ('id', 'candidate', 'is_elected', 'votes', 'poll', 'weight')
def get_is_elected(self, obj): def get_is_elected(self, obj):
""" """

View File

@ -5,9 +5,10 @@
angular.module('OpenSlidesApp.assignments.pdf', ['OpenSlidesApp.core.pdf']) angular.module('OpenSlidesApp.assignments.pdf', ['OpenSlidesApp.core.pdf'])
.factory('AssignmentContentProvider', [ .factory('AssignmentContentProvider', [
'$filter',
'gettextCatalog', 'gettextCatalog',
'PDFLayout', 'PDFLayout',
function(gettextCatalog, PDFLayout) { function($filter, gettextCatalog, PDFLayout) {
var createInstance = function(assignment) { var createInstance = function(assignment) {
@ -60,10 +61,11 @@ angular.module('OpenSlidesApp.assignments.pdf', ['OpenSlidesApp.core.pdf'])
// show candidate list (if assignment phase is not 'finished') // show candidate list (if assignment phase is not 'finished')
var createCandidateList = function() { var createCandidateList = function() {
if (assignment.phase != 2) { if (assignment.phase != 2) {
var candidates = $filter('orderBy')(assignment.assignment_related_users, 'weight');
var candidatesText = gettextCatalog.getString("Candidates") + ": "; var candidatesText = gettextCatalog.getString("Candidates") + ": ";
var userList = []; var userList = [];
angular.forEach(assignment.assignment_related_users, function(assignmentsRelatedUser) { angular.forEach(candidates, function(assignmentsRelatedUser) {
userList.push({ userList.push({
text: assignmentsRelatedUser.user.get_full_name(), text: assignmentsRelatedUser.user.get_full_name(),
margin: [0, 0, 0, 10], margin: [0, 0, 0, 10],
@ -263,9 +265,10 @@ angular.module('OpenSlidesApp.assignments.pdf', ['OpenSlidesApp.core.pdf'])
}]) }])
.factory('BallotContentProvider', [ .factory('BallotContentProvider', [
'$filter',
'gettextCatalog', 'gettextCatalog',
'PDFLayout', 'PDFLayout',
function(gettextCatalog, PDFLayout) { function($filter, gettextCatalog, PDFLayout) {
var createInstance = function(scope, poll, pollNumber) { var createInstance = function(scope, poll, pollNumber) {
@ -324,15 +327,16 @@ angular.module('OpenSlidesApp.assignments.pdf', ['OpenSlidesApp.core.pdf'])
}; };
var createSelectionField = function() { var createSelectionField = function() {
var candidates = $filter('orderBy')(poll.options, 'weight');
var candidateBallotList = []; var candidateBallotList = [];
if (poll.pollmethod == 'votes') { if (poll.pollmethod == 'votes') {
angular.forEach(poll.options, function(option) { angular.forEach(candidates, function(option) {
var candidate = option.candidate.get_full_name(); var candidate = option.candidate.get_full_name();
candidateBallotList.push(PDFLayout.createBallotEntry(candidate)); candidateBallotList.push(PDFLayout.createBallotEntry(candidate));
}); });
} else { } else {
angular.forEach(poll.options, function(option) { angular.forEach(candidates, function(option) {
var candidate = option.candidate.get_full_name(); var candidate = option.candidate.get_full_name();
candidateBallotList.push(createYNBallotEntry(candidate)); candidateBallotList.push(createYNBallotEntry(candidate));
}); });

View File

@ -440,6 +440,7 @@ angular.module('OpenSlidesApp.assignments.site', [
.controller('AssignmentDetailCtrl', [ .controller('AssignmentDetailCtrl', [
'$scope', '$scope',
'$http', '$http',
'$filter',
'filterFilter', 'filterFilter',
'gettext', 'gettext',
'ngDialog', 'ngDialog',
@ -456,11 +457,10 @@ angular.module('OpenSlidesApp.assignments.site', [
'PdfMakeDocumentProvider', 'PdfMakeDocumentProvider',
'PdfMakeBallotPaperProvider', 'PdfMakeBallotPaperProvider',
'gettextCatalog', 'gettextCatalog',
function($scope, $http, filterFilter, gettext, ngDialog, AssignmentForm, operator, Assignment, User, function($scope, $http, $filter, filterFilter, gettext, ngDialog, AssignmentForm, operator, Assignment,
assignment, phases, Projector, ProjectionDefault, AssignmentContentProvider, BallotContentProvider, User, assignment, phases, Projector, ProjectionDefault, AssignmentContentProvider, BallotContentProvider,
PdfMakeDocumentProvider, PdfMakeBallotPaperProvider, gettextCatalog) { PdfMakeDocumentProvider, PdfMakeBallotPaperProvider, gettextCatalog) {
User.bindAll({}, $scope, 'users'); User.bindAll({}, $scope, 'users');
Assignment.bindOne(assignment.id, $scope, 'assignment');
Assignment.loadRelations(assignment, 'agenda_item'); Assignment.loadRelations(assignment, 'agenda_item');
$scope.$watch(function () { $scope.$watch(function () {
return Projector.lastModified(); return Projector.lastModified();
@ -470,6 +470,13 @@ angular.module('OpenSlidesApp.assignments.site', [
$scope.defaultProjectorId = projectiondefault.projector_id; $scope.defaultProjectorId = projectiondefault.projector_id;
} }
}); });
$scope.$watch(function () {
return Assignment.lastModified(assignment.id);
}, function () {
// setup sorting of candidates
$scope.relatedUsersSorted = $filter('orderBy')(assignment.assignment_related_users, 'weight');
$scope.assignment = Assignment.get(assignment.id);
});
$scope.candidateSelectBox = {}; $scope.candidateSelectBox = {};
$scope.phases = phases; $scope.phases = phases;
$scope.alert = {}; $scope.alert = {};
@ -532,6 +539,18 @@ angular.module('OpenSlidesApp.assignments.site', [
else else
return false; return false;
}; };
// Sort all candidates
$scope.treeOptions = {
dropped: function () {
var sortedCandidates = [];
_.forEach($scope.relatedUsersSorted, function (user) {
sortedCandidates.push(user.id);
});
$http.post('/rest/assignments/assignment/' + $scope.assignment.id + '/sort_related_users/',
{related_users: sortedCandidates}
);
}
};
// update phase // update phase
$scope.updatePhase = function (phase_id) { $scope.updatePhase = function (phase_id) {
assignment.phase = phase_id; assignment.phase = phase_id;
@ -730,11 +749,12 @@ angular.module('OpenSlidesApp.assignments.site', [
.controller('AssignmentPollUpdateCtrl', [ .controller('AssignmentPollUpdateCtrl', [
'$scope', '$scope',
'$filter',
'gettextCatalog', 'gettextCatalog',
'AssignmentPoll', 'AssignmentPoll',
'assignmentpoll', 'assignmentpoll',
'ballot', 'ballot',
function($scope, gettextCatalog, AssignmentPoll, assignmentpoll, ballot) { function($scope, $filter, gettextCatalog, AssignmentPoll, assignmentpoll, ballot) {
// set initial values for form model by create deep copy of assignmentpoll object // set initial values for form model by create deep copy of assignmentpoll object
// so detail view is not updated while editing poll // so detail view is not updated while editing poll
$scope.model = angular.copy(assignmentpoll); $scope.model = angular.copy(assignmentpoll);
@ -743,7 +763,8 @@ angular.module('OpenSlidesApp.assignments.site', [
$scope.alert = {}; $scope.alert = {};
// add dynamic form fields // add dynamic form fields
assignmentpoll.options.forEach(function(option) { var options = $filter('orderBy')(assignmentpoll.options, 'weight');
options.forEach(function(option) {
var defaultValue; var defaultValue;
if (assignmentpoll.pollmethod == 'yna' || assignmentpoll.pollmethod == 'yn') { if (assignmentpoll.pollmethod == 'yna' || assignmentpoll.pollmethod == 'yn') {
if (assignmentpoll.pollmethod == 'yna') { if (assignmentpoll.pollmethod == 'yna') {

View File

@ -82,17 +82,21 @@
<div ng-if="assignment.phase != 2"> <div ng-if="assignment.phase != 2">
<h3 translate>Candidates</h3> <h3 translate>Candidates</h3>
<ul> <div ui-tree="treeOptions" ng-if="assignment.assignment_related_users.length">
<li ng-repeat="related_user in assignment.assignment_related_users"> <ol ui-tree-nodes="" ng-model="relatedUsersSorted">
<a ui-sref="users.user.detail({id: related_user.user_id})">{{ related_user.user.get_full_name() }}</a> <li ui-tree-node ng-repeat="related_user in assignment.assignment_related_users | orderBy:'weight'">
<i ui-tree-handle="" class="fa fa-arrows-v spacer-right" os-perms="assignments.can_manage"></i>
<a class="spacer-right" ui-sref="users.user.detail({id: related_user.user_id})">
{{ related_user.user.get_full_name() }}
</a>
<i ng-if="related_user.elected" class="fa fa-star" title="{{ 'is elected' | translate }}"></i> <i ng-if="related_user.elected" class="fa fa-star" title="{{ 'is elected' | translate }}"></i>
<button os-perms="assignments.can_manage" ng-click="removeCandidate(related_user.user_id)" <a href os-perms="assignments.can_manage" ng-click="removeCandidate(related_user.user_id)" class="btn btn-default btn-xs">
class="btn btn-default btn-xs">
<i class="fa fa-times"></i> <i class="fa fa-times"></i>
</button> </a>
</ul> </ol>
</div>
<div class="form-group"> <div class="form-group spacer-top-lg">
<div uib-alert ng-show="alert.show" ng-class="'alert-' + (alert.type || 'warning')" ng-click="alert={}" close="alert={}"> <div uib-alert ng-show="alert.show" ng-class="'alert-' + (alert.type || 'warning')" ng-click="alert={}" close="alert={}">
{{ alert.msg }} {{ alert.msg }}
</div> </div>
@ -174,7 +178,7 @@
<div ng-if="!poll.has_votes"> <div ng-if="!poll.has_votes">
<strong translate>Candidates</strong> <strong translate>Candidates</strong>
<ul class="list-unstyled"> <ul class="list-unstyled">
<li ng-repeat="option in poll.options"> <li ng-repeat="option in poll.options | orderBy:'weight'">
<a ui-sref="users.user.detail({id: option.candidate.id})"> <a ui-sref="users.user.detail({id: option.candidate.id})">
{{ option.candidate.get_full_name() }} {{ option.candidate.get_full_name() }}
</a> </a>
@ -202,7 +206,7 @@
<th translate ng-hide="method === 'disabled'">Quorum <th translate ng-hide="method === 'disabled'">Quorum
</th> </th>
<!-- candidates (poll options) --> <!-- candidates (poll options) -->
<tr ng-repeat="option in poll.options"> <tr ng-repeat="option in poll.options | orderBy:'weight'">
<!-- candidate name --> <!-- candidate name -->
<td> <td>
<span os-perms="assignments.can_manage"> <span os-perms="assignments.can_manage">

View File

@ -17,7 +17,7 @@
<div ng-if="!showResult"> <div ng-if="!showResult">
<h3 translate>Candidates</h3> <h3 translate>Candidates</h3>
<ul> <ul>
<li ng-repeat="related_user in assignment.assignment_related_users"> <li ng-repeat="related_user in assignment.assignment_related_users | orderBy:'weight'">
{{ related_user.user.get_full_name() }} {{ related_user.user.get_full_name() }}
<i ng-if="related_user.elected" class="fa fa-star" title="{{ 'is elected' | translate }}"></i> <i ng-if="related_user.elected" class="fa fa-star" title="{{ 'is elected' | translate }}"></i>
</ul> </ul>
@ -31,7 +31,7 @@
<th ng-if="poll.has_votes" class="col-sm-6" translate>Votes</th> <th ng-if="poll.has_votes" class="col-sm-6" translate>Votes</th>
<!-- candidates (poll options) --> <!-- candidates (poll options) -->
<tr ng-repeat="option in poll.options"> <tr ng-repeat="option in poll.options | orderBy:'weight'">
<!-- candidate name --> <!-- candidate name -->
<td> <td>

View File

@ -2,6 +2,7 @@ from django.contrib.auth import get_user_model
from django.db import transaction from django.db import transaction
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from openslides.utils.autoupdate import inform_changed_data
from openslides.utils.rest_api import ( from openslides.utils.rest_api import (
DestroyModelMixin, DestroyModelMixin,
GenericViewSet, GenericViewSet,
@ -13,7 +14,7 @@ from openslides.utils.rest_api import (
) )
from .access_permissions import AssignmentAccessPermissions from .access_permissions import AssignmentAccessPermissions
from .models import Assignment, AssignmentPoll from .models import Assignment, AssignmentPoll, AssignmentRelatedUser
from .serializers import AssignmentAllPollSerializer from .serializers import AssignmentAllPollSerializer
@ -40,7 +41,7 @@ class AssignmentViewSet(ModelViewSet):
# Everybody is allowed to see the metadata. # Everybody is allowed to see the metadata.
result = True result = True
elif self.action in ('create', 'partial_update', 'update', 'destroy', elif self.action in ('create', 'partial_update', 'update', 'destroy',
'mark_elected', 'create_poll'): 'mark_elected', 'create_poll', 'sort_related_users'):
result = (self.request.user.has_perm('assignments.can_see') and result = (self.request.user.has_perm('assignments.can_see') and
self.request.user.has_perm('assignments.can_manage')) self.request.user.has_perm('assignments.can_manage'))
elif self.action == 'candidature_self': elif self.action == 'candidature_self':
@ -186,6 +187,48 @@ class AssignmentViewSet(ModelViewSet):
assignment.create_poll() assignment.create_poll()
return Response({'detail': _('Ballot created successfully.')}) return Response({'detail': _('Ballot created successfully.')})
@detail_route(methods=['post'])
def sort_related_users(self, request, pk=None):
"""
Special view endpoint to sort the assignment related users.
Expects a list of IDs of the related users (pk of AssignmentRelatedUser model).
"""
assignment = self.get_object()
# Check data
related_user_ids = request.data.get('related_users')
if not isinstance(related_user_ids, list):
raise ValidationError(
{'detail': _('users has to be a list of IDs.')})
# Get all related users from AssignmentRelatedUser.
related_users = {}
for related_user in AssignmentRelatedUser.objects.filter(assignment__id=assignment.id):
related_users[related_user.pk] = related_user
# Check all given candidates from the request
valid_related_users = []
for related_user_id in related_user_ids:
if not isinstance(related_user_id, int) or related_users.get(related_user_id) is None:
raise ValidationError(
{'detail': _('Invalid data.')})
valid_related_users.append(related_users[related_user_id])
# Sort the related users
weight = 1
with transaction.atomic():
for valid_related_user in valid_related_users:
valid_related_user.weight = weight
valid_related_user.save(skip_autoupdate=True)
weight += 1
# send autoupdate
inform_changed_data(assignment)
# Initiate response.
return Response({'detail': _('Assignment related users successfully sorted.')})
class AssignmentPollViewSet(UpdateModelMixin, DestroyModelMixin, GenericViewSet): class AssignmentPollViewSet(UpdateModelMixin, DestroyModelMixin, GenericViewSet):
""" """