1
0
Fork 0
mirror of https://github.com/YunoHost-Apps/ihatemoney_ynh.git synced 2024-09-03 19:26:15 +02:00

Merge branch 'master' into auth-forms-usability

This commit is contained in:
Arnaud Bos 2011-09-18 23:38:12 +02:00
commit 681f22f3e4
13 changed files with 534 additions and 47 deletions

View file

@ -32,3 +32,30 @@ To deploy it, I'm using gunicorn and supervisord::
3. reload both nginx and supervisord. It should be working ;) 3. reload both nginx and supervisord. It should be working ;)
Don't forget to set the right permission for your files ! 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

153
budget/api.py Normal file
View file

@ -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/<project_id>/members",
app=api,
handler=MemberHandler(),
authentifier=check_project)
bill_resource = RESTResource(
name="bill",
inject_name="project",
route="/project/<project_id>/bills",
app=api,
handler=BillHandler(),
authentifier=check_project)

View file

@ -1,6 +1,6 @@
from flaskext.wtf import * from flaskext.wtf import *
from wtforms.widgets import html_params from wtforms.widgets import html_params
from models import Project, Person, Bill from models import Project, Person, Bill, db
from datetime import datetime from datetime import datetime
from jinja2 import Markup from jinja2 import Markup
from utils import slugify from utils import slugify
@ -36,11 +36,33 @@ def get_billform_for(request, project, set_default=True):
return form return form
class ProjectForm(Form): class EditProjectForm(Form):
name = TextField("Project name", validators=[Required()]) name = TextField("Project name", validators=[Required()])
id = TextField("Project identifier", validators=[Required()]) password = TextField("Private code", validators=[Required()])
password = PasswordField("Private code", validators=[Required()])
contact_email = TextField("Email", validators=[Required(), Email()]) 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") submit = SubmitField("Create the project")
def validate_id(form, field): def validate_id(form, field):
@ -56,16 +78,6 @@ class ProjectForm(Form):
to remember. 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): class AuthenticationForm(Form):
id = TextField("Project identifier", validators=[Required()]) id = TextField("Project identifier", validators=[Required()])
@ -103,19 +115,26 @@ class BillForm(Form):
class MemberForm(Form): class MemberForm(Form):
def __init__(self, project, *args, **kwargs):
super(MemberForm, self).__init__(*args, **kwargs)
self.project = project
name = TextField("Name", validators=[Required()]) name = TextField("Name", validators=[Required()])
submit = SubmitField("Add a member") 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): def validate_name(form, field):
if Person.query.filter(Person.name == field.data)\ if Person.query.filter(Person.name == field.data)\
.filter(Person.project == form.project)\ .filter(Person.project == form.project)\
.filter(Person.activated == True).all(): .filter(Person.activated == True).all():
raise ValidationError("This project already have this member") 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): class InviteForm(Form):
emails = TextAreaField("People to notify") emails = TextAreaField("People to notify")

View file

@ -1,12 +1,19 @@
from collections import defaultdict from collections import defaultdict
from datetime import datetime 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() db = SQLAlchemy()
# define models # define models
class Project(db.Model): class Project(db.Model):
_to_serialize = ("id", "name", "password", "contact_email",
"members", "active_members")
id = db.Column(db.String, primary_key=True) id = db.Column(db.String, primary_key=True)
name = db.Column(db.UnicodeText) name = db.Column(db.UnicodeText)
@ -68,6 +75,23 @@ class Project(db.Model):
class Person(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) id = db.Column(db.Integer, primary_key=True)
project_id = db.Column(db.Integer, db.ForeignKey("project.id")) project_id = db.Column(db.Integer, db.ForeignKey("project.id"))
bills = db.relationship("Bill", backref="payer") bills = db.relationship("Bill", backref="payer")
@ -96,6 +120,29 @@ billowers = db.Table('billowers',
) )
class Bill(db.Model): 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) id = db.Column(db.Integer, primary_key=True)
payer_id = db.Column(db.Integer, db.ForeignKey("person.id")) payer_id = db.Column(db.Integer, db.ForeignKey("person.id"))
@ -115,7 +162,6 @@ class Bill(db.Model):
return "<Bill of %s from %s for %s>" % (self.amount, return "<Bill of %s from %s for %s>" % (self.amount,
self.payer, ", ".join([o.name for o in self.owers])) self.payer, ", ".join([o.name for o in self.owers]))
class Archive(db.Model): class Archive(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
project_id = db.Column(db.Integer, db.ForeignKey("project.id")) project_id = db.Column(db.Integer, db.ForeignKey("project.id"))
@ -131,3 +177,4 @@ class Archive(db.Model):
def __repr__(self): def __repr__(self):
return "<Archive>" return "<Archive>"

158
budget/rest.py Normal file
View file

@ -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()}

View file

@ -1,11 +1,13 @@
from web import main, db, mail from web import main, db, mail
#import api from api import api
from flask import * from flask import *
app = Flask(__name__) app = Flask(__name__)
app.config.from_object("default_settings") app.config.from_object("default_settings")
app.register_blueprint(main) app.register_blueprint(main)
app.register_blueprint(api)
# db # db
db.init_app(app) db.init_app(app)

View file

@ -1,6 +1,10 @@
@import "bootstrap-1.0.0.min.css"; @import "bootstrap-1.0.0.min.css";
@import url(http://fonts.googleapis.com/css?family=Lobster|Comfortaa); @import url(http://fonts.googleapis.com/css?family=Lobster|Comfortaa);
html body{
height: 100%;
}
.topbar h3{ margin-left: 75px; } .topbar h3{ margin-left: 75px; }
.topbar ul{ padding-left: 75px; } .topbar ul{ padding-left: 75px; }
div.topbar ul.secondary-nav { padding-right: 75px; } div.topbar ul.secondary-nav { padding-right: 75px; }
@ -80,8 +84,9 @@ div.topbar ul.secondary-nav { padding-right: 75px; }
height: 100px; height: 100px;
} }
.footer{ #footer{
position: absolute; margin-top: 30px;
position: relative;
bottom: 0px; bottom: 0px;
width: 100%; width: 100%;
text-align: center; text-align: center;

View file

@ -2,10 +2,7 @@
{% block content %} {% block content %}
<h2>Create a new project</h2> <h2>Create a new project</h2>
<div class="uniForm"> <form method="post">
<form method="post" class="container span-24 add-bill">
{{ forms.create_project(form) }} {{ forms.create_project(form) }}
</form> </form>
</div>
{% endblock %} {% endblock %}

View file

@ -57,6 +57,17 @@
{% endmacro %} {% 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) %} {% macro add_bill(form, edit=False) %}
<fieldset> <fieldset>

View file

@ -52,7 +52,7 @@
<li class="menu"> <li class="menu">
<a href="#" class="menu"><strong>{{ g.project.name }}</strong> options</a> <a href="#" class="menu"><strong>{{ g.project.name }}</strong> options</a>
<ul class="menu-dropdown" style="display: none; "> <ul class="menu-dropdown" style="display: none; ">
<li><a href="">Project settings</a></li> <li><a href="{{ url_for(".edit_project") }}">Project settings</a></li>
<li class="divider"></li> <li class="divider"></li>
{% for id, name in session['projects'] %} {% for id, name in session['projects'] %}
{% if id != g.project.id %} {% if id != g.project.id %}
@ -81,7 +81,7 @@
</div> </div>
{% endblock %} {% endblock %}
{% block footer %} {% block footer %}
<div class="footer"> <div id="footer">
<p><a href="https://github.com/ametaireau/budget-manager">This is a free software</a>, you can contribute and improve it!</p> <p><a href="https://github.com/ametaireau/budget-manager">This is a free software</a>, you can contribute and improve it!</p>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -30,9 +30,8 @@ class TestCase(unittest.TestCase):
def login(self, project, password=None, test_client=None): def login(self, project, password=None, test_client=None):
password = password or project password = password or project
test_client = test_client or self.app
return test_client.post('/authenticate', data=dict( return self.app.post('/authenticate', data=dict(
id=project, password=password), follow_redirects=True) id=project, password=password), follow_redirects=True)
def post_project(self, name): def post_project(self, name):
@ -187,6 +186,15 @@ class BudgetTestCase(TestCase):
self.assertEqual( self.assertEqual(
len(models.Project.query.get("raclette").active_members), 2) 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): def test_demo(self):
# Test that it is possible to connect automatically by going onto /demo # Test that it is possible to connect automatically by going onto /demo
with run.app.test_client() as c: with run.app.test_client() as c:
@ -299,6 +307,31 @@ class BudgetTestCase(TestCase):
balance = models.Project.query.get("raclette").get_balance() balance = models.Project.query.get("raclette").get_balance()
self.assertEqual(set(balance.values()), set([19.0, -19.0])) self.assertEqual(set(balance.values()), set([19.0, -19.0]))
def test_edit_project(self):
# A project should be editable
self.post_project("raclette")
new_data = {
'name': 'Super raclette party!',
'contact_email': 'alexis@notmyidea.org',
'password': 'didoudida'
}
resp = self.app.post("/raclette/edit", data=new_data,
follow_redirects=True)
self.assertEqual(resp.status_code, 200)
project = models.Project.query.get("raclette")
for key, value in new_data.items():
self.assertEqual(getattr(project, key), value, key)
# Editing a project with a wrong email address should fail
new_data['contact_email'] = 'wrong_email'
resp = self.app.post("/raclette/edit", data=new_data,
follow_redirects=True)
self.assertIn("Invalid email address", resp.data)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -1,5 +1,7 @@
import re import re
from functools import wraps from functools import wraps
import inspect
from flask import redirect, url_for, session, request from flask import redirect, url_for, session, request
from werkzeug.routing import HTTPException, RoutingException from werkzeug.routing import HTTPException, RoutingException
@ -29,3 +31,12 @@ class Redirect303(HTTPException, RoutingException):
def get_response(self, environ): def get_response(self, environ):
return redirect(self.new_url, 303) 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

View file

@ -2,11 +2,12 @@ from collections import defaultdict
from flask import * from flask import *
from flaskext.mail import Mail, Message from flaskext.mail import Mail, Message
import werkzeug
# local modules # local modules
from models import db, Project, Person, Bill from models import db, Project, Person, Bill
from forms import (get_billform_for, ProjectForm, AuthenticationForm, BillForm, from forms import (get_billform_for, ProjectForm, AuthenticationForm, BillForm,
MemberForm, InviteForm, CreateArchiveForm) MemberForm, InviteForm, CreateArchiveForm, EditProjectForm)
from utils import Redirect303 from utils import Redirect303
""" """
@ -62,7 +63,7 @@ def pull_project(endpoint, values):
def authenticate(project_id=None): def authenticate(project_id=None):
"""Authentication form""" """Authentication form"""
form = AuthenticationForm() 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'] form.id.data = request.args['project_id']
project_id = form.id.data project_id = form.id.data
project = Project.query.get(project_id) project = Project.query.get(project_id)
@ -70,6 +71,9 @@ def authenticate(project_id=None):
if not project: if not project:
# But if the user try to connect to an unexisting project, we will # But if the user try to connect to an unexisting project, we will
# propose him a link to the creation form. # propose him a link to the creation form.
if request.method == "POST":
form.validate()
else:
create_project = project_id create_project = project_id
else: else:
@ -145,6 +149,24 @@ def create_project():
return render_template("create_project.html", form=form) return render_template("create_project.html", form=form)
@main.route("/<project_id>/edit", methods=["GET", "POST"])
def edit_project():
form = EditProjectForm()
if request.method == "POST":
if form.validate():
project = form.update(g.project)
db.session.commit()
session[project.id] = project.password
return redirect(url_for(".list_bills"))
else:
form.name.data = g.project.name
form.password.data = g.project.password
form.contact_email.data = g.project.contact_email
return render_template("edit_project.html", form=form)
@main.route("/exit") @main.route("/exit")
def exit(): def exit():
# delete the session # delete the session
@ -206,18 +228,11 @@ def add_member():
form = MemberForm(g.project) form = MemberForm(g.project)
if request.method == "POST": if request.method == "POST":
if form.validate(): if form.validate():
# if the user is already bound to the project, just reactivate him member = form.save(g.project, Person())
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() db.session.commit()
flash("%s is part of this project again" % person[0].name) flash("%s is had been added" % member.name)
return redirect(url_for(".list_bills")) return redirect(url_for(".list_bills"))
db.session.add(Person(name=form.name.data, project=g.project))
db.session.commit()
return redirect(url_for(".list_bills"))
return render_template("add_member.html", form=form) return render_template("add_member.html", form=form)
@main.route("/<project_id>/members/<member_id>/reactivate", methods=["GET",]) @main.route("/<project_id>/members/<member_id>/reactivate", methods=["GET",])
@ -258,7 +273,11 @@ def add_bill():
@main.route("/<project_id>/delete/<int:bill_id>") @main.route("/<project_id>/delete/<int:bill_id>")
def delete_bill(bill_id): 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.delete(bill)
db.session.commit() db.session.commit()
flash("The bill has been deleted") flash("The bill has been deleted")
@ -268,8 +287,13 @@ def delete_bill(bill_id):
@main.route("/<project_id>/edit/<int:bill_id>", methods=["GET", "POST"]) @main.route("/<project_id>/edit/<int:bill_id>", methods=["GET", "POST"])
def edit_bill(bill_id): 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(request, g.project, set_default=False) form = get_billform_for(request, g.project, set_default=False)
if request.method == 'POST' and form.validate(): if request.method == 'POST' and form.validate():
form.save(bill) form.save(bill)
db.session.commit() db.session.commit()