diff --git a/TODO b/TODO new file mode 100644 index 0000000..59cf626 --- /dev/null +++ b/TODO @@ -0,0 +1,3 @@ +* Find a way to make the couple (name, project) unique. +* use a psql backend rather than sqlite +* put the settings in an ini file ? diff --git a/budget/budget.py b/budget/budget.py index 6a579f1..c4e318c 100644 --- a/budget/budget.py +++ b/budget/budget.py @@ -1,17 +1,14 @@ from datetime import datetime +from functools import wraps from flask import * -from flaskext.wtf import (Form, SelectField, SelectMultipleField, SubmitField, - DateTimeField, Required, TextField) -from flaskext.wtf.html5 import DecimalField +from flaskext.wtf import * from flaskext.sqlalchemy import SQLAlchemy # configuration DEBUG = True SQLALCHEMY_DATABASE_URI = 'sqlite:///budget.db' SQLACHEMY_ECHO = DEBUG -PAYERS = ["Raph", "Joel", "Alexis", "Nick", "Julius"] -PAYER_CHOICES = [(p.lower(), p) for p in PAYERS] SECRET_KEY = "tralala" @@ -22,55 +19,170 @@ app.config.from_envvar('BUDGET_SETTINGS', silent=True) db = SQLAlchemy(app) -# define models -class Bill(db.Model): - __tablename__ = "bills" +# define models +class Project(db.Model): + id = db.Column(db.String, primary_key=True) + + name = db.Column(db.UnicodeText) + password = db.Column(db.String) + contact_email = db.Column(db.String) + members = db.relationship("Person", backref="project") + + def __repr__(self): + return "" % self.name + + +class Person(db.Model): id = db.Column(db.Integer, primary_key=True) - what = db.Column(db.UnicodeText) - payer = db.Column(db.Unicode(200)) + project_id = db.Column(db.Integer, db.ForeignKey("project.id")) + bills = db.relationship("Bill", backref="payer") + + name = db.Column(db.UnicodeText) + status = db.Column(db.Boolean) + + def __repr__(self): + return "" % (self.name, self.project.name) + +# We need to manually define a join table for m2m relations +billowers = db.Table('billowers', + db.Column('bill_id', db.Integer, db.ForeignKey('bill.id')), + db.Column('person_id', db.Integer, db.ForeignKey('person.id')), +) + +class Bill(db.Model): + id = db.Column(db.Integer, primary_key=True) + + payer_id = db.Column(db.Integer, db.ForeignKey("person.id")) + owers = db.relationship(Person, secondary=billowers) + amount = db.Column(db.Float) date = db.Column(db.Date, default=datetime.now) - processed = db.Column(db.Boolean, default=False) + what = db.Column(db.UnicodeText) def pay_each(self): """Compute what each person has to pay""" return round(self.amount / len(self.owers), 2) def __repr__(self): - return "" % (self.amount, + return "" % (self.amount, self.payer, ", ".join([o.name for o in self.owers])) - -class BillOwer(db.Model): - __tablename__ = "billowers" - - bill_id = db.Column(db.Integer, db.ForeignKey("bills.id"), primary_key=True) - name = db.Column(db.Unicode(200), primary_key=True) - - bill = db.relationship(Bill, backref=db.backref('owers', order_by=name)) - db.create_all() + # define forms +class CreationForm(Form): + name = TextField("Project name", validators=[Required()]) + id = TextField("Project identifier", validators=[Required()]) + password = PasswordField("Password", validators=[Required()]) + contact_email = TextField("Email", validators=[Required(), Email()]) + submit = SubmitField("Get in") + + +class AuthenticationForm(Form): + password = TextField("Password", validators=[Required()]) + submit = SubmitField("Get in") + + class BillForm(Form): what = TextField("What?", validators=[Required()]) - payer = SelectField("Payer", validators=[Required()], choices=PAYER_CHOICES) + payer = SelectField("Payer", validators=[Required()]) amount = DecimalField("Amount payed", validators=[Required()]) - payed_for = SelectMultipleField("Who has to pay for this?", validators=[Required()], choices=PAYER_CHOICES) + payed_for = SelectMultipleField("Who has to pay for this?", validators=[Required()]) submit = SubmitField("Add the bill") -@app.route("/") -def list_bills(): - bills = Bill.query.filter(Bill.processed==False).order_by(Bill.id.asc()) - return render_template("list_bills.html", bills=bills) - - -@app.route("/add", methods=["GET", "POST"]) -def add_bill(): +# utils +def get_billform_for(project_id): + """Return an instance of BillForm configured for a particular project.""" form = BillForm() + payers = [(m.id, m.name) for m in Project.query.get("blah").members] + form.payed_for.choices = form.payer.choices = payers + return form + +def requires_auth(f): + """Decorator checking that the user do have access to the given project id. + + If not, redirects to an authentication page, otherwise display the requested + page. + """ + + @wraps(f) + def decorator(*args, **kwargs): + # if a project id is specified in kwargs, check we have access to it + # get the password matching this project id + # pop project_id out of the kwargs + project_id = kwargs.pop('project_id') + project = Project.query.get(project_id) + if not project: + return redirect(url_for("create_project", project_id=kwargs['project_id'])) + + if project.id in session and session[project.id] == project.password: + # add project into kwargs and call the original function + kwargs['project'] = project + return f(*args, **kwargs) + else: + # redirect to authentication page + return redirect(url_for("authenticate", + project_id=project.id, redirect_url=request.url)) + return decorator + + +# views + +@app.route("/") +def home(): + return "this is the homepage" + +@app.route("/create") +def create_project(project_id=None): + form = CreationForm() + + if request.method == "POST": + if form.validate(): + # populate object & redirect + pass + + return render_template("create_project.html", form=form) + +@app.route("//") +@requires_auth +def list_bills(project): + bills = Bill.query.order_by(Bill.id.asc()) + return render_template("list_bills.html", + bills=bills, project=project) + + +@app.route("//authenticate", methods=["GET", "POST"]) +def authenticate(project_id, redirect_url=None): + project = Project.query.get(project_id) + redirect_url = redirect_url or url_for("list_bills", project_id=project_id) + + # if credentials are already in session, redirect + if project_id in session and project.password == session[project_id]: + return redirect(redirect_url) + + # else create the form and process it + form = AuthenticationForm() + if request.method == "POST": + if form.validate(): + if not form.password.data == project.password: + form.errors['password'] = ["The password is not the right one"] + else: + session[project_id] = form.password.data + session.update() + from ipdb import set_trace; set_trace() + return redirect(redirect_url) + + return render_template("authenticate.html", form=form, project=project) + + +@app.route("//add", methods=["GET", "POST"]) +@requires_auth +def add_bill(project): + form = get_billform_for(project.id) if request.method == 'POST': if form.validate(): bill = Bill() @@ -87,12 +199,13 @@ def add_bill(): db.session.commit() flash("The bill have been added") return redirect(url_for('list_bills')) - - return render_template("add_bill.html", form=form) + + return render_template("add_bill.html", form=form, project=project) -@app.route("/compute") -def compute_bills(): +@app.route("//compute") +@requires_auth +def compute_bills(project): """Compute the sum each one have to pay to each other and display it""" balances, should_pay, should_receive = {}, {}, {} @@ -110,11 +223,12 @@ def compute_bills(): for name, void in PAYER_CHOICES: balances[name] = should_receive.get(name, 0) - should_pay.get(name, 0) - return render_template("compute_bills.html", balances=balances) + return render_template("compute_bills.html", balances=balances, project=project) -@app.route("/reset") -def reset_bills(): +@app.route("//reset") +@requires_auth +def reset_bills(project): """Reset the list of bills""" # get all the bills which are not processed bills = Bill.query.filter(Bill.processed == False) @@ -125,14 +239,20 @@ def reset_bills(): return redirect(url_for('list_bills')) -@app.route("/delete/") -def delete_bill(bill_id): - Bill.query.filter(Bill.id == bill_id).delete() +@app.route("//delete/") +@requires_auth +def delete_bill(project, bill_id): + Bill.query.filter(Bill.id == bill_id).delete() BillOwer.query.filter(BillOwer.bill_id == bill_id).delete() db.session.commit() flash("the bill was deleted") return redirect(url_for('list_bills')) +@app.route("/debug/") +def debug(): + from ipdb import set_trace; set_trace() + return render_template("debug.html") + if __name__ == '__main__': app.run(host="0.0.0.0", debug=True) diff --git a/budget/templates/add_bill.html b/budget/templates/add_bill.html index 37ecfa3..9b13b2e 100644 --- a/budget/templates/add_bill.html +++ b/budget/templates/add_bill.html @@ -8,7 +8,7 @@
    {% for error in form.errors %}
  • {{ error }}
  • {% endfor %}
{% endif %} -
+ {{ form.hidden_tag() }}

{{ form.payer.label }}
{{ form.payer }}

diff --git a/budget/templates/authenticate.html b/budget/templates/authenticate.html new file mode 100644 index 0000000..c745e9f --- /dev/null +++ b/budget/templates/authenticate.html @@ -0,0 +1,14 @@ +{% extends "layout.html" %} +{% block content %} +

Login to "{{ project.name }}"

+ +{% for errors in form.errors.values() %} +

{{ ", ".join(errors) }}

+{% endfor %} + + + {{ form.hidden_tag() }} + Password: +

{{ form.submit }}

+
+{% endblock %} diff --git a/budget/templates/create_project.html b/budget/templates/create_project.html new file mode 100644 index 0000000..524a592 --- /dev/null +++ b/budget/templates/create_project.html @@ -0,0 +1,21 @@ +{% extends "layout.html" %} + +{% block content %} +

Create a new project

+ +{% if form.errors %} +

Your form contains errors.

+
    {% for error in form.errors %}
  • {{ error }}
  • {% endfor %}
+{% endif %} + +
+ {{ form.hidden_tag() }} + +

{{ form.name.label }}
{{ form.name }}

+

{{ form.id.label }}
{{ form.id }}

+

{{ form.password.label }}
{{ form.password }}

+

{{ form.contact_email.label }}
{{ form.contact_email }}

+

{{ form.submit }}

+
+{% endblock %} + diff --git a/budget/templates/debug.html b/budget/templates/debug.html new file mode 100644 index 0000000..6f97667 --- /dev/null +++ b/budget/templates/debug.html @@ -0,0 +1 @@ +Yeah diff --git a/budget/templates/layout.html b/budget/templates/layout.html index 50f1884..19c0e59 100644 --- a/budget/templates/layout.html +++ b/budget/templates/layout.html @@ -12,9 +12,7 @@

Account manager ! Manage your shared expenses.

- + {% block top_menu %}{% endblock %}

diff --git a/budget/templates/list_bills.html b/budget/templates/list_bills.html index 09caa48..d0f0c41 100644 --- a/budget/templates/list_bills.html +++ b/budget/templates/list_bills.html @@ -11,16 +11,16 @@ {{ bill.what }} {% for ower in bill.owers %}{{ ower.name }} {% endfor %} {{ bill.amount }} ({{ bill.pay_each() }} each) - delete + delete {% endfor %} -Compute bills +Compute bills

Periodically (probably at the end of each month, you can compute the balance of each people, in order to reset all the debts. You can also let this "as-is" and try to find a good balance, that's up to you

{% else %} -

Nothing to list yet. You probably want to add a bill ?

+

Nothing to list yet. You probably want to add a bill ?

{% endif %} {% endblock %}