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 diff --git a/budget/api.py b/budget/api.py new file mode 100644 index 00000000..3df8ab2b --- /dev/null +++ b/budget/api.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +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 +from werkzeug import Response + + +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 and project.password == auth.password: + return project + return False + + +class ProjectHandler(object): + + def add(self): + 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): + return project + + @need_auth(check_project, "project") + def delete(self, project): + db.session.delete(project) + db.session.commit() + return 200, "DELETED" + + @need_auth(check_project, "project") + def update(self, project): + form = ProjectForm(csrf_enabled=False) + if form.validate(): + form.save(project) + db.session.commit() + return 200, "UPDATED" + return 400, form.errors + + +class MemberHandler(object): + + def get(self, project, member_id): + member = Person.query.get(member_id) + if not member or member.project != project: + return 404, "Not Found" + return member + + def list(self, project): + return project.members + + def add(self, project): + 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): + 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 200, "OK" + return 404, "Not Found" + + +class BillHandler(object): + + def get(self, project, bill_id): + bill = Bill.query.get(project, bill_id) + if not bill: + return 404, "Not Found" + return bill + + def list(self, project): + return project.get_bills().all() + + def add(self, project): + 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): + 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 404, "Not Found" + return bill + + +project_resource = RESTResource( + name="project", + route="/project", + app=api, + actions=["add", "update", "delete", "get"], + 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) diff --git a/budget/forms.py b/budget/forms.py index bb191427..33d7b387 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 from jinja2 import Markup from utils import slugify @@ -36,11 +36,33 @@ def get_billform_for(request, project, set_default=True): return form -class ProjectForm(Form): +class EditProjectForm(Form): name = TextField("Project name", validators=[Required()]) - id = TextField("Project identifier", validators=[Required()]) - password = PasswordField("Private code", validators=[Required()]) + password = TextField("Private code", validators=[Required()]) contact_email = TextField("Email", validators=[Required(), Email()]) + submit = SubmitField("Edit the project") + + def save(self): + """Create a new project with the information given by this form. + + Returns the created instance + """ + project = Project(name=self.name.data, id=self.id.data, + password=self.password.data, + 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.password = self.password.data + project.contact_email = self.contact_email.data + + return project + + +class ProjectForm(EditProjectForm): + id = TextField("Project identifier", validators=[Required()]) submit = SubmitField("Create the project") def validate_id(form, field): @@ -56,16 +78,6 @@ class ProjectForm(Form): to remember. """)) - def save(self): - """Create a new project with the information given by this form. - - Returns the created instance - """ - project = Project(name=self.name.data, id=self.id.data, - password=self.password.data, - contact_email=self.contact_email.data) - return project - class AuthenticationForm(Form): id = TextField("Project identifier", validators=[Required()]) @@ -103,19 +115,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 8feaccb0..5ee7b07e 100644 --- a/budget/models.py +++ b/budget/models.py @@ -1,12 +1,19 @@ 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 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 +75,23 @@ 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) project_id = db.Column(db.Integer, db.ForeignKey("project.id")) bills = db.relationship("Bill", backref="payer") @@ -96,6 +120,29 @@ billowers = db.Table('billowers', ) class Bill(db.Model): + + class BillQuery(BaseQuery): + + 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")) @@ -115,7 +162,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")) @@ -131,3 +177,4 @@ class Archive(db.Model): def __repr__(self): return "" + diff --git a/budget/rest.py b/budget/rest.py new file mode 100644 index 00000000..f2372173 --- /dev/null +++ b/budget/rest.py @@ -0,0 +1,158 @@ +import json +from flask import request +import werkzeug + +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, handler, authentifier=None, + actions=None, inject_name=None): + """ + :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. Optional. None means all. + + :handler: + The handler instance which will handle the requests + + :authentifier: + 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) + + 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._inject_name or self._name)(method) + + method = serialize(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, remove_attr=True): + """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 + + :remove_attr: + Remove or not the `*name*_id` from the kwargs before calling the + function + """ + def wrapper(func): + def wrapped(*args, **kwargs): + result = authentifier(*args, **kwargs) + if result: + if name: + kwargs[name] = result + if remove_attr: + del kwargs["%s_id" % name] + return func(*args, **kwargs) + else: + return 403, "Forbidden" + 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()) + data = func(*args, **kwargs) + serializer = SERIALIZERS[mime] + + status = 200 + if len(data) == 2: + status, data = data + + # serialize it + return werkzeug.Response(serializer.encode(data), + status=status, mimetype=mime) + + 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 + data = {} + 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) + +SERIALIZERS = {"text/json": JSONEncoder()} diff --git a/budget/run.py b/budget/run.py index c01dcdba..e1711a70 100644 --- a/budget/run.py +++ b/budget/run.py @@ -1,11 +1,13 @@ 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/static/main.css b/budget/static/main.css index 3b671955..de3d6757 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/create_project.html b/budget/templates/create_project.html index 6593822c..41b8fd07 100644 --- a/budget/templates/create_project.html +++ b/budget/templates/create_project.html @@ -2,10 +2,7 @@ {% block content %}

Create a new project

-
-
- {{ forms.create_project(form) }} -
-
+
+ {{ forms.create_project(form) }} +
{% endblock %} - diff --git a/budget/templates/forms.html b/budget/templates/forms.html index f516e40e..80a0d176 100644 --- a/budget/templates/forms.html +++ b/budget/templates/forms.html @@ -57,6 +57,17 @@ {% endmacro %} +{% macro edit_project(form) %} + + {% include "display_errors.html" %} + {{ form.hidden_tag() }} + {{ input(form.name) }} + {{ input(form.password) }} + {{ input(form.contact_email) }} + {{ submit(form.submit) }} + +{% endmacro %} + {% macro add_bill(form, edit=False) %}
diff --git a/budget/templates/layout.html b/budget/templates/layout.html index c45a1936..2b7c11ef 100644 --- a/budget/templates/layout.html +++ b/budget/templates/layout.html @@ -52,7 +52,7 @@