From 3b1ab265eb23a862ee9fdaa40c470f363894f3c2 Mon Sep 17 00:00:00 2001 From: FinnStutzenstein Date: Tue, 6 Dec 2016 12:21:29 +0100 Subject: [PATCH] Sort candidates in assignments --- openslides/agenda/views.py | 6 ++- .../migrations/0003_candidate_weight.py | 25 ++++++++++ openslides/assignments/models.py | 34 +++++++++++++- openslides/assignments/serializers.py | 5 +- .../assignments/static/js/assignments/pdf.js | 14 ++++-- .../assignments/static/js/assignments/site.js | 31 ++++++++++-- .../assignments/assignment-detail.html | 28 ++++++----- .../assignments/slide_assignment.html | 4 +- openslides/assignments/views.py | 47 ++++++++++++++++++- 9 files changed, 163 insertions(+), 31 deletions(-) create mode 100644 openslides/assignments/migrations/0003_candidate_weight.py diff --git a/openslides/agenda/views.py b/openslides/agenda/views.py index 7e905e4dd..2b0360d9a 100644 --- a/openslides/agenda/views.py +++ b/openslides/agenda/views.py @@ -3,6 +3,7 @@ from django.db import transaction from django.utils.translation import ugettext as _ from openslides.core.config import config +from openslides.utils.autoupdate import inform_changed_data from openslides.utils.exceptions import OpenSlidesError from openslides.utils.rest_api import ( GenericViewSet, @@ -211,9 +212,12 @@ class ItemViewSet(ListModelMixin, RetrieveModelMixin, UpdateModelMixin, GenericV with transaction.atomic(): for speaker in valid_speakers: speaker.weight = weight - speaker.save() + speaker.save(skip_autoupdate=True) weight += 1 + # send autoupdate + inform_changed_data(item) + # Initiate response. return Response({'detail': _('List of speakers successfully sorted.')}) diff --git a/openslides/assignments/migrations/0003_candidate_weight.py b/openslides/assignments/migrations/0003_candidate_weight.py new file mode 100644 index 000000000..51ef01e21 --- /dev/null +++ b/openslides/assignments/migrations/0003_candidate_weight.py @@ -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), + ), + ] diff --git a/openslides/assignments/models.py b/openslides/assignments/models.py index 40fe85a00..08c506698 100644 --- a/openslides/assignments/models.py +++ b/openslides/assignments/models.py @@ -28,14 +28,31 @@ class AssignmentRelatedUser(RESTModelMixin, models.Model): """ Many to Many table between an assignment and user. """ + assignment = models.ForeignKey( 'Assignment', on_delete=models.CASCADE, related_name='assignment_related_users') + """ + ForeinKey to the assignment. + """ + user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + """ + ForeinKey to the user who is related to the assignment. + """ + 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: default_permissions = () @@ -193,9 +210,14 @@ class Assignment(RESTModelMixin, models.Model): """ 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( user=user, - defaults={'elected': False}) + defaults=defaults) def set_elected(self, user): """ @@ -249,7 +271,13 @@ class Assignment(RESTModelMixin, models.Model): poll = self.polls.create( description=self.poll_description_default, 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 # TODO: Try to do this in a bulk create @@ -360,6 +388,8 @@ class AssignmentOption(RESTModelMixin, BaseOption): candidate = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + weight = models.IntegerField(default=0) + vote_class = AssignmentVote class Meta: diff --git a/openslides/assignments/serializers.py b/openslides/assignments/serializers.py index ea5802c18..730dbc25c 100644 --- a/openslides/assignments/serializers.py +++ b/openslides/assignments/serializers.py @@ -32,7 +32,8 @@ class AssignmentRelatedUserSerializer(ModelSerializer): 'id', 'user', '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): @@ -53,7 +54,7 @@ class AssignmentOptionSerializer(ModelSerializer): class Meta: model = AssignmentOption - fields = ('id', 'candidate', 'is_elected', 'votes', 'poll') + fields = ('id', 'candidate', 'is_elected', 'votes', 'poll', 'weight') def get_is_elected(self, obj): """ diff --git a/openslides/assignments/static/js/assignments/pdf.js b/openslides/assignments/static/js/assignments/pdf.js index c49fa29d3..f73c95e20 100644 --- a/openslides/assignments/static/js/assignments/pdf.js +++ b/openslides/assignments/static/js/assignments/pdf.js @@ -5,9 +5,10 @@ angular.module('OpenSlidesApp.assignments.pdf', ['OpenSlidesApp.core.pdf']) .factory('AssignmentContentProvider', [ + '$filter', 'gettextCatalog', 'PDFLayout', - function(gettextCatalog, PDFLayout) { + function($filter, gettextCatalog, PDFLayout) { 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') var createCandidateList = function() { if (assignment.phase != 2) { + var candidates = $filter('orderBy')(assignment.assignment_related_users, 'weight'); var candidatesText = gettextCatalog.getString("Candidates") + ": "; var userList = []; - angular.forEach(assignment.assignment_related_users, function(assignmentsRelatedUser) { + angular.forEach(candidates, function(assignmentsRelatedUser) { userList.push({ text: assignmentsRelatedUser.user.get_full_name(), margin: [0, 0, 0, 10], @@ -263,9 +265,10 @@ angular.module('OpenSlidesApp.assignments.pdf', ['OpenSlidesApp.core.pdf']) }]) .factory('BallotContentProvider', [ + '$filter', 'gettextCatalog', 'PDFLayout', - function(gettextCatalog, PDFLayout) { + function($filter, gettextCatalog, PDFLayout) { var createInstance = function(scope, poll, pollNumber) { @@ -324,15 +327,16 @@ angular.module('OpenSlidesApp.assignments.pdf', ['OpenSlidesApp.core.pdf']) }; var createSelectionField = function() { + var candidates = $filter('orderBy')(poll.options, 'weight'); var candidateBallotList = []; if (poll.pollmethod == 'votes') { - angular.forEach(poll.options, function(option) { + angular.forEach(candidates, function(option) { var candidate = option.candidate.get_full_name(); candidateBallotList.push(PDFLayout.createBallotEntry(candidate)); }); } else { - angular.forEach(poll.options, function(option) { + angular.forEach(candidates, function(option) { var candidate = option.candidate.get_full_name(); candidateBallotList.push(createYNBallotEntry(candidate)); }); diff --git a/openslides/assignments/static/js/assignments/site.js b/openslides/assignments/static/js/assignments/site.js index 160db603d..7b1618ddb 100644 --- a/openslides/assignments/static/js/assignments/site.js +++ b/openslides/assignments/static/js/assignments/site.js @@ -440,6 +440,7 @@ angular.module('OpenSlidesApp.assignments.site', [ .controller('AssignmentDetailCtrl', [ '$scope', '$http', + '$filter', 'filterFilter', 'gettext', 'ngDialog', @@ -456,11 +457,10 @@ angular.module('OpenSlidesApp.assignments.site', [ 'PdfMakeDocumentProvider', 'PdfMakeBallotPaperProvider', 'gettextCatalog', - function($scope, $http, filterFilter, gettext, ngDialog, AssignmentForm, operator, Assignment, User, - assignment, phases, Projector, ProjectionDefault, AssignmentContentProvider, BallotContentProvider, + function($scope, $http, $filter, filterFilter, gettext, ngDialog, AssignmentForm, operator, Assignment, + User, assignment, phases, Projector, ProjectionDefault, AssignmentContentProvider, BallotContentProvider, PdfMakeDocumentProvider, PdfMakeBallotPaperProvider, gettextCatalog) { User.bindAll({}, $scope, 'users'); - Assignment.bindOne(assignment.id, $scope, 'assignment'); Assignment.loadRelations(assignment, 'agenda_item'); $scope.$watch(function () { return Projector.lastModified(); @@ -470,6 +470,13 @@ angular.module('OpenSlidesApp.assignments.site', [ $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.phases = phases; $scope.alert = {}; @@ -532,6 +539,18 @@ angular.module('OpenSlidesApp.assignments.site', [ else 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 $scope.updatePhase = function (phase_id) { assignment.phase = phase_id; @@ -730,11 +749,12 @@ angular.module('OpenSlidesApp.assignments.site', [ .controller('AssignmentPollUpdateCtrl', [ '$scope', + '$filter', 'gettextCatalog', 'AssignmentPoll', 'assignmentpoll', '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 // so detail view is not updated while editing poll $scope.model = angular.copy(assignmentpoll); @@ -743,7 +763,8 @@ angular.module('OpenSlidesApp.assignments.site', [ $scope.alert = {}; // add dynamic form fields - assignmentpoll.options.forEach(function(option) { + var options = $filter('orderBy')(assignmentpoll.options, 'weight'); + options.forEach(function(option) { var defaultValue; if (assignmentpoll.pollmethod == 'yna' || assignmentpoll.pollmethod == 'yn') { if (assignmentpoll.pollmethod == 'yna') { diff --git a/openslides/assignments/static/templates/assignments/assignment-detail.html b/openslides/assignments/static/templates/assignments/assignment-detail.html index 900e1e4a5..a6036b330 100644 --- a/openslides/assignments/static/templates/assignments/assignment-detail.html +++ b/openslides/assignments/static/templates/assignments/assignment-detail.html @@ -82,17 +82,21 @@

Candidates

- +
+
    +
  1. + + + {{ related_user.user.get_full_name() }} + + + + + +
+
-
+
{{ alert.msg }}
@@ -174,7 +178,7 @@
Candidates
    -
  • +
  • {{ option.candidate.get_full_name() }} @@ -202,7 +206,7 @@ Quorum - + diff --git a/openslides/assignments/static/templates/assignments/slide_assignment.html b/openslides/assignments/static/templates/assignments/slide_assignment.html index 877d7f6aa..c44c914cd 100644 --- a/openslides/assignments/static/templates/assignments/slide_assignment.html +++ b/openslides/assignments/static/templates/assignments/slide_assignment.html @@ -17,7 +17,7 @@

    Candidates

      -
    • +
    • {{ related_user.user.get_full_name() }}
    @@ -31,7 +31,7 @@ Votes - + diff --git a/openslides/assignments/views.py b/openslides/assignments/views.py index d265832c4..0af667017 100644 --- a/openslides/assignments/views.py +++ b/openslides/assignments/views.py @@ -2,6 +2,7 @@ from django.contrib.auth import get_user_model from django.db import transaction from django.utils.translation import ugettext as _ +from openslides.utils.autoupdate import inform_changed_data from openslides.utils.rest_api import ( DestroyModelMixin, GenericViewSet, @@ -13,7 +14,7 @@ from openslides.utils.rest_api import ( ) from .access_permissions import AssignmentAccessPermissions -from .models import Assignment, AssignmentPoll +from .models import Assignment, AssignmentPoll, AssignmentRelatedUser from .serializers import AssignmentAllPollSerializer @@ -40,7 +41,7 @@ class AssignmentViewSet(ModelViewSet): # Everybody is allowed to see the metadata. result = True 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 self.request.user.has_perm('assignments.can_manage')) elif self.action == 'candidature_self': @@ -186,6 +187,48 @@ class AssignmentViewSet(ModelViewSet): assignment.create_poll() 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): """