diff --git a/budget/api.py b/budget/api.py index 4ca04f2..ec664b6 100644 --- a/budget/api.py +++ b/budget/api.py @@ -1,13 +1,10 @@ # -*- coding: utf-8 -*- -from flask import * +from flask import Blueprint, request +from flask_rest import RESTResource, need_auth from models import db, Project, Person, Bill -from forms import (ProjectForm, EditProjectForm, MemberForm, BillForm, +from forms import (ProjectForm, EditProjectForm, MemberForm, get_billform_for) -from utils import for_all_methods - -from flask_rest import RESTResource, need_auth -from werkzeug import Response api = Blueprint("api", __name__, url_prefix="/api") diff --git a/budget/forms.py b/budget/forms.py index dccf5fa..ac2c1e2 100644 --- a/budget/forms.py +++ b/budget/forms.py @@ -1,9 +1,11 @@ -from flaskext.wtf import * +from flaskext.wtf import DateField, DecimalField, Email, Form, PasswordField, \ + Required, SelectField, SelectMultipleField, SubmitField, TextAreaField, \ + TextField, ValidationError from flaskext.babel import lazy_gettext as _ from flask import request from wtforms.widgets import html_params -from models import Project, Person, Bill, db +from models import Project, Person from datetime import datetime from jinja2 import Markup from utils import slugify @@ -18,14 +20,16 @@ def select_multi_checkbox(field, ul_class='', **kwargs): js_function = u'toggle();' options = dict(kwargs, id=choice_id, onclick=js_function) label = _("Select All/None") - html.append(u'
  • ' % (choice_id, ' ' % html_params(**options), label)) + html.append(u'
  • ' + % (choice_id, ' ' % html_params(**options), label)) for value, label, checked in field.iter_choices(): choice_id = u'%s-%s' % (field_id, value) options = dict(kwargs, name=field.name, value=value, id=choice_id) if checked: options['checked'] = 'checked' - html.append(u'
  • ' % (choice_id, ' ' % html_params(**options), label)) + html.append(u'
  • ' + % (choice_id, ' ' % html_params(**options), label)) html.append(u'') return u''.join(html) @@ -33,18 +37,20 @@ def select_multi_checkbox(field, ul_class='', **kwargs): def get_billform_for(project, set_default=True, **kwargs): """Return an instance of BillForm configured for a particular project. - :set_default: if set to True, on GET methods (usually when we want to + :set_default: if set to True, on GET methods (usually when we want to display the default form, it will call set_default on it. - + """ form = BillForm(**kwargs) - form.payed_for.choices = form.payer.choices = [(m.id, m.name) for m in project.active_members] + form.payed_for.choices = form.payer.choices = [(m.id, m.name) + for m in project.active_members] form.payed_for.default = [m.id for m in project.active_members] if set_default and request.method == "GET": form.set_default() return form + class CommaDecimalField(DecimalField): """A class to deal with comma in Decimal Field""" def process_formdata(self, value): @@ -63,8 +69,8 @@ class EditProjectForm(Form): Returns the created instance """ - project = Project(name=self.name.data, id=self.id.data, - password=self.password.data, + project = Project(name=self.name.data, id=self.id.data, + password=self.password.data, contact_email=self.contact_email.data) return project @@ -85,7 +91,12 @@ class ProjectForm(EditProjectForm): def validate_id(form, field): form.id.data = slugify(field.data) if Project.query.get(form.id.data): - raise ValidationError(Markup(_("The project identifier is used to log in and for the URL of the project. We tried to generate an identifier for you but a project with this identifier already exists. Please create a new identifier you will be able to remember."))) + raise ValidationError(Markup(_("The project identifier is used " + "to log in and for the URL of the project. " + "We tried to generate an identifier for you but a project " + "with this identifier already exists. " + "Please create a new identifier " + "that you will be able to remember."))) class AuthenticationForm(Form): @@ -108,17 +119,18 @@ class BillForm(Form): what = TextField(_("What?"), validators=[Required()]) payer = SelectField(_("Payer"), validators=[Required()], coerce=int) amount = CommaDecimalField(_("Amount paid"), validators=[Required()]) - payed_for = SelectMultipleField(_("For whom?"), + payed_for = SelectMultipleField(_("For whom?"), validators=[Required()], widget=select_multi_checkbox, coerce=int) submit = SubmitField(_("Submit")) submit2 = SubmitField(_("Submit and add a new one")) def save(self, bill, project): - bill.payer_id=self.payer.data - bill.amount=self.amount.data - bill.what=self.what.data - bill.date=self.date.data - bill.owers = [Person.query.get(ower, project) for ower in self.payed_for.data] + bill.payer_id = self.payer.data + bill.amount = self.amount.data + bill.what = self.what.data + bill.date = self.date.data + bill.owers = [Person.query.get(ower, project) + for ower in self.payed_for.data] return bill @@ -141,7 +153,8 @@ class BillForm(Form): class MemberForm(Form): - name = TextField(_("Name"), validators=[Required()], default=_("Type user name here")) + name = TextField(_("Name"), validators=[Required()], + default=_("Type user name here")) submit = SubmitField(_("Add")) def __init__(self, project, *args, **kwargs): @@ -163,6 +176,7 @@ class MemberForm(Form): return person + class InviteForm(Form): emails = TextAreaField(_("People to notify")) submit = SubmitField(_("Send invites")) @@ -171,11 +185,11 @@ class InviteForm(Form): validator = Email() for email in [email.strip() for email in form.emails.data.split(",")]: if not validator.regex.match(email): - raise ValidationError(_("The email %(email)s is not valid", + raise ValidationError(_("The email %(email)s is not valid", email=email)) class CreateArchiveForm(Form): - start_date = DateField(_("Start date"), validators=[Required(),]) - end_date = DateField(_("End date"), validators=[Required(),]) + start_date = DateField(_("Start date"), validators=[Required(), ]) + end_date = DateField(_("End date"), validators=[Required(), ]) name = TextField(_("Name for this archive (optional)")) diff --git a/budget/models.py b/budget/models.py index 3dcd709..5783703 100644 --- a/budget/models.py +++ b/budget/models.py @@ -8,10 +8,13 @@ from sqlalchemy import orm db = SQLAlchemy() + # define models + + class Project(db.Model): - _to_serialize = ("id", "name", "password", "contact_email", + _to_serialize = ("id", "name", "password", "contact_email", "members", "active_members", "balance") id = db.Column(db.String, primary_key=True) @@ -28,25 +31,27 @@ class Project(db.Model): @property def balance(self): - balances, should_pay, should_receive = defaultdict(int), defaultdict(int), defaultdict(int) + balances, should_pay, should_receive = (defaultdict(int) + for time in (1, 2, 3)) # for each person for person in self.members: # get the list of bills he has to pay bills = Bill.query.filter(Bill.owers.contains(person)) for bill in bills.all(): - if person != bill.payer: + if person != bill.payer: should_pay[person] += bill.pay_each() should_receive[bill.payer] += bill.pay_each() for person in self.members: - balances[person.id] = round(should_receive[person] - should_pay[person], 2) + balance = should_receive[person] - should_pay[person] + balances[person.id] = round(balance, 2) return balances def has_bills(self): """return if the project do have bills or not""" - return self.get_bills().count() != 0 + return self.get_bills().count() > 0 def get_bills(self): """Return the list of bills related to this project""" @@ -95,7 +100,6 @@ class Person(db.Model): return Person.query.filter(Person.id == id)\ .filter(Project.id == project.id).one() - query_class = PersonQuery _to_serialize = ("id", "name", "activated") @@ -126,6 +130,7 @@ billowers = db.Table('billowers', db.Column('person_id', db.Integer, db.ForeignKey('person.id')), ) + class Bill(db.Model): class BillQuery(BaseQuery): @@ -169,6 +174,7 @@ 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.String, db.ForeignKey("project.id")) diff --git a/budget/run.py b/budget/run.py index 3d57329..9065a34 100644 --- a/budget/run.py +++ b/budget/run.py @@ -1,9 +1,9 @@ +from flask import Flask, g, request, session +from flaskext.babel import Babel + from web import main, db, mail from api import api -import os -from flask import * -from flaskext.babel import Babel app = Flask(__name__) app.config.from_object("default_settings") diff --git a/budget/utils.py b/budget/utils.py index 52fd151..60337fb 100644 --- a/budget/utils.py +++ b/budget/utils.py @@ -1,10 +1,10 @@ import re -from functools import wraps import inspect -from flask import redirect, url_for, session, request +from flask import redirect from werkzeug.routing import HTTPException, RoutingException + def slugify(value): """Normalizes string, converts to lowercase, removes non-alpha characters, and converts spaces to hyphens. @@ -32,11 +32,3 @@ 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 b8ae71d..6097d80 100644 --- a/budget/web.py +++ b/budget/web.py @@ -1,16 +1,3 @@ -from collections import defaultdict - -from flask import * -from flaskext.mail import Mail, Message -from flaskext.babel import get_locale, gettext as _ -from smtplib import SMTPRecipientsRefused -import werkzeug - -# local modules -from models import db, Project, Person, Bill -from forms import * -from utils import Redirect303 - """ The blueprint for the web interface. @@ -22,9 +9,24 @@ some shortcuts to make your life better when coding (see `pull_project` and `add_project_id` for a quick overview) """ +from flask import Blueprint, current_app, flash, g, redirect, \ + render_template, request, session, url_for +from flaskext.mail import Mail, Message +from flaskext.babel import get_locale, gettext as _ +from smtplib import SMTPRecipientsRefused +import werkzeug + +# local modules +from models import db, Project, Person, Bill +from forms import AuthenticationForm, CreateArchiveForm, EditProjectForm, \ + InviteForm, MemberForm, PasswordReminder, ProjectForm, get_billform_for +from utils import Redirect303 + + main = Blueprint("main", __name__) mail = Mail() + @main.url_defaults def add_project_id(endpoint, values): """Add the project id to the url calls if it is expected. @@ -36,6 +38,7 @@ def add_project_id(endpoint, values): if current_app.url_map.is_endpoint_expecting(endpoint, 'project_id'): values['project_id'] = g.project.id + @main.url_value_preprocessor def pull_project(endpoint, values): """When a request contains a project_id value, transform it directly @@ -51,7 +54,8 @@ def pull_project(endpoint, values): if project_id: project = Project.query.get(project_id) if not project: - raise Redirect303(url_for(".create_project", project_id=project_id)) + raise Redirect303(url_for(".create_project", + project_id=project_id)) if project.id in session and session[project.id] == project.password: # add project into kwargs and call the original function g.project = project @@ -60,6 +64,7 @@ def pull_project(endpoint, values): raise Redirect303( url_for(".authenticate", project_id=project_id)) + @main.route("/authenticate", methods=["GET", "POST"]) def authenticate(project_id=None): """Authentication form""" @@ -68,7 +73,7 @@ def authenticate(project_id=None): form.id.data = request.args['project_id'] project_id = form.id.data project = Project.query.get(project_id) - create_project = False # We don't want to create the project by default + create_project = False # We don't want to create the project by default if not project: # But if the user try to connect to an unexisting project, we will # propose him a link to the creation form. @@ -87,7 +92,8 @@ def authenticate(project_id=None): if request.method == "POST": if form.validate(): if not form.password.data == project.password: - form.errors['password'] = [_("This private code is not the right one")] + msg = _("This private code is not the right one") + form.errors['password'] = [msg] else: # maintain a list of visited projects if "projects" not in session: @@ -102,6 +108,7 @@ def authenticate(project_id=None): return render_template("authenticate.html", form=form, create_project=create_project) + @main.route("/") def home(): project_form = ProjectForm() @@ -109,6 +116,7 @@ def home(): return render_template("home.html", project_form=project_form, auth_form=auth_form, session=session) + @main.route("/create", methods=["GET", "POST"]) def create_project(): form = ProjectForm() @@ -117,9 +125,10 @@ def create_project(): if request.method == "POST": # At first, we don't want the user to bother with the identifier - # so it will automatically be missing because not displayed into the form - # Thus we fill it with the same value as the filled name, the validation will - # take care of the slug + # so it will automatically be missing because not displayed into + # the form + # Thus we fill it with the same value as the filled name, + # the validation will take care of the slug if not form.id.data: form.id.data = form.name.data if form.validate(): @@ -135,10 +144,11 @@ def create_project(): # send reminder email g.project = project - message_title = _("You have just created '%(project)s' to share your expenses", - project=g.project.name) + message_title = _("You have just created '%(project)s' " + "to share your expenses", project=g.project.name) - message_body = render_template("reminder_mail.%s" % get_locale().language) + message_body = render_template("reminder_mail.%s" % + get_locale().language) msg = Message(message_title, body=message_body, @@ -158,6 +168,7 @@ def create_project(): return render_template("create_project.html", form=form) + @main.route("/password-reminder", methods=["GET", "POST"]) def remind_password(): form = PasswordReminder() @@ -167,9 +178,9 @@ def remind_password(): project = Project.query.get(form.id.data) # send the password reminder + password_reminder = "password_reminder.%s" % get_locale().language mail.send(Message("password recovery", - body=render_template("password_reminder.%s" % get_locale().language, - project=project), + body=render_template(password_reminder, project=project), recipients=[project.contact_email])) flash(_("a mail has been sent to you with the password")) @@ -193,18 +204,21 @@ def edit_project(): return render_template("edit_project.html", form=form) + @main.route("//delete", methods=["POST"]) def remove_project(): g.project.remove_project() return redirect(url_for(".home")) + @main.route("/exit") def exit(): # delete the session session.clear() return redirect(url_for(".home")) + @main.route("/demo") def demo(): """ @@ -222,6 +236,7 @@ def demo(): session[project.id] = project.password return redirect(url_for(".list_bills", project_id=project.id)) + @main.route("//invite", methods=["GET", "POST"]) def invite(): """Send invitations for this particular project""" @@ -232,10 +247,11 @@ def invite(): if form.validate(): # send the email - message_body = render_template("invitation_mail.%s" % get_locale().language) + message_body = render_template("invitation_mail.%s" % + get_locale().language) - message_title = _("You have been invited to share your expenses for %(project)s", - project=g.project.name) + message_title = _("You have been invited to share your " + "expenses for %(project)s", project=g.project.name) msg = Message(message_title, body=message_body, recipients=[email.strip() @@ -246,9 +262,10 @@ def invite(): return render_template("send_invites.html", form=form) + @main.route("//") def list_bills(): - bill_form=get_billform_for(g.project) + bill_form = get_billform_for(g.project) # set the last selected payer as default choice if exists if 'last_selected_payer' in session: bill_form.payer.data = session['last_selected_payer'] @@ -260,6 +277,7 @@ def list_bills(): add_bill=request.values.get('add_bill', False) ) + @main.route("//members/add", methods=["GET", "POST"]) def add_member(): # FIXME manage form errors on the list_bills page @@ -273,6 +291,7 @@ def add_member(): return render_template("add_member.html", form=form) + @main.route("//members//reactivate", methods=["POST"]) def reactivate(member_id): person = Person.query.filter(Person.id == member_id)\ @@ -294,6 +313,7 @@ def remove_member(member_id): return redirect(url_for(".list_bills")) + @main.route("//add", methods=["GET", "POST"]) def add_bill(): form = get_billform_for(g.project) @@ -353,6 +373,7 @@ def edit_bill(bill_id): return render_template("add_bill.html", form=form, edit=True) + @main.route("/lang/") def change_lang(lang): session['lang'] = lang @@ -360,11 +381,13 @@ def change_lang(lang): return redirect(request.headers.get('Referer') or url_for('.home')) + @main.route("//compute") def compute_bills(): """Compute the sum each one have to pay to each other and display it""" return render_template("compute_bills.html") + @main.route("//archives/create") def create_archive(): form = CreateArchiveForm() @@ -375,6 +398,7 @@ def create_archive(): return render_template("create_archive.html", form=form) + @main.route("/dashboard") def dashboard(): return render_template("dashboard.html", projects=Project.query.all())