aboutsummaryrefslogtreecommitdiff
path: root/budget
diff options
context:
space:
mode:
authorArnaud Bos <arnaud.tlse@gmail.com>2011-09-18 23:38:12 +0200
committerArnaud Bos <arnaud.tlse@gmail.com>2011-09-18 23:39:10 +0200
commit681f22f3e47c3fb75fdb1d858b179e945c952596 (patch)
treee83246b13d33f30083488b6913e96e261605f5e3 /budget
parent89e1bbe134bc770d4a3f999a1329bd07522b07cf (diff)
parent20ab40690d74befcd8fc75f24f301759840bf43a (diff)
downloadihatemoney-mirror-681f22f3e47c3fb75fdb1d858b179e945c952596.zip
ihatemoney-mirror-681f22f3e47c3fb75fdb1d858b179e945c952596.tar.gz
ihatemoney-mirror-681f22f3e47c3fb75fdb1d858b179e945c952596.tar.bz2
Merge branch 'master' into auth-forms-usability
Diffstat (limited to 'budget')
-rw-r--r--budget/api.py153
-rw-r--r--budget/forms.py53
-rw-r--r--budget/models.py51
-rw-r--r--budget/rest.py158
-rw-r--r--budget/run.py4
-rw-r--r--budget/static/main.css9
-rw-r--r--budget/templates/create_project.html9
-rw-r--r--budget/templates/forms.html11
-rw-r--r--budget/templates/layout.html4
-rw-r--r--budget/tests.py37
-rw-r--r--budget/utils.py11
-rw-r--r--budget/web.py54
12 files changed, 507 insertions, 47 deletions
diff --git a/budget/api.py b/budget/api.py
new file mode 100644
index 0000000..3df8ab2
--- /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/<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)
diff --git a/budget/forms.py b/budget/forms.py
index bb19142..33d7b38 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 8feaccb..5ee7b07 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 "<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>"
+
diff --git a/budget/rest.py b/budget/rest.py
new file mode 100644
index 0000000..f237217
--- /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 c01dcdb..e1711a7 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 3b67195..de3d675 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 6593822..41b8fd0 100644
--- a/budget/templates/create_project.html
+++ b/budget/templates/create_project.html
@@ -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 %}
-
diff --git a/budget/templates/forms.html b/budget/templates/forms.html
index f516e40..80a0d17 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) %}
<fieldset>
diff --git a/budget/templates/layout.html b/budget/templates/layout.html
index c45a193..2b7c11e 100644
--- a/budget/templates/layout.html
+++ b/budget/templates/layout.html
@@ -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 %}
diff --git a/budget/tests.py b/budget/tests.py
index 65ef40c..4bb8e60 100644
--- a/budget/tests.py
+++ b/budget/tests.py
@@ -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()
diff --git a/budget/utils.py b/budget/utils.py
index f4003eb..88b8580 100644
--- a/budget/utils.py
+++ b/budget/utils.py
@@ -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
+
diff --git a/budget/web.py b/budget/web.py
index 6b3a01c..37c6415 100644
--- a/budget/web.py
+++ b/budget/web.py
@@ -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()