Lunching for groups.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

339 lines
11 KiB

#!/usr/bin/env python
# -*- encoding: utf-8 -*-
"""Voting"""
import datetime
import logging
import operator
from flask import Blueprint
from flask import jsonify
from flask import request
from flask import current_app
from luncho.helpers import ForceJSON
from luncho.helpers import auth
from luncho.server import db
from luncho.server import Group
from luncho.server import Vote
from luncho.server import CastedVote
from luncho.server import Place
from luncho.exceptions import LunchoException
from luncho.exceptions import UserIsNotMemberException
from luncho.exceptions import ElementNotFoundException
LOG = logging.getLogger('luncho.blueprints.voting')
voting = Blueprint('voting', __name__)
# ----------------------------------------------------------------------
# Exceptions
# ----------------------------------------------------------------------
class VoteAlreadyCastException(LunchoException):
"""The user already voted today.
.. sourcecode:: http
HTTP/1.1 406 Not Acceptable
Content-Type: application/json
{ "status": "ERROR", "message": "User already voted today" }
"""
def __init__(self):
super(VoteAlreadyCastException, self).__init__()
self.status = 406
self.message = 'User already voted today'
class InvalidNumberOfPlacesCastedException(LunchoException):
"""The number of places in the vote casted is invalid.
.. sourcecode:: http
HTTP/1.1 406 Not Acceptable
Content-Type: application/json
{ "status": "ERROR",
"message": "The vote must register {places} places" }
"""
def __init__(self, places):
super(InvalidNumberOfPlacesCastedException, self).__init__()
self.status = 406
self.message = 'The vote must register {places} places'.format(
places=places)
class PlaceDoesntBelongToGroupException(LunchoException):
"""The indicated places do not belong to the group.
.. sourcecode:: http
http/1.1 404 Not Found
Content-Type: application/json
{ "status": "ERROR",
"message": "Places are not part of this group",
"places": [<place>, <place>, ...]}
"""
def __init__(self, places):
super(PlaceDoesntBelongToGroupException, self).__init__()
self.status = 404
self.message = 'Places are not part of this group'
self.places = places
def _json(self):
super(PlaceDoesntBelongToGroupException, self)._json()
self.json['places'] = self.places
class PlacesVotedMoreThanOnceException(LunchoException):
"""The indicated places were voted more than once. Only a vote
per place is allowed.
.. sourcecode:: http
HTTP/1.1 409 Conflict
Content-Type: application/json
{ "status": "ERROR",
"message": "Places voted more than once",
"places": [<place>, <place>, ...]}
"""
def __init__(self, places):
super(PlacesVotedMoreThanOnceException, self).__init__()
self.status = 409
self.message = 'Places voted more than once'
self.places = places
return
def _json(self):
super(PlacesVotedMoreThanOnceException, self)._json()
self.json['places'] = list(self.places)
# ----------------------------------------------------------------------
# Voting
# ----------------------------------------------------------------------
@voting.route('<int:group_id>/', methods=['POST'])
@ForceJSON(required=['choices'])
@auth
def cast_vote(group_id):
"""*Authenticated request*
Cast a vote for a group. A user can cast a vote in a single group
per day.
:header Authorization: Access token from '/token/'.
:status 200: Success
:status 400: Request MUST be in JSON format
(:py:class:`RequestMustBeJSONException`)
:status 400: Missing fields
(:py:class:`MissingFieldsException`)
:status 403: User is not member of this group
(:py:class:`UserIsNotMemberException`)
:status 404: User not found (via token)
(:py:class:`UserNotFoundException`)
:status 404: Group not found
(:py:class:`ElementNotFoundException`)
:status 404: Place not found
(:py:class:`ElementNotFoundException`)
:status 404: Place doesn't belong to the group
(:py:class:`PlaceDoesntBelongToGroupException`)
:status 406: User already voted today
(:py:class:`VoteAlreadyCastException`)
:status 406: Number of places vote doesn't match the required
(:py:class:`InvalidNumberOfPlacesCastedException`)
:status 409: Places voted more than once
(:py:class:`PlacesVotedMoreThanOnceException`)
:status 412: Authorization required
(:py:class:`AuthorizationRequiredException`)
"""
# check if the group exists
group = Group.query.get(group_id)
if not group:
raise ElementNotFoundException('Group')
# check if the user belongs to the group
if request.user not in group.users:
LOG.debug('User is not member')
raise UserIsNotMemberException()
choices = request.as_json.get('choices')
# check if the user voted today already, for any group
_already_voted(request.user.username)
# check if the user is trying to vote in the same place twice
_check_duplicates(choices)
# check the number of votes the user casted
_check_place_count(choices, group.places)
# check if the places exist and are part of the group
# (don't vote yet, so we can stop the whole thing if there is anything
# wrong)
_check_places(choices, group.places)
# finally, cast the vote
vote = Vote(request.user, group_id)
LOG.debug('User {user} casted vote {vote}'.format(user=request.user,
vote=vote))
db.session.add(vote)
db.session.commit() # so vote gets an id
for (pos, place_id) in enumerate(request.as_json.get('choices')):
place = CastedVote(vote, pos, place_id)
LOG.debug('\tVoted {place} in {pos} position'.format(place=place,
pos=pos))
db.session.add(place)
db.session.commit()
return jsonify(status='OK')
@voting.route('<int:group_id>/', methods=['GET'])
@auth
def get_vote(group_id):
"""*Authenticated request*
Return the current voting status for the group.
:header Authorization: Access token from '/token/'.
:status 200: Success
.. sourcecode:: http
HTTP/1.1 200 OK
Content-type: application/json
{ "status": "OK",
"closed": <True if all members voted>,
"results": [ {"id": <place id>,
"name": "<place name>",
"points": <points> },
{"id": <place id>,
"name": "<place name>",
"points": <points> },
... ] }
:status 403: User is not member of this group
(:py:class:`UserIsNotMemberException`)
:status 404: User not found (via token)
(:py:class:`UserNotFoundException`)
:status 404: Group not found
(:py:class:`ElementNotFoundException`)
:status 412: Authorization required
(:py:class:`AuthorizationRequiredException`)
"""
# check if the group exists
group = Group.query.get(group_id)
if not group:
raise ElementNotFoundException('Group')
# check if the user belongs to the group
if request.user not in group.users:
LOG.debug('User is not member')
raise UserIsNotMemberException()
# calculate the decrementating value, based on the number of places
max_places = min(current_app.config['PLACES_IN_VOTE'],
len(group.places))
if max_places == 0:
# this means the group have no places at all, so the result will
# *always* be an empty list, closed.
return jsonify(status='OK',
results=[],
closed=True)
decrement = round(1.0 / float(max_places), 1)
LOG.debug('For {places}, the decrement factor is {decrement}'.format(
places=max_places, decrement=decrement))
# get the votes for today
today = datetime.date.today()
group_votes = Vote.query.filter_by(group=group.id,
created_at=today)
points = {}
votes = 0
for vote in group_votes:
votes += 1
# get the casted votes
vote_value = 1.0
for cast in CastedVote.query.filter_by(vote=vote.cast):
if cast.place not in points:
points[cast.place] = 0.0
points[cast.place] += vote_value
vote_value -= decrement
LOG.debug('Unsorted results: {results}'.format(results=points))
# check if the voting is closed. for that, the number of votes must be
# equal to the number of users in the group
closed = False
if votes == len(group.users):
closed = True
# sort the results from most voted to least voted
# (turn the dictionary into a list with place,points values, then sort
# them by points)
result = []
for (place_id, points) in sorted(points.items(),
key=operator.itemgetter(1)):
place = Place.query.get(place_id)
result.append({'id': place.id,
'name': place.name,
'points': points})
return jsonify(status='OK',
closed=closed,
results=result)
# ----------------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------------
def _already_voted(username):
"""Check if the user already voted today."""
today = datetime.date.today()
today_vote = Vote.query.filter_by(user=username,
created_at=today).first()
if today_vote:
LOG.debug('User already voted today')
raise VoteAlreadyCastException()
return
def _check_place_count(choices, group_places):
"""Check if the user voted in the right number of places."""
# maybe the group have less than PLACES_IN_VOTE choices...
max_places = min(current_app.config['PLACES_IN_VOTE'],
len(group_places))
if len(choices) != max_places:
LOG.debug('Max places = {max_places}, voted for {choices}'.format(
max_places=max_places, choices=len(choices)))
raise InvalidNumberOfPlacesCastedException(max_places)
return
def _check_places(choices, group_places):
"""Check if the places the user voted exist and belong to the group."""
for place_id in choices:
place = Place.query.get(place_id)
if not place:
raise ElementNotFoundException('Place')
if place not in group_places:
raise PlaceDoesntBelongToGroupException(place_id)
return
def _check_duplicates(choices):
"""Check if the places the user voted are listed more than once."""
duplicates = set([x for x in choices if choices.count(x) > 1])
if len(duplicates) > 0:
raise PlacesVotedMoreThanOnceException(duplicates)
return