From 20f6f204cfc704b7486cfd9f36a643073fdc57fa Mon Sep 17 00:00:00 2001 From: Alexis Metaireau Date: Fri, 9 Sep 2011 21:32:52 +0200 Subject: [PATCH 01/13] update the README with information about how to contribute --- README.rst | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/README.rst b/README.rst index 8b32bb5e..2cd41ccc 100644 --- a/README.rst +++ b/README.rst @@ -32,3 +32,30 @@ To deploy it, I'm using gunicorn and supervisord:: 3. reload both nginx and supervisord. It should be working ;) Don't forget to set the right permission for your files ! + +How to contribute +================= + +There are different ways to help us, regarding if you are a designer, +a developer or just an user. + +As a developer +-------------- + +The best way to contribute code is to write it and to make a pull request on +github. Please, think about updating and running the tests before asking for +a pull request as it will help us to maintain the code clean and running. + +As a designer / Front-end developer +----------------------------------- + +Feel free to provide us mockups or to involve yourself into the discussions +hapenning on the github issue tracker. All ideas are welcome. Of course, if you +know how to implement them, feel free to fork and make a pull request. + +End-user +-------- + +You just wanted to have a look at the application and found a bug? Please tell +us and go fill a new issue: +https://github.com/ametaireau/budget-manager/issues/new From 4bb96b28dee16e78d68f16f5bf158f1e879a0523 Mon Sep 17 00:00:00 2001 From: Alexis Metaireau Date: Sun, 11 Sep 2011 22:11:36 +0200 Subject: [PATCH 02/13] API first draft: utils. (related to #27) Introduces the "rest" module, with reusable utils for flask applications (will be packaged as a flask extension later on). --- budget/api.py | 64 ++++++++++++++++++++++++++ budget/rest.py | 120 ++++++++++++++++++++++++++++++++++++++++++++++++ budget/run.py | 3 +- budget/utils.py | 11 +++++ 4 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 budget/api.py create mode 100644 budget/rest.py diff --git a/budget/api.py b/budget/api.py new file mode 100644 index 00000000..70864a65 --- /dev/null +++ b/budget/api.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +from flask import * +import werkzeug + +from models import db, Project, Person, Bill +from utils import for_all_methods + +from rest import RESTResource, DefaultHandler, need_auth # FIXME make it an ext + + +api = Blueprint("api", __name__, url_prefix="/api") + +def check_project(*args, **kwargs): + """Check the request for basic authentication for a given project. + + Return the project if the authorization is good, False otherwise + """ + auth = request.authorization + + # project_id should be contained in kwargs and equal to the username + if auth and "project_id" in kwargs and \ + auth.username == kwargs["project_id"]: + project = Project.query.get(auth.username) + if project.password == auth.password: + return project + return False + + +class ProjectHandler(DefaultHandler): + + def get(self, *args, **kwargs): + return "get" + + def delete(self, *args, **kwargs): + return "delete" + +project_resource = RESTResource( + name="project", + route="/project", + app=api, + actions=["add", "update", "delete", "get"], + authentifier=check_project, + handler=ProjectHandler()) + +# projects: add, delete, edit, get +# GET /project/ → get +# PUT /project/ → add & edit +# DELETE /project/ → delete + +# project members: list, add, delete +# GET /project//members → list +# POST /project//members/ → add +# PUT /project//members/ → edit +# DELETE /project//members/ → delete + +# project bills: list, add, delete, edit, get +# GET /project//bills → list +# GET /project//bills/ → get +# DELETE /project//bills/ → delete +# POST /project//bills/ → add + + +# GET, PUT, DELETE: / : Get, update and delete +# GET, POST: / Add & List diff --git a/budget/rest.py b/budget/rest.py new file mode 100644 index 00000000..f1de42f5 --- /dev/null +++ b/budget/rest.py @@ -0,0 +1,120 @@ +class RESTResource(object): + """Represents a REST resource, with the different HTTP verbs""" + _NEED_ID = ["get", "update", "delete"] + _VERBS = {"get": "GET", + "update": "PUT", + "delete": "DELETE", + "list": "GET", + "add": "POST",} + + def __init__(self, name, route, app, actions, handler, authentifier): + """ + :name: + name of the resource. This is being used when registering + the route, for its name and for the name of the id parameter + that will be passed to the views + + :route: + Default route for this resource + + :app: + Application to register the routes onto + + :actions: + Authorized actions. + + :handler: + The handler instance which will handle the requests + + :authentifier: + callable checking the authentication. If specified, all the + methods will be checked against it. + """ + + self._route = route + self._handler = handler + self._name = name + self._identifier = "%s_id" % name + self._authentifier = authentifier + + for action in actions: + self.add_url_rule(app, action) + + def _get_route_for(self, action): + """Return the complete URL for this action. + + Basically: + + - get, update and delete need an id + - add and list does not + """ + route = self._route + + if action in self._NEED_ID: + route += "/<%s>" % self._identifier + + return route + + def add_url_rule(self, app, action): + """Registers a new url to the given application, regarding + the action. + """ + method = getattr(self._handler, action) + + # decorate the view + if self._authentifier: + method = need_auth(self._authentifier, self._name)(method) + + app.add_url_rule( + self._get_route_for(action), + "%s_%s" % (self._name, action), + method, + methods=[self._VERBS.get(action, "GET")]) + + +def need_auth(authentifier, name=None): + """Decorator checking that the authentifier does not returns false in + the current context. + + If the request is authorized, the object returned by the authentifier + is added to the kwargs of the method. + + If not, issue a 403 Forbidden error + + :authentifier: + The callable to check the context onto. + + :name: + **Optional**, name of the argument to put the object into. + If it is not provided, nothing will be added to the kwargs + of the decorated function + """ + def wrapper(func): + def wrapped(*args, **kwargs): + result = authentifier(*args, **kwargs) + if result: + if name: + kwargs[name] = result + return func(*args, **kwargs) + else: + raise werkzeug.exceptions.Forbidden() + return wrapped + return wrapper + + +class DefaultHandler(object): + + def add(self, *args, **kwargs): + pass + + def update(self, *args, **kwargs): + pass + + def delete(self, *args, **kwargs): + pass + + def list(self, *args, **kwargs): + pass + + def get(self, *args, **kwargs): + pass diff --git a/budget/run.py b/budget/run.py index b1fad19e..65c65912 100644 --- a/budget/run.py +++ b/budget/run.py @@ -1,11 +1,12 @@ from web import main, db, mail -import api +from api import api from flask import * app = Flask(__name__) app.config.from_object("default_settings") app.register_blueprint(main) +app.register_blueprint(api) # db db.init_app(app) diff --git a/budget/utils.py b/budget/utils.py index 262ebfef..8d674101 100644 --- a/budget/utils.py +++ b/budget/utils.py @@ -1,4 +1,6 @@ from functools import wraps +import inspect + from flask import redirect, url_for, session, request from werkzeug.routing import HTTPException, RoutingException @@ -34,3 +36,12 @@ class Redirect303(HTTPException, RoutingException): def get_response(self, environ): return redirect(self.new_url, 303) + +def for_all_methods(decorator): + """Apply a decorator to all the methods of a class""" + def decorate(cls): + for name, method in inspect.getmembers(cls, inspect.ismethod): + setattr(cls, name, decorator(method)) + return cls + return decorate + From ef3d761fc70c1c2bf0d45dde6d2703c73715bf6c Mon Sep 17 00:00:00 2001 From: Alexis Metaireau Date: Sun, 11 Sep 2011 23:00:32 +0200 Subject: [PATCH 03/13] Add Handlers for members and bills. --- budget/api.py | 71 ++++++++++++++++++++++++++++++++++++++++++++++---- budget/rest.py | 37 +++++++++++--------------- budget/run.py | 1 + 3 files changed, 82 insertions(+), 27 deletions(-) diff --git a/budget/api.py b/budget/api.py index 70864a65..e768bd98 100644 --- a/budget/api.py +++ b/budget/api.py @@ -5,7 +5,7 @@ import werkzeug from models import db, Project, Person, Bill from utils import for_all_methods -from rest import RESTResource, DefaultHandler, need_auth # FIXME make it an ext +from rest import RESTResource, need_auth # FIXME make it an ext api = Blueprint("api", __name__, url_prefix="/api") @@ -26,22 +26,83 @@ def check_project(*args, **kwargs): return False -class ProjectHandler(DefaultHandler): +class ProjectHandler(object): - def get(self, *args, **kwargs): + def add(self): + pass + + @need_auth(check_project, "project") + def get(self, project): return "get" - def delete(self, *args, **kwargs): + @need_auth(check_project, "project") + def delete(self, project): return "delete" + @need_auth(check_project, "project") + def update(self, project): + return "update" + + +class MemberHandler(object): + + def get(self, project, member_id): + pass + + def list(self, project): + pass + + def add(self, project): + pass + + def update(self, project, member_id): + pass + + def delete(self, project, member_id): + pass + + +class BillHandler(object): + + def get(self, project, member_id): + pass + + def list(self, project): + pass + + def add(self, project): + pass + + def update(self, project, member_id): + pass + + def delete(self, project, member_id): + pass + + project_resource = RESTResource( name="project", route="/project", app=api, actions=["add", "update", "delete", "get"], - authentifier=check_project, handler=ProjectHandler()) +member_resource = RESTResource( + name="member", + inject_name="project", + route="/project//members", + app=api, + handler=MemberHandler(), + authentifier=check_project) + +bill_resource = RESTResource( + name="bill", + inject_name="project", + route="/project//bills", + app=api, + handler=BillHandler(), + authentifier=check_project) + # projects: add, delete, edit, get # GET /project/ → get # PUT /project/ → add & edit diff --git a/budget/rest.py b/budget/rest.py index f1de42f5..5ecee695 100644 --- a/budget/rest.py +++ b/budget/rest.py @@ -7,7 +7,8 @@ class RESTResource(object): "list": "GET", "add": "POST",} - def __init__(self, name, route, app, actions, handler, authentifier): + def __init__(self, name, route, app, handler, authentifier=None, + actions=None, inject_name=None): """ :name: name of the resource. This is being used when registering @@ -21,7 +22,7 @@ class RESTResource(object): Application to register the routes onto :actions: - Authorized actions. + Authorized actions. Optional. None means all. :handler: The handler instance which will handle the requests @@ -30,12 +31,15 @@ class RESTResource(object): callable checking the authentication. If specified, all the methods will be checked against it. """ + if not actions: + actions = self._VERBS.keys() self._route = route self._handler = handler self._name = name self._identifier = "%s_id" % name self._authentifier = authentifier + self._inject_name = inject_name # FIXME for action in actions: self.add_url_rule(app, action) @@ -63,7 +67,8 @@ class RESTResource(object): # decorate the view if self._authentifier: - method = need_auth(self._authentifier, self._name)(method) + method = need_auth(self._authentifier, + self._inject_name or self._name)(method) app.add_url_rule( self._get_route_for(action), @@ -72,7 +77,7 @@ class RESTResource(object): methods=[self._VERBS.get(action, "GET")]) -def need_auth(authentifier, name=None): +def need_auth(authentifier, name=None, remove_attr=True): """Decorator checking that the authentifier does not returns false in the current context. @@ -88,6 +93,10 @@ def need_auth(authentifier, name=None): **Optional**, name of the argument to put the object into. If it is not provided, nothing will be added to the kwargs of the decorated function + + :remove_attr: + Remove or not the `*name*_id` from the kwargs before calling the + function """ def wrapper(func): def wrapped(*args, **kwargs): @@ -95,26 +104,10 @@ def need_auth(authentifier, name=None): if result: if name: kwargs[name] = result + if remove_attr: + del kwargs["%s_id" % name] return func(*args, **kwargs) else: raise werkzeug.exceptions.Forbidden() return wrapped return wrapper - - -class DefaultHandler(object): - - def add(self, *args, **kwargs): - pass - - def update(self, *args, **kwargs): - pass - - def delete(self, *args, **kwargs): - pass - - def list(self, *args, **kwargs): - pass - - def get(self, *args, **kwargs): - pass diff --git a/budget/run.py b/budget/run.py index 65c65912..e1711a70 100644 --- a/budget/run.py +++ b/budget/run.py @@ -8,6 +8,7 @@ app.config.from_object("default_settings") app.register_blueprint(main) app.register_blueprint(api) + # db db.init_app(app) db.app = app From d2e2260e522cf4b7de24dbb2adbb3bfb12ee01f3 Mon Sep 17 00:00:00 2001 From: Alexis Metaireau Date: Tue, 13 Sep 2011 11:27:36 +0200 Subject: [PATCH 04/13] Add a serialization mechanism --- budget/api.py | 2 +- budget/models.py | 7 +++++++ budget/rest.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/budget/api.py b/budget/api.py index e768bd98..c307fdfd 100644 --- a/budget/api.py +++ b/budget/api.py @@ -50,7 +50,7 @@ class MemberHandler(object): pass def list(self, project): - pass + return project.members def add(self, project): pass diff --git a/budget/models.py b/budget/models.py index 8feaccb0..8d68746f 100644 --- a/budget/models.py +++ b/budget/models.py @@ -7,6 +7,10 @@ db = SQLAlchemy() # define models class Project(db.Model): + + _to_serialize = ("id", "name", "password", "contact_email", + "members", "active_members") + id = db.Column(db.String, primary_key=True) name = db.Column(db.UnicodeText) @@ -68,6 +72,9 @@ class Project(db.Model): class Person(db.Model): + + _to_serialize = ("id", "name", "activated") + id = db.Column(db.Integer, primary_key=True) project_id = db.Column(db.Integer, db.ForeignKey("project.id")) bills = db.relationship("Bill", backref="payer") diff --git a/budget/rest.py b/budget/rest.py index 5ecee695..3a911ab7 100644 --- a/budget/rest.py +++ b/budget/rest.py @@ -1,3 +1,5 @@ +import json + class RESTResource(object): """Represents a REST resource, with the different HTTP verbs""" _NEED_ID = ["get", "update", "delete"] @@ -70,6 +72,9 @@ class RESTResource(object): method = need_auth(self._authentifier, self._inject_name or self._name)(method) + # regarding the format, transform the response + method = serialize("json")(method) #FIXME handle headers + app.add_url_rule( self._get_route_for(action), "%s_%s" % (self._name, action), @@ -111,3 +116,27 @@ def need_auth(authentifier, name=None, remove_attr=True): raise werkzeug.exceptions.Forbidden() return wrapped return wrapper + + +# serializers + +def serialize(format): + def wrapper(func): + def wrapped(*args, **kwargs): + return SERIALIZERS[format].encode(func(*args, **kwargs)) + return wrapped + return wrapper + + +class JSONEncoder(json.JSONEncoder): + def default(self, o): + if hasattr(o, "_to_serialize"): + # build up the object + data = {} + for attr in o._to_serialize: + data[attr] = getattr(o, attr) + return data + else: + return json.JSONEncoder.default(self, o) + +SERIALIZERS = {"json": JSONEncoder()} From a60b0c2b48540729df64c71bf82ff1238811e11d Mon Sep 17 00:00:00 2001 From: Alexis Metaireau Date: Tue, 13 Sep 2011 11:52:11 +0200 Subject: [PATCH 05/13] add mimetype support to the rest API utils --- budget/rest.py | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/budget/rest.py b/budget/rest.py index 3a911ab7..a61f02c8 100644 --- a/budget/rest.py +++ b/budget/rest.py @@ -1,4 +1,5 @@ import json +from flask import request class RESTResource(object): """Represents a REST resource, with the different HTTP verbs""" @@ -23,14 +24,14 @@ class RESTResource(object): :app: Application to register the routes onto - :actions: + :actions: Authorized actions. Optional. None means all. :handler: The handler instance which will handle the requests :authentifier: - callable checking the authentication. If specified, all the + callable checking the authentication. If specified, all the methods will be checked against it. """ if not actions: @@ -45,12 +46,12 @@ class RESTResource(object): for action in actions: self.add_url_rule(app, action) - + def _get_route_for(self, action): """Return the complete URL for this action. Basically: - + - get, update and delete need an id - add and list does not """ @@ -58,22 +59,21 @@ class RESTResource(object): if action in self._NEED_ID: route += "/<%s>" % self._identifier - + return route def add_url_rule(self, app, action): - """Registers a new url to the given application, regarding + """Registers a new url to the given application, regarding the action. """ method = getattr(self._handler, action) # decorate the view if self._authentifier: - method = need_auth(self._authentifier, + method = need_auth(self._authentifier, self._inject_name or self._name)(method) - # regarding the format, transform the response - method = serialize("json")(method) #FIXME handle headers + method = serialize(method) app.add_url_rule( self._get_route_for(action), @@ -83,7 +83,7 @@ class RESTResource(object): def need_auth(authentifier, name=None, remove_attr=True): - """Decorator checking that the authentifier does not returns false in + """Decorator checking that the authentifier does not returns false in the current context. If the request is authorized, the object returned by the authentifier @@ -100,7 +100,7 @@ def need_auth(authentifier, name=None, remove_attr=True): of the decorated function :remove_attr: - Remove or not the `*name*_id` from the kwargs before calling the + Remove or not the `*name*_id` from the kwargs before calling the function """ def wrapper(func): @@ -120,12 +120,12 @@ def need_auth(authentifier, name=None, remove_attr=True): # serializers -def serialize(format): - def wrapper(func): - def wrapped(*args, **kwargs): - return SERIALIZERS[format].encode(func(*args, **kwargs)) - return wrapped - return wrapper +def serialize(func): + def wrapped(*args, **kwargs): + mime = request.accept_mimetypes.best_match(SERIALIZERS.keys()) + return SERIALIZERS.get(mime, "text/json")\ + .encode(func(*args, **kwargs)) + return wrapped class JSONEncoder(json.JSONEncoder): @@ -139,4 +139,4 @@ class JSONEncoder(json.JSONEncoder): else: return json.JSONEncoder.default(self, o) -SERIALIZERS = {"json": JSONEncoder()} +SERIALIZERS = {"text/json": JSONEncoder()} From e13ceaf351d4b54dd2bc651d9f4385a8188b7418 Mon Sep 17 00:00:00 2001 From: Alexis Metaireau Date: Tue, 13 Sep 2011 18:15:07 +0200 Subject: [PATCH 06/13] REST API is now able to list stuff \o/ --- budget/api.py | 53 +++++++++++++++++++----------------------------- budget/models.py | 27 +++++++++++++++++++++++- budget/rest.py | 19 ++++++++++++++--- budget/web.py | 13 ++++++++++-- 4 files changed, 74 insertions(+), 38 deletions(-) diff --git a/budget/api.py b/budget/api.py index c307fdfd..c50d6686 100644 --- a/budget/api.py +++ b/budget/api.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- from flask import * -import werkzeug from models import db, Project, Person, Bill from utils import for_all_methods -from rest import RESTResource, need_auth # FIXME make it an ext +from rest import RESTResource, need_auth# FIXME make it an ext +from werkzeug import Response api = Blueprint("api", __name__, url_prefix="/api") @@ -33,7 +33,7 @@ class ProjectHandler(object): @need_auth(check_project, "project") def get(self, project): - return "get" + return project @need_auth(check_project, "project") def delete(self, project): @@ -47,7 +47,10 @@ class ProjectHandler(object): class MemberHandler(object): def get(self, project, member_id): - pass + member = Person.query.get(member_id) + if not member or member.project != project: + return Response('Not Found', status=404) + return member def list(self, project): return project.members @@ -59,25 +62,32 @@ class MemberHandler(object): pass def delete(self, project, member_id): - pass + if project.remove_member(member_id): + return Response('OK', status=200) class BillHandler(object): - def get(self, project, member_id): - pass + def get(self, project, bill_id): + bill = Bill.query.get(project, bill_id) + if not bill: + return Response('Not Found', status=404) + return bill def list(self, project): - pass + return project.get_bills().all() def add(self, project): pass - def update(self, project, member_id): + def update(self, project, bill_id): pass - def delete(self, project, member_id): - pass + def delete(self, project, bill_id): + bill = Bill.query.delete(project, bill_id) + if not bill: + return Response('Not Found', status=404) + return bill project_resource = RESTResource( @@ -102,24 +112,3 @@ bill_resource = RESTResource( app=api, handler=BillHandler(), authentifier=check_project) - -# projects: add, delete, edit, get -# GET /project/ → get -# PUT /project/ → add & edit -# DELETE /project/ → delete - -# project members: list, add, delete -# GET /project//members → list -# POST /project//members/ → add -# PUT /project//members/ → edit -# DELETE /project//members/ → delete - -# project bills: list, add, delete, edit, get -# GET /project//bills → list -# GET /project//bills/ → get -# DELETE /project//bills/ → delete -# POST /project//bills/ → add - - -# GET, PUT, DELETE: / : Get, update and delete -# GET, POST: / Add & List diff --git a/budget/models.py b/budget/models.py index 8d68746f..e56ae4ed 100644 --- a/budget/models.py +++ b/budget/models.py @@ -3,6 +3,8 @@ from collections import defaultdict from datetime import datetime from flaskext.sqlalchemy import SQLAlchemy +from sqlalchemy import orm + db = SQLAlchemy() # define models @@ -103,6 +105,29 @@ billowers = db.Table('billowers', ) class Bill(db.Model): + + class BillQuery(orm.query.Query): + + def get(self, project, id): + try: + return self.join(Person, Project)\ + .filter(Bill.payer_id == Person.id)\ + .filter(Person.project_id == Project.id)\ + .filter(Project.id == project.id)\ + .filter(Bill.id == id).one() + except orm.exc.NoResultFound: + return None + + def delete(self, project, id): + bill = self.get(project, id) + if bill: + db.session.delete(bill) + return bill + + query_class = BillQuery + + _to_serialize = ("id", "payer_id", "owers", "amount", "date", "what") + id = db.Column(db.Integer, primary_key=True) payer_id = db.Column(db.Integer, db.ForeignKey("person.id")) @@ -122,7 +147,6 @@ class Bill(db.Model): return "" % (self.amount, self.payer, ", ".join([o.name for o in self.owers])) - class Archive(db.Model): id = db.Column(db.Integer, primary_key=True) project_id = db.Column(db.Integer, db.ForeignKey("project.id")) @@ -138,3 +162,4 @@ class Archive(db.Model): def __repr__(self): return "" + diff --git a/budget/rest.py b/budget/rest.py index a61f02c8..e698e215 100644 --- a/budget/rest.py +++ b/budget/rest.py @@ -1,5 +1,6 @@ import json from flask import request +import werkzeug class RESTResource(object): """Represents a REST resource, with the different HTTP verbs""" @@ -117,18 +118,28 @@ def need_auth(authentifier, name=None, remove_attr=True): return wrapped return wrapper - # serializers def serialize(func): + """If the object returned by the view is not already a Response, serialize + it using the ACCEPT header and return it. + """ def wrapped(*args, **kwargs): + # get the mimetype mime = request.accept_mimetypes.best_match(SERIALIZERS.keys()) - return SERIALIZERS.get(mime, "text/json")\ - .encode(func(*args, **kwargs)) + data = func(*args, **kwargs) + + if isinstance(data, werkzeug.Response): + return data + else: + # serialize it + return SERIALIZERS.get(mime, "text/json").encode(data) + return wrapped class JSONEncoder(json.JSONEncoder): + """Subclass of the default encoder to support custom objects""" def default(self, o): if hasattr(o, "_to_serialize"): # build up the object @@ -136,6 +147,8 @@ class JSONEncoder(json.JSONEncoder): for attr in o._to_serialize: data[attr] = getattr(o, attr) return data + elif hasattr(o, "isoformat"): + return o.isoformat() else: return json.JSONEncoder.default(self, o) diff --git a/budget/web.py b/budget/web.py index f72a686a..61d67e53 100644 --- a/budget/web.py +++ b/budget/web.py @@ -2,6 +2,7 @@ from collections import defaultdict from flask import * from flaskext.mail import Mail, Message +import werkzeug # local modules from models import db, Project, Person, Bill @@ -239,7 +240,11 @@ def add_bill(): @main.route("//delete/") def delete_bill(bill_id): - bill = Bill.query.get_or_404(bill_id) + # fixme: everyone is able to delete a bill + bill = Bill.query.get(g.project, bill_id) + if not bill: + raise werkzeug.exceptions.NotFound() + db.session.delete(bill) db.session.commit() flash("The bill has been deleted") @@ -249,7 +254,11 @@ def delete_bill(bill_id): @main.route("//edit/", methods=["GET", "POST"]) def edit_bill(bill_id): - bill = Bill.query.get_or_404(bill_id) + # FIXME: Test this bill belongs to this project ! + bill = Bill.query.get(g.project, bill_id) + if not bill: + raise werkzeug.exceptions.NotFound() + form = get_billform_for(g.project, set_default=False) if request.method == 'POST' and form.validate(): form.save(bill) From 8528526f0b6dc8828247ef03f11e4894580f8dd5 Mon Sep 17 00:00:00 2001 From: Alexis Metaireau Date: Tue, 13 Sep 2011 19:24:48 +0200 Subject: [PATCH 07/13] API: set the mimetype on the response --- budget/api.py | 2 ++ budget/rest.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/budget/api.py b/budget/api.py index c50d6686..ddaf65c4 100644 --- a/budget/api.py +++ b/budget/api.py @@ -64,6 +64,8 @@ class MemberHandler(object): def delete(self, project, member_id): if project.remove_member(member_id): return Response('OK', status=200) + else: + return Response('Not Found', status=404) class BillHandler(object): diff --git a/budget/rest.py b/budget/rest.py index e698e215..8ade14bb 100644 --- a/budget/rest.py +++ b/budget/rest.py @@ -133,7 +133,8 @@ def serialize(func): return data else: # serialize it - return SERIALIZERS.get(mime, "text/json").encode(data) + return werkzeug.Response(SERIALIZERS[mime].encode(data), + status=200, mimetype=mime) return wrapped From b0d41291afade8aec86502d07d1d29d000ff1bca Mon Sep 17 00:00:00 2001 From: Alexis Metaireau Date: Tue, 13 Sep 2011 22:58:53 +0200 Subject: [PATCH 08/13] API: Create and Update support --- budget/api.py | 65 +++++++++++++++++++++++++++++++++++++----------- budget/forms.py | 24 +++++++++++++++--- budget/models.py | 19 ++++++++++++-- budget/rest.py | 16 ++++++------ budget/web.py | 13 +++------- 5 files changed, 100 insertions(+), 37 deletions(-) diff --git a/budget/api.py b/budget/api.py index ddaf65c4..3df8ab2b 100644 --- a/budget/api.py +++ b/budget/api.py @@ -2,6 +2,7 @@ from flask import * from models import db, Project, Person, Bill +from forms import ProjectForm from utils import for_all_methods from rest import RESTResource, need_auth# FIXME make it an ext @@ -21,7 +22,7 @@ def check_project(*args, **kwargs): if auth and "project_id" in kwargs and \ auth.username == kwargs["project_id"]: project = Project.query.get(auth.username) - if project.password == auth.password: + if project and project.password == auth.password: return project return False @@ -29,7 +30,13 @@ def check_project(*args, **kwargs): class ProjectHandler(object): def add(self): - pass + form = ProjectForm(csrf_enabled=False) + if form.validate(): + project = form.save(Project()) + db.session.add(project) + db.session.commit() + return 201, project.id + return 400, form.errors @need_auth(check_project, "project") def get(self, project): @@ -37,11 +44,18 @@ class ProjectHandler(object): @need_auth(check_project, "project") def delete(self, project): - return "delete" + db.session.delete(project) + db.session.commit() + return 200, "DELETED" @need_auth(check_project, "project") def update(self, project): - return "update" + form = ProjectForm(csrf_enabled=False) + if form.validate(): + form.save(project) + db.session.commit() + return 200, "UPDATED" + return 400, form.errors class MemberHandler(object): @@ -49,23 +63,34 @@ class MemberHandler(object): def get(self, project, member_id): member = Person.query.get(member_id) if not member or member.project != project: - return Response('Not Found', status=404) + return 404, "Not Found" return member def list(self, project): return project.members def add(self, project): - pass + form = MemberForm(csrf_enabled=False) + if form.validate(): + member = Person() + form.save(project, member) + db.session.commit() + return 200, member.id + return 400, form.errors def update(self, project, member_id): - pass + form = MemberForm(csrf_enabled=False) + if form.validate(): + member = Person.query.get(member_id, project) + form.save(project, member) + db.session.commit() + return 200, member + return 400, form.errors def delete(self, project, member_id): if project.remove_member(member_id): - return Response('OK', status=200) - else: - return Response('Not Found', status=404) + return 200, "OK" + return 404, "Not Found" class BillHandler(object): @@ -73,22 +98,34 @@ class BillHandler(object): def get(self, project, bill_id): bill = Bill.query.get(project, bill_id) if not bill: - return Response('Not Found', status=404) + return 404, "Not Found" return bill def list(self, project): return project.get_bills().all() def add(self, project): - pass + form = BillForm(csrf_enabled=False) + if form.validate(): + bill = Bill() + form.save(bill) + db.session.add(bill) + db.session.commit() + return 200, bill.id + return 400, form.errors def update(self, project, bill_id): - pass + form = BillForm(csrf_enabled=False) + if form.validate(): + form.save(bill) + db.session.commit() + return 200, bill.id + return 400, form.errors def delete(self, project, bill_id): bill = Bill.query.delete(project, bill_id) if not bill: - return Response('Not Found', status=404) + return 404, "Not Found" return bill diff --git a/budget/forms.py b/budget/forms.py index 7ac48cc8..cfd57889 100644 --- a/budget/forms.py +++ b/budget/forms.py @@ -1,6 +1,6 @@ from flaskext.wtf import * from wtforms.widgets import html_params -from models import Project, Person, Bill +from models import Project, Person, Bill, db from datetime import datetime @@ -39,6 +39,15 @@ class ProjectForm(Form): contact_email=self.contact_email.data) return project + def update(self, project): + """Update the project with the information from the form""" + project.name = self.name.data + project.id = self.id.data + project.password = self.password.data + project.contact_email = self.contact_email.data + + return project + class AuthenticationForm(Form): id = TextField("Project identifier", validators=[Required()]) @@ -76,19 +85,26 @@ class BillForm(Form): class MemberForm(Form): - def __init__(self, project, *args, **kwargs): - super(MemberForm, self).__init__(*args, **kwargs) - self.project = project name = TextField("Name", validators=[Required()]) submit = SubmitField("Add a member") + def __init__(self, project, *args, **kwargs): + super(MemberForm, self).__init__(*args, **kwargs) + self.project = project + def validate_name(form, field): if Person.query.filter(Person.name == field.data)\ .filter(Person.project == form.project)\ .filter(Person.activated == True).all(): raise ValidationError("This project already have this member") + def save(self, project, person): + # if the user is already bound to the project, just reactivate him + person.name = self.name.data + person.project = project + + return person class InviteForm(Form): emails = TextAreaField("People to notify") diff --git a/budget/models.py b/budget/models.py index e56ae4ed..5ee7b07e 100644 --- a/budget/models.py +++ b/budget/models.py @@ -1,7 +1,8 @@ from collections import defaultdict from datetime import datetime -from flaskext.sqlalchemy import SQLAlchemy +from flaskext.sqlalchemy import SQLAlchemy, BaseQuery +from flask import g from sqlalchemy import orm @@ -75,6 +76,20 @@ class Project(db.Model): class Person(db.Model): + class PersonQuery(BaseQuery): + def get_by_name(self, name, project): + return Person.query.filter(Person.name == name)\ + .filter(Project.id == project.id).one() + + def get(self, id, project=None): + if not project: + project = g.project + return Person.query.filter(Person.id == id)\ + .filter(Project.id == project.id).one() + + + query_class = PersonQuery + _to_serialize = ("id", "name", "activated") id = db.Column(db.Integer, primary_key=True) @@ -106,7 +121,7 @@ billowers = db.Table('billowers', class Bill(db.Model): - class BillQuery(orm.query.Query): + class BillQuery(BaseQuery): def get(self, project, id): try: diff --git a/budget/rest.py b/budget/rest.py index 8ade14bb..f2372173 100644 --- a/budget/rest.py +++ b/budget/rest.py @@ -114,7 +114,7 @@ def need_auth(authentifier, name=None, remove_attr=True): del kwargs["%s_id" % name] return func(*args, **kwargs) else: - raise werkzeug.exceptions.Forbidden() + return 403, "Forbidden" return wrapped return wrapper @@ -128,13 +128,15 @@ def serialize(func): # get the mimetype mime = request.accept_mimetypes.best_match(SERIALIZERS.keys()) data = func(*args, **kwargs) + serializer = SERIALIZERS[mime] - if isinstance(data, werkzeug.Response): - return data - else: - # serialize it - return werkzeug.Response(SERIALIZERS[mime].encode(data), - status=200, mimetype=mime) + status = 200 + if len(data) == 2: + status, data = data + + # serialize it + return werkzeug.Response(serializer.encode(data), + status=status, mimetype=mime) return wrapped diff --git a/budget/web.py b/budget/web.py index 61d67e53..5667b056 100644 --- a/budget/web.py +++ b/budget/web.py @@ -188,18 +188,11 @@ def add_member(): form = MemberForm(g.project) if request.method == "POST": if form.validate(): - # if the user is already bound to the project, just reactivate him - person = Person.query.filter(Person.name == form.name.data)\ - .filter(Project.id == g.project.id).all() - if person: - person[0].activated = True - db.session.commit() - flash("%s is part of this project again" % person[0].name) - return redirect(url_for(".list_bills")) - - db.session.add(Person(name=form.name.data, project=g.project)) + member = form.save(g.project, Person()) db.session.commit() + flash("%s is had been added" % member.name) return redirect(url_for(".list_bills")) + return render_template("add_member.html", form=form) @main.route("//members//reactivate", methods=["GET",]) From 34ccb3546d2d1cb15e3bc4f5524a2c7630dc2182 Mon Sep 17 00:00:00 2001 From: Arnaud Bos Date: Wed, 14 Sep 2011 02:07:26 +0200 Subject: [PATCH 09/13] Validate authentication form if given identifier is null. Fix #30. --- budget/web.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/budget/web.py b/budget/web.py index 5667b056..3527a75c 100644 --- a/budget/web.py +++ b/budget/web.py @@ -63,7 +63,7 @@ def pull_project(endpoint, values): def authenticate(project_id=None): """Authentication form""" form = AuthenticationForm() - if not form.id.data and request.args['project_id']: + if not form.id.data and request.args.get('project_id'): form.id.data = request.args['project_id'] project_id = form.id.data project = Project.query.get(project_id) @@ -71,7 +71,11 @@ def authenticate(project_id=None): if not project: # But if the user try to connect to an unexisting project, we will # propose him a link to the creation form. - create_project = project_id + if not project_id: + if request.method == "POST": + form.validate() + else: + create_project = project_id else: # if credentials are already in session, redirect From 6212b643ec97e5708a1dc5af39e3f9eb35c01405 Mon Sep 17 00:00:00 2001 From: Arnaud Bos Date: Wed, 14 Sep 2011 02:19:10 +0200 Subject: [PATCH 10/13] Simplified #30 fix. --- budget/web.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/budget/web.py b/budget/web.py index 3527a75c..73761357 100644 --- a/budget/web.py +++ b/budget/web.py @@ -71,9 +71,8 @@ def authenticate(project_id=None): if not project: # But if the user try to connect to an unexisting project, we will # propose him a link to the creation form. - if not project_id: - if request.method == "POST": - form.validate() + if request.method == "POST": + form.validate() else: create_project = project_id From 1b8258a52110cd8eac2f2012e99cf6c16153a839 Mon Sep 17 00:00:00 2001 From: Alexis Metaireau Date: Wed, 14 Sep 2011 14:50:54 +0200 Subject: [PATCH 11/13] Add a test for #23 --- budget/tests.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/budget/tests.py b/budget/tests.py index db37e468..96ed0423 100644 --- a/budget/tests.py +++ b/budget/tests.py @@ -186,6 +186,15 @@ class BudgetTestCase(TestCase): self.assertEqual( len(models.Project.query.get("raclette").active_members), 2) + # adding an user with the same name as another user from a different + # project should not cause any troubles + self.post_project("randomid") + self.login("randomid") + self.app.post("/randomid/members/add", data={'name': 'fred' }) + self.assertEqual( + len(models.Project.query.get("randomid").active_members), 1) + + def test_demo(self): # Test that it is possible to connect automatically by going onto /demo with run.app.test_client() as c: From 5721be1d15ba02e47f98fb9a487248adb297a082 Mon Sep 17 00:00:00 2001 From: Alexis Metaireau Date: Wed, 14 Sep 2011 18:22:26 +0200 Subject: [PATCH 12/13] Fix #26. Footer is now relative --- budget/static/main.css | 9 +++++++-- budget/templates/layout.html | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/budget/static/main.css b/budget/static/main.css index aa2be621..d1bf24b5 100644 --- a/budget/static/main.css +++ b/budget/static/main.css @@ -1,6 +1,10 @@ @import "bootstrap-1.0.0.min.css"; @import url(http://fonts.googleapis.com/css?family=Lobster|Comfortaa); +html body{ + height: 100%; +} + .topbar h3{ margin-left: 75px; } .topbar ul{ padding-left: 75px; } div.topbar ul.secondary-nav { padding-right: 75px; } @@ -80,8 +84,9 @@ div.topbar ul.secondary-nav { padding-right: 75px; } height: 100px; } -.footer{ - position: absolute; +#footer{ + margin-top: 30px; + position: relative; bottom: 0px; width: 100%; text-align: center; diff --git a/budget/templates/layout.html b/budget/templates/layout.html index afdda854..d79c39f4 100644 --- a/budget/templates/layout.html +++ b/budget/templates/layout.html @@ -81,7 +81,7 @@ {% endblock %} {% block footer %} -