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 ;)
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 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")

View file

@ -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 "<Bill of %s from %s for %s>" % (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 "<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
#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)

View file

@ -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;

View file

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

View file

@ -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) %}
<fieldset>

View file

@ -52,7 +52,7 @@
<li class="menu">
<a href="#" class="menu"><strong>{{ g.project.name }}</strong> options</a>
<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>
{% for id, name in session['projects'] %}
{% if id != g.project.id %}
@ -81,7 +81,7 @@
</div>
{% endblock %}
{% 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>
</div>
{% endblock %}

View file

@ -30,9 +30,8 @@ class TestCase(unittest.TestCase):
def login(self, project, password=None, test_client=None):
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)
def post_project(self, name):
@ -187,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:
@ -299,6 +307,31 @@ class BudgetTestCase(TestCase):
balance = models.Project.query.get("raclette").get_balance()
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__":
unittest.main()

View file

@ -1,5 +1,7 @@
import re
from functools import wraps
import inspect
from flask import redirect, url_for, session, request
from werkzeug.routing import HTTPException, RoutingException
@ -29,3 +31,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

View file

@ -2,11 +2,12 @@ from collections import defaultdict
from flask import *
from flaskext.mail import Mail, Message
import werkzeug
# local modules
from models import db, Project, Person, Bill
from forms import (get_billform_for, ProjectForm, AuthenticationForm, BillForm,
MemberForm, InviteForm, CreateArchiveForm)
MemberForm, InviteForm, CreateArchiveForm, EditProjectForm)
from utils import Redirect303
"""
@ -62,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)
@ -70,7 +71,10 @@ 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 request.method == "POST":
form.validate()
else:
create_project = project_id
else:
# if credentials are already in session, redirect
@ -145,6 +149,24 @@ def create_project():
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")
def exit():
# delete the session
@ -206,18 +228,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("/<project_id>/members/<member_id>/reactivate", methods=["GET",])
@ -258,7 +273,11 @@ def add_bill():
@main.route("/<project_id>/delete/<int: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.commit()
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"])
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)
if request.method == 'POST' and form.validate():
form.save(bill)
db.session.commit()