From 4730af454b0f902373c7660fd54024b74bf7bb98 Mon Sep 17 00:00:00 2001 From: Julio Biason Date: Sun, 13 Apr 2014 21:05:06 -0300 Subject: [PATCH] voting --- luncho/blueprints/groups.py | 22 +----- luncho/blueprints/voting.py | 148 ++++++++++++++++++++++++++++++++++++ luncho/exceptions.py | 34 +++++++-- luncho/server.py | 46 +++++++++++ tests/vote_tests.py | 57 ++++++++++++++ 5 files changed, 281 insertions(+), 26 deletions(-) create mode 100644 luncho/blueprints/voting.py create mode 100644 tests/vote_tests.py diff --git a/luncho/blueprints/groups.py b/luncho/blueprints/groups.py index 7598d7f..c0e8c9f 100644 --- a/luncho/blueprints/groups.py +++ b/luncho/blueprints/groups.py @@ -17,31 +17,11 @@ from luncho.server import User from luncho.server import Group from luncho.server import Place -from luncho.exceptions import LunchoException from luncho.exceptions import ElementNotFoundException from luncho.exceptions import AccountNotVerifiedException from luncho.exceptions import NewMaintainerDoesNotExistException from luncho.exceptions import UserIsNotAdminException - - -# ---------------------------------------------------------------------- -# Exceptions -# ---------------------------------------------------------------------- - -class UserIsNotMemberException(LunchoException): - """The user is not the admin of the group. - - .. sourcecode:: http - - HTTP/1.1 403 Forbidden - Content-Type: test/json - - { "status": "ERROR", "message": "User is not member of this group" } - """ - def __init__(self): - super(UserIsNotMemberException, self).__init__() - self.status = 403 - self.message = 'User is not member of this group' +from luncho.exceptions import UserIsNotMemberException # ---------------------------------------------------------------------- diff --git a/luncho/blueprints/voting.py b/luncho/blueprints/voting.py new file mode 100644 index 0000000..a62f379 --- /dev/null +++ b/luncho/blueprints/voting.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- + +"""Voting""" + +import datetime +import logging + +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: text/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: text/json + + { "status": "ERROR", + "message": "The vote must register {places} places" } + """ + def __init__(self, places): + super(InvalidNumberOfPlacesCastedException, self).__init__() + self.status = 406 + self.message = 'The must register {places} places'.format(places) + + +class PlaceDoesntBelongToGroupException(LunchoException): + """The indicated places do not belong to the group. + + .. sourcecode:: http + http/1.1 404 Not Found + Content-Type: text/json + + { "status": "ERROR", + "message": "Places are not part of this group", + "places": [, , ...]} + """ + 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 + + +# ---------------------------------------------------------------------- +# Voting +# ---------------------------------------------------------------------- + +@voting.route('/', methods=['POST']) +@ForceJSON(required=['choices']) +@auth +def cast_vote(group_id): + """Cast a vote for a group. A user can cast a vote in a single group + per day. + """ + # 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() + + # check if the user voted today already, for any group + today = datetime.date.today() + today_vote = Vote.query.filter_by(user=request.user.username, + created_at=today).first() + if today_vote: + LOG.debug('User already voted today') + raise VoteAlreadyCastException() + + # check the number of votes the user casted + choices = request.as_json.get('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}', + max_places=max_places, choices=len(choices)) + raise InvalidNumberOfPlacesCastedException() + + # 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) + for place_id in request.as_json.get('choices'): + place = Place.query.get(place_id) + if not place: + raise ElementNotFoundException('Place') + + if not place in group.places: + raise PlaceDoesntBelongToGroupException(place_id) + + # finally, cast the vote + vote = Vote(request.user, group_id) + 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) + db.session.add(place) + + db.session.commit() + + return jsonify(status='OK') diff --git a/luncho/exceptions.py b/luncho/exceptions.py index 37c9d64..bf3a951 100644 --- a/luncho/exceptions.py +++ b/luncho/exceptions.py @@ -10,14 +10,22 @@ class LunchoException(Exception): self.status = 500 self.message = 'Unknown error' self.extra_fields = None + self.json = {} + + def _json(self): + """Fill the json property. If you need to change something, just + extend this function.""" + code = self.__class__.__name__[:-9] + self.json = {'status': 'ERROR', # always an error + 'message': self.message, + 'code': code} + if self.extra_fields: + self.json.update(self.extra_fields) def response(self): """Return a JSON representation of the exception.""" - json = {'status': 'ERROR', - 'message': self.message} - if self.extra_fields: - json.update(self.extra_fields) - response = jsonify(json) + self._json() # encode the error + response = jsonify(self.json) response.status_code = self.status return response @@ -170,3 +178,19 @@ class UserIsNotAdminException(LunchoException): super(UserIsNotAdminException, self).__init__() self.status = 403 self.message = 'User is not admin' + + +class UserIsNotMemberException(LunchoException): + """The user is not the admin of the group. + + .. sourcecode:: http + + HTTP/1.1 403 Forbidden + Content-Type: test/json + + { "status": "ERROR", "message": "User is not member of this group" } + """ + def __init__(self): + super(UserIsNotMemberException, self).__init__() + self.status = 403 + self.message = 'User is not member of this group' diff --git a/luncho/server.py b/luncho/server.py index a86b8ec..a6e74e9 100644 --- a/luncho/server.py +++ b/luncho/server.py @@ -20,6 +20,7 @@ from luncho.exceptions import LunchoException class Settings(object): SQLALCHEMY_DATABASE_URI = 'sqlite://./luncho.db3' DEBUG = True + PLACES_IN_VOTE = 3 # number of places the user can vote log = logging.getLogger('luncho.server') @@ -125,6 +126,49 @@ class Place(db.Model): self.name = name self.owner = owner.username + def __repr__(self): + return 'Place {id}-{name}-{owner}'.format(id=self.id, + name=self.name, + owner=self.owner) + + +class Vote(db.Model): + cast = db.Column(db.Integer, primary_key=True) + user = db.Column(db.String, db.ForeignKey('user.username')) + created_at = db.Column(db.Date, nullable=False) + group = db.Column(db.Integer, db.ForeignKey('group.id')) + + def __init__(self, user, group): + self.user = user.username + self.created_at = datetime.date.today() + self.group = group + return + + def __repr__(self): + values = {'cast': self.cast, + 'user': self.user, + 'created_at': self.created_at, + 'group': self.group} + return 'Vote {cast}-{user}-{created_at}-{group}'.format(**values) + + +class CastedVote(db.Model): + vote = db.Column(db.Integer, db.ForeignKey('vote.cast'), primary_key=True) + order = db.Column(db.Integer, nullable=False, primary_key=True) + place = db.Column(db.Integer, db.ForeignKey('place.id')) + + def __init__(self, vote, order, place): + self.vote = vote.cast + self.order = order + self.place = place + return + + def __repr__(self): + return 'Cast {vote}-{order}-{place}'.format(vote=self.vote, + order=self.order, + place=self.place) + + # ---------------------------------------------------------------------- # Blueprints # ---------------------------------------------------------------------- @@ -134,6 +178,7 @@ from blueprints.groups import groups from blueprints.groups import group_users from blueprints.groups import group_places from blueprints.places import places +from blueprints.voting import voting app.register_blueprint(token, url_prefix='/token/') app.register_blueprint(users, url_prefix='/user/') @@ -141,6 +186,7 @@ app.register_blueprint(groups, url_prefix='/group/') app.register_blueprint(group_users, url_prefix='/group/') app.register_blueprint(group_places, url_prefix='/group/') app.register_blueprint(places, url_prefix='/place/') +app.register_blueprint(voting, url_prefix='/vote/') # ---------------------------------------------------------------------- diff --git a/tests/vote_tests.py b/tests/vote_tests.py new file mode 100644 index 0000000..8f7c936 --- /dev/null +++ b/tests/vote_tests.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- + +import unittest + +from luncho import server + +from base import LunchoTests +from luncho.server import Group +from luncho.server import Place + + +class TestVote(LunchoTests): + def setUp(self): + super(TestVote, self).setUp() + self.default_user() + + def tearDown(self): + super(TestVote, self).tearDown() + + def _group(self): + """Add a default group.""" + group = Group(name='Test group', + owner=self.user) + server.db.session.add(group) + self.user.groups.append(group) + server.db.session.commit() + return group + + def _place(self, user=None): + """Add a default place, linked to the user.""" + if not user: + user = self.user + + place = Place(name='Place', + owner=user) + server.db.session.add(place) + server.db.session.commit() + return place + + def test_cast_vote(self): + """Try to cast a vote.""" + group = self._group() + place = self._place() + group.places.append(place) + self.user.groups.append(group) + server.db.session.commit() + + request = {'choices': [place.id]} + rv = self.post('/vote/{group_id}/'.format(group_id=group.id), + request, + token=self.user.token) + print rv.data + self.assertJsonOk(rv) + +if __name__ == '__main__': + unittest.main()