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' of git://github.com/spiral-project/ihatemoney

This commit is contained in:
Frédéric Sureau 2012-03-28 17:02:02 +02:00
commit c343bf96ea
6 changed files with 131 additions and 90 deletions

View file

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

View file

@ -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'<li><label for="%s">%s<span>%s</span></label></li>' % (choice_id, '<input %s /> ' % html_params(**options), label))
html.append(u'<li><label for="%s">%s<span>%s</span></label></li>'
% (choice_id, '<input %s /> ' % 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'<li><label for="%s">%s<span>%s</span></label></li>' % (choice_id, '<input %s /> ' % html_params(**options), label))
html.append(u'<li><label for="%s">%s<span>%s</span></label></li>'
% (choice_id, '<input %s /> ' % html_params(**options), label))
html.append(u'</ul>')
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)"))

View file

@ -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 "<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.String, db.ForeignKey("project.id"))

View file

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

View file

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

View file

@ -1,15 +1,3 @@
from collections import defaultdict
from flask import *
from flaskext.mail import Mail, Message
from flaskext.babel import get_locale, gettext as _
import werkzeug
# local modules
from models import db, Project, Person, Bill
from forms import *
from utils import Redirect303
"""
The blueprint for the web interface.
@ -17,13 +5,28 @@ Contains all the interaction logic with the end user (except forms which
are directly handled in the forms module.
Basically, this blueprint takes care of the authentication and provides
some shortcuts to make your life better when coding (see `pull_project`
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.
@ -35,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
@ -50,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
@ -59,17 +64,18 @@ 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"""
form = AuthenticationForm()
if not form.id.data and request.args.get('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)
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
# But if the user try to connect to an unexisting project, we will
# propose him a link to the creation form.
if request.method == "POST":
form.validate()
@ -86,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:
@ -98,16 +105,18 @@ def authenticate(project_id=None):
setattr(g, 'project', project)
return redirect(url_for(".list_bills"))
return render_template("authenticate.html", form=form,
return render_template("authenticate.html", form=form,
create_project=create_project)
@main.route("/")
def home():
project_form = ProjectForm()
auth_form = AuthenticationForm()
return render_template("home.html", project_form=project_form,
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()
@ -116,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():
@ -133,23 +143,32 @@ 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_body = render_template("reminder_mail.%s" % get_locale().language)
message_title = _("You have just created '%(project)s' "
"to share your expenses", project=g.project.name)
msg = Message(message_title,
body=message_body,
message_body = render_template("reminder_mail.%s" %
get_locale().language)
msg = Message(message_title,
body=message_body,
recipients=[project.contact_email])
mail.send(msg)
try:
mail.send(msg)
except SMTPRecipientsRefused:
msg_compl = 'Problem sending mail. '
# TODO: destroy the project and cancel instead?
else:
msg_compl = ''
# redirect the user to the next step (invite)
flash(_("The project identifier is %(project)s", project=project.id))
flash(_("%(msg_compl)sThe project identifier is %(project)s",
msg_compl=msg_compl, project=project.id))
return redirect(url_for(".invite", project_id=project.id))
return render_template("create_project.html", form=form)
@main.route("/password-reminder", methods=["GET", "POST"])
def remind_password():
form = PasswordReminder()
@ -159,9 +178,9 @@ def remind_password():
project = Project.query.get(form.id.data)
# send the password reminder
mail.send(Message("password recovery",
body=render_template("password_reminder.%s" % get_locale().language,
project=project),
password_reminder = "password_reminder.%s" % get_locale().language
mail.send(Message("password recovery",
body=render_template(password_reminder, project=project),
recipients=[project.contact_email]))
flash(_("a mail has been sent to you with the password"))
@ -185,18 +204,21 @@ def edit_project():
return render_template("edit_project.html", form=form)
@main.route("/<project_id>/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():
"""
@ -207,30 +229,32 @@ def demo():
"""
project = Project.query.get("demo")
if not project:
project = Project(id="demo", name=u"demonstration", password="demo",
project = Project(id="demo", name=u"demonstration", password="demo",
contact_email="demo@notmyidea.org")
db.session.add(project)
db.session.commit()
session[project.id] = project.password
return redirect(url_for(".list_bills", project_id=project.id))
@main.route("/<project_id>/invite", methods=["GET", "POST"])
def invite():
"""Send invitations for this particular project"""
form = InviteForm()
if request.method == "POST":
if request.method == "POST":
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)
msg = Message(message_title,
body=message_body,
recipients=[email.strip()
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()
for email in form.emails.data.split(",")])
mail.send(msg)
flash(_("Your invitations have been sent"))
@ -238,20 +262,22 @@ def invite():
return render_template("send_invites.html", form=form)
@main.route("/<project_id>/")
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']
bills = g.project.get_bills()
return render_template("list_bills.html",
return render_template("list_bills.html",
bills=bills, member_form=MemberForm(g.project),
bill_form=bill_form,
add_bill=request.values.get('add_bill', False)
)
@main.route("/<project_id>/members/add", methods=["GET", "POST"])
def add_member():
# FIXME manage form errors on the list_bills page
@ -265,6 +291,7 @@ def add_member():
return render_template("add_member.html", form=form)
@main.route("/<project_id>/members/<member_id>/reactivate", methods=["POST"])
def reactivate(member_id):
person = Person.query.filter(Person.id == member_id)\
@ -286,6 +313,7 @@ def remove_member(member_id):
return redirect(url_for(".list_bills"))
@main.route("/<project_id>/add", methods=["GET", "POST"])
def add_bill():
form = get_billform_for(g.project)
@ -345,6 +373,7 @@ def edit_bill(bill_id):
return render_template("add_bill.html", form=form, edit=True)
@main.route("/lang/<lang>")
def change_lang(lang):
session['lang'] = lang
@ -352,14 +381,16 @@ def change_lang(lang):
return redirect(request.headers.get('Referer') or url_for('.home'))
@main.route("/<project_id>/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("/<project_id>/archives/create")
def create_archive():
form = CreateArchiveForm()
form = CreateArchiveForm()
if request.method == "POST":
if form.validate():
pass
@ -367,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())