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 'upstream'

Conflicts:
	sources/budget/static/css/datepicker.css
	sources/budget/static/js/jquery-1.7.2.min.js
This commit is contained in:
Jocelyn Delalande 2017-03-18 17:58:12 +01:00
commit 2b3b63ef27
31 changed files with 3102 additions and 1612 deletions

7
sources/.travis.yml Normal file
View file

@ -0,0 +1,7 @@
language: python
python:
- "2.7"
# command to install dependencies
install: "pip install -r budget/requirements.txt"
# command to run tests
script: cd budget && python tests.py

View file

@ -1,6 +1,10 @@
Budget-manager Budget-manager
############## ##############
.. image:: https://travis-ci.org/spiral-project/ihatemoney.svg?branch=master
:target: https://travis-ci.org/spiral-project/ihatemoney
:alt: Travis CI Build Status
This is a really tiny app to ease the shared houses budget management. Keep This is a really tiny app to ease the shared houses budget management. Keep
track of who bought what, when, and for who to then compute the balance of each track of who bought what, when, and for who to then compute the balance of each
person. person.
@ -59,7 +63,7 @@ How about the REST API?
======================= =======================
Yep, you're right, there is a REST API with this. Head to the `api Yep, you're right, there is a REST API with this. Head to the `api
documentation <http://readthedocs.org/docs/ihatemoney/en/latest/api.html>`_ to know more. documentation <https://ihatemoney.readthedocs.io/en/latest/api.html>`_ to know more.
How to contribute How to contribute
================= =================

View file

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from flask import Blueprint, request from flask import Blueprint, request
from flask.ext.rest import RESTResource, need_auth from flask_rest import RESTResource, need_auth
from models import db, Project, Person, Bill from models import db, Project, Person, Bill
from forms import (ProjectForm, EditProjectForm, MemberForm, from forms import (ProjectForm, EditProjectForm, MemberForm,

View file

@ -1,7 +1,7 @@
from flask.ext.wtf import DateField, DecimalField, Email, Form, PasswordField, \ from flask_wtf import DateField, DecimalField, Email, Form, PasswordField, \
Required, SelectField, SelectMultipleField, SubmitField, TextAreaField, \ Required, SelectField, SelectMultipleField, SubmitField, TextAreaField, \
TextField, ValidationError TextField, ValidationError
from flask.ext.babel import lazy_gettext as _ from flask_babel import lazy_gettext as _
from flask import request from flask import request
from wtforms.widgets import html_params from wtforms.widgets import html_params
@ -10,28 +10,6 @@ from datetime import datetime
from jinja2 import Markup from jinja2 import Markup
from utils import slugify from utils import slugify
def select_multi_checkbox(field, ul_class='', **kwargs):
kwargs.setdefault('type', 'checkbox')
field_id = kwargs.pop('id', field.id)
html = [u'<ul %s>' % html_params(id=field_id, class_="inputs-list")]
choice_id = u'toggleField'
js_function = u'toggle();'
options = dict(kwargs, id=choice_id, onclick=js_function)
html.append(u'<p><a id="selectall" onclick="selectall()">%s</a> | <a id="selectnone" onclick="selectnone()">%s</a></p>'% (_("Select all"), _("Select none")))
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'<p><label for="%s">%s<span>%s</span></label></p>'
% (choice_id, '<input %s /> ' % html_params(**options), label))
html.append(u'</ul>')
return u''.join(html)
def get_billform_for(project, set_default=True, **kwargs): def get_billform_for(project, set_default=True, **kwargs):
"""Return an instance of BillForm configured for a particular project. """Return an instance of BillForm configured for a particular project.
@ -118,7 +96,7 @@ class BillForm(Form):
payer = SelectField(_("Payer"), validators=[Required()], coerce=int) payer = SelectField(_("Payer"), validators=[Required()], coerce=int)
amount = CommaDecimalField(_("Amount paid"), validators=[Required()]) amount = CommaDecimalField(_("Amount paid"), validators=[Required()])
payed_for = SelectMultipleField(_("For whom?"), payed_for = SelectMultipleField(_("For whom?"),
validators=[Required()], widget=select_multi_checkbox, coerce=int) validators=[Required()], coerce=int)
submit = SubmitField(_("Submit")) submit = SubmitField(_("Submit"))
submit2 = SubmitField(_("Submit and add a new one")) submit2 = SubmitField(_("Submit and add a new one"))
@ -143,9 +121,7 @@ class BillForm(Form):
self.payed_for.data = self.payed_for.default self.payed_for.data = self.payed_for.default
def validate_amount(self, field): def validate_amount(self, field):
if field.data < 0: if field.data == 0:
field.data = abs(field.data)
elif field.data == 0:
raise ValidationError(_("Bills can't be null")) raise ValidationError(_("Bills can't be null"))
@ -198,3 +174,16 @@ class CreateArchiveForm(Form):
name = TextField(_("Name for this archive (optional)"), validators=[]) name = TextField(_("Name for this archive (optional)"), validators=[])
start_date = DateField(_("Start date"), validators=[Required()]) start_date = DateField(_("Start date"), validators=[Required()])
end_date = DateField(_("End date"), validators=[Required()], default=datetime.now) end_date = DateField(_("End date"), validators=[Required()], default=datetime.now)
class ExportForm(Form):
export_type = SelectField(_("What do you want to download ?"),
validators=[Required()],
coerce=str,
choices=[("bills", _("bills")), ("transactions", _("transactions"))]
)
export_format = SelectField(_("Export file format"),
validators=[Required()],
coerce=str,
choices=[("csv", "csv"), ("json", "json")]
)

View file

@ -55,7 +55,7 @@ def run_migrations_online():
# This callback is used to prevent an auto-migration from being generated # This callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema. # when there are no changes to the schema.
# reference: http://alembic.readthedocs.org/en/latest/cookbook.html # reference: https://alembic.readthedocs.io/en/latest/cookbook.html
def process_revision_directives(context, revision, directives): def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False): if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0] script = directives[0]

View file

@ -1,7 +1,7 @@
from collections import defaultdict from collections import defaultdict
from datetime import datetime from datetime import datetime
from flask.ext.sqlalchemy import SQLAlchemy, BaseQuery from flask_sqlalchemy import SQLAlchemy, BaseQuery
from flask import g from flask import g
from sqlalchemy import orm from sqlalchemy import orm
@ -37,7 +37,7 @@ class Project(db.Model):
# for each person # for each person
for person in self.members: for person in self.members:
# get the list of bills he has to pay # get the list of bills he has to pay
bills = Bill.query.filter(Bill.owers.contains(person)) bills = Bill.query.options(orm.subqueryload(Bill.owers)).filter(Bill.owers.contains(person))
for bill in bills.all(): for bill in bills.all():
if person != bill.payer: if person != bill.payer:
share = bill.pay_each() * person.weight share = bill.pay_each() * person.weight
@ -54,16 +54,28 @@ class Project(db.Model):
def uses_weights(self): def uses_weights(self):
return len([i for i in self.members if i.weight != 1]) > 0 return len([i for i in self.members if i.weight != 1]) > 0
def get_transactions_to_settle_bill(self): def get_transactions_to_settle_bill(self, pretty_output=False):
"""Return a list of transactions that could be made to settle the bill""" """Return a list of transactions that could be made to settle the bill"""
def prettify(transactions, pretty_output):
""" Return pretty transactions
"""
if not pretty_output:
return transactions
pretty_transactions = []
for transaction in transactions:
pretty_transactions.append({'ower': transaction['ower'].name,
'receiver': transaction['receiver'].name,
'amount': round(transaction['amount'], 2)})
return pretty_transactions
#cache value for better performance #cache value for better performance
balance = self.balance balance = self.balance
credits, debts, transactions = [],[],[] credits, debts, transactions = [],[],[]
# Create lists of credits and debts # Create lists of credits and debts
for person in self.members: for person in self.members:
if balance[person.id] > 0: if round(balance[person.id], 2) > 0:
credits.append({"person": person, "balance": balance[person.id]}) credits.append({"person": person, "balance": balance[person.id]})
elif balance[person.id] < 0: elif round(balance[person.id], 2) < 0:
debts.append({"person": person, "balance": -balance[person.id]}) debts.append({"person": person, "balance": -balance[person.id]})
# Try and find exact matches # Try and find exact matches
for credit in credits: for credit in credits:
@ -83,7 +95,8 @@ class Project(db.Model):
transactions.append({"ower": debts[0]["person"], "receiver": credits[0]["person"], "amount": credits[0]["balance"]}) transactions.append({"ower": debts[0]["person"], "receiver": credits[0]["person"], "amount": credits[0]["balance"]})
debts[0]["balance"] = debts[0]["balance"] - credits[0]["balance"] debts[0]["balance"] = debts[0]["balance"] - credits[0]["balance"]
del credits[0] del credits[0]
return transactions
return prettify(transactions, pretty_output)
def exactmatch(self, credit, debts): def exactmatch(self, credit, debts):
"""Recursively try and find subsets of 'debts' whose sum is equal to credit""" """Recursively try and find subsets of 'debts' whose sum is equal to credit"""
@ -111,7 +124,25 @@ class Project(db.Model):
.filter(Bill.payer_id == Person.id)\ .filter(Bill.payer_id == Person.id)\
.filter(Person.project_id == Project.id)\ .filter(Person.project_id == Project.id)\
.filter(Project.id == self.id)\ .filter(Project.id == self.id)\
.order_by(Bill.date.desc()) .order_by(Bill.date.desc())\
.order_by(Bill.id.desc())
def get_pretty_bills(self, export_format="json"):
"""Return a list of project's bills with pretty formatting"""
bills = self.get_bills()
pretty_bills = []
for bill in bills:
if export_format == "json":
owers = [ower.name for ower in bill.owers]
else:
owers = ', '.join([ower.name for ower in bill.owers])
pretty_bills.append({"what": bill.what,
"amount": round(bill.amount, 2),
"date": str(bill.date),
"payer_name": Person.query.get(bill.payer_id).name,
"payer_weight": Person.query.get(bill.payer_id).weight,
"owers": owers})
return pretty_bills
def remove_member(self, member_id): def remove_member(self, member_id):
"""Remove a member from the project. """Remove a member from the project.

View file

@ -2,8 +2,8 @@ import os
import warnings import warnings
from flask import Flask, g, request, session from flask import Flask, g, request, session
from flask.ext.babel import Babel from flask_babel import Babel
from flask.ext.migrate import Migrate, upgrade, stamp from flask_migrate import Migrate, upgrade, stamp
from raven.contrib.flask import Sentry from raven.contrib.flask import Sentry
from web import main, db, mail from web import main, db, mail

View file

@ -0,0 +1,707 @@
/*!
* Datepicker for Bootstrap v1.6.4 (https://github.com/eternicode/bootstrap-datepicker)
*
* Copyright 2012 Stefan Petre
* Improvements by Andrew Rowls
* Licensed under the Apache License v2.0 (http://www.apache.org/licenses/LICENSE-2.0)
*/
.datepicker {
border-radius: 4px;
direction: ltr;
}
.datepicker-inline {
width: 220px;
}
.datepicker.datepicker-rtl {
direction: rtl;
}
.datepicker.datepicker-rtl table tr td span {
float: right;
}
.datepicker-dropdown {
top: 0;
left: 0;
padding: 4px;
}
.datepicker-dropdown:before {
content: '';
display: inline-block;
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-bottom: 7px solid rgba(0, 0, 0, 0.15);
border-top: 0;
border-bottom-color: rgba(0, 0, 0, 0.2);
position: absolute;
}
.datepicker-dropdown:after {
content: '';
display: inline-block;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 6px solid #fff;
border-top: 0;
position: absolute;
}
.datepicker-dropdown.datepicker-orient-left:before {
left: 6px;
}
.datepicker-dropdown.datepicker-orient-left:after {
left: 7px;
}
.datepicker-dropdown.datepicker-orient-right:before {
right: 6px;
}
.datepicker-dropdown.datepicker-orient-right:after {
right: 7px;
}
.datepicker-dropdown.datepicker-orient-bottom:before {
top: -7px;
}
.datepicker-dropdown.datepicker-orient-bottom:after {
top: -6px;
}
.datepicker-dropdown.datepicker-orient-top:before {
bottom: -7px;
border-bottom: 0;
border-top: 7px solid rgba(0, 0, 0, 0.15);
}
.datepicker-dropdown.datepicker-orient-top:after {
bottom: -6px;
border-bottom: 0;
border-top: 6px solid #fff;
}
.datepicker table {
margin: 0;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.datepicker table tr td,
.datepicker table tr th {
text-align: center;
width: 30px;
height: 30px;
border-radius: 4px;
border: none;
}
.table-striped .datepicker table tr td,
.table-striped .datepicker table tr th {
background-color: transparent;
}
.datepicker table tr td.old,
.datepicker table tr td.new {
color: #777777;
}
.datepicker table tr td.day:hover,
.datepicker table tr td.focused {
background: #eeeeee;
cursor: pointer;
}
.datepicker table tr td.disabled,
.datepicker table tr td.disabled:hover {
background: none;
color: #777777;
cursor: default;
}
.datepicker table tr td.highlighted {
color: #000;
background-color: #d9edf7;
border-color: #85c5e5;
border-radius: 0;
}
.datepicker table tr td.highlighted:focus,
.datepicker table tr td.highlighted.focus {
color: #000;
background-color: #afd9ee;
border-color: #298fc2;
}
.datepicker table tr td.highlighted:hover {
color: #000;
background-color: #afd9ee;
border-color: #52addb;
}
.datepicker table tr td.highlighted:active,
.datepicker table tr td.highlighted.active {
color: #000;
background-color: #afd9ee;
border-color: #52addb;
}
.datepicker table tr td.highlighted:active:hover,
.datepicker table tr td.highlighted.active:hover,
.datepicker table tr td.highlighted:active:focus,
.datepicker table tr td.highlighted.active:focus,
.datepicker table tr td.highlighted:active.focus,
.datepicker table tr td.highlighted.active.focus {
color: #000;
background-color: #91cbe8;
border-color: #298fc2;
}
.datepicker table tr td.highlighted.disabled:hover,
.datepicker table tr td.highlighted[disabled]:hover,
fieldset[disabled] .datepicker table tr td.highlighted:hover,
.datepicker table tr td.highlighted.disabled:focus,
.datepicker table tr td.highlighted[disabled]:focus,
fieldset[disabled] .datepicker table tr td.highlighted:focus,
.datepicker table tr td.highlighted.disabled.focus,
.datepicker table tr td.highlighted[disabled].focus,
fieldset[disabled] .datepicker table tr td.highlighted.focus {
background-color: #d9edf7;
border-color: #85c5e5;
}
.datepicker table tr td.highlighted.focused {
background: #afd9ee;
}
.datepicker table tr td.highlighted.disabled,
.datepicker table tr td.highlighted.disabled:active {
background: #d9edf7;
color: #777777;
}
.datepicker table tr td.today {
color: #000;
background-color: #ffdb99;
border-color: #ffb733;
}
.datepicker table tr td.today:focus,
.datepicker table tr td.today.focus {
color: #000;
background-color: #ffc966;
border-color: #b37400;
}
.datepicker table tr td.today:hover {
color: #000;
background-color: #ffc966;
border-color: #f59e00;
}
.datepicker table tr td.today:active,
.datepicker table tr td.today.active {
color: #000;
background-color: #ffc966;
border-color: #f59e00;
}
.datepicker table tr td.today:active:hover,
.datepicker table tr td.today.active:hover,
.datepicker table tr td.today:active:focus,
.datepicker table tr td.today.active:focus,
.datepicker table tr td.today:active.focus,
.datepicker table tr td.today.active.focus {
color: #000;
background-color: #ffbc42;
border-color: #b37400;
}
.datepicker table tr td.today.disabled:hover,
.datepicker table tr td.today[disabled]:hover,
fieldset[disabled] .datepicker table tr td.today:hover,
.datepicker table tr td.today.disabled:focus,
.datepicker table tr td.today[disabled]:focus,
fieldset[disabled] .datepicker table tr td.today:focus,
.datepicker table tr td.today.disabled.focus,
.datepicker table tr td.today[disabled].focus,
fieldset[disabled] .datepicker table tr td.today.focus {
background-color: #ffdb99;
border-color: #ffb733;
}
.datepicker table tr td.today.focused {
background: #ffc966;
}
.datepicker table tr td.today.disabled,
.datepicker table tr td.today.disabled:active {
background: #ffdb99;
color: #777777;
}
.datepicker table tr td.range {
color: #000;
background-color: #eeeeee;
border-color: #bbbbbb;
border-radius: 0;
}
.datepicker table tr td.range:focus,
.datepicker table tr td.range.focus {
color: #000;
background-color: #d5d5d5;
border-color: #7c7c7c;
}
.datepicker table tr td.range:hover {
color: #000;
background-color: #d5d5d5;
border-color: #9d9d9d;
}
.datepicker table tr td.range:active,
.datepicker table tr td.range.active {
color: #000;
background-color: #d5d5d5;
border-color: #9d9d9d;
}
.datepicker table tr td.range:active:hover,
.datepicker table tr td.range.active:hover,
.datepicker table tr td.range:active:focus,
.datepicker table tr td.range.active:focus,
.datepicker table tr td.range:active.focus,
.datepicker table tr td.range.active.focus {
color: #000;
background-color: #c3c3c3;
border-color: #7c7c7c;
}
.datepicker table tr td.range.disabled:hover,
.datepicker table tr td.range[disabled]:hover,
fieldset[disabled] .datepicker table tr td.range:hover,
.datepicker table tr td.range.disabled:focus,
.datepicker table tr td.range[disabled]:focus,
fieldset[disabled] .datepicker table tr td.range:focus,
.datepicker table tr td.range.disabled.focus,
.datepicker table tr td.range[disabled].focus,
fieldset[disabled] .datepicker table tr td.range.focus {
background-color: #eeeeee;
border-color: #bbbbbb;
}
.datepicker table tr td.range.focused {
background: #d5d5d5;
}
.datepicker table tr td.range.disabled,
.datepicker table tr td.range.disabled:active {
background: #eeeeee;
color: #777777;
}
.datepicker table tr td.range.highlighted {
color: #000;
background-color: #e4eef3;
border-color: #9dc1d3;
}
.datepicker table tr td.range.highlighted:focus,
.datepicker table tr td.range.highlighted.focus {
color: #000;
background-color: #c1d7e3;
border-color: #4b88a6;
}
.datepicker table tr td.range.highlighted:hover {
color: #000;
background-color: #c1d7e3;
border-color: #73a6c0;
}
.datepicker table tr td.range.highlighted:active,
.datepicker table tr td.range.highlighted.active {
color: #000;
background-color: #c1d7e3;
border-color: #73a6c0;
}
.datepicker table tr td.range.highlighted:active:hover,
.datepicker table tr td.range.highlighted.active:hover,
.datepicker table tr td.range.highlighted:active:focus,
.datepicker table tr td.range.highlighted.active:focus,
.datepicker table tr td.range.highlighted:active.focus,
.datepicker table tr td.range.highlighted.active.focus {
color: #000;
background-color: #a8c8d8;
border-color: #4b88a6;
}
.datepicker table tr td.range.highlighted.disabled:hover,
.datepicker table tr td.range.highlighted[disabled]:hover,
fieldset[disabled] .datepicker table tr td.range.highlighted:hover,
.datepicker table tr td.range.highlighted.disabled:focus,
.datepicker table tr td.range.highlighted[disabled]:focus,
fieldset[disabled] .datepicker table tr td.range.highlighted:focus,
.datepicker table tr td.range.highlighted.disabled.focus,
.datepicker table tr td.range.highlighted[disabled].focus,
fieldset[disabled] .datepicker table tr td.range.highlighted.focus {
background-color: #e4eef3;
border-color: #9dc1d3;
}
.datepicker table tr td.range.highlighted.focused {
background: #c1d7e3;
}
.datepicker table tr td.range.highlighted.disabled,
.datepicker table tr td.range.highlighted.disabled:active {
background: #e4eef3;
color: #777777;
}
.datepicker table tr td.range.today {
color: #000;
background-color: #f7ca77;
border-color: #f1a417;
}
.datepicker table tr td.range.today:focus,
.datepicker table tr td.range.today.focus {
color: #000;
background-color: #f4b747;
border-color: #815608;
}
.datepicker table tr td.range.today:hover {
color: #000;
background-color: #f4b747;
border-color: #bf800c;
}
.datepicker table tr td.range.today:active,
.datepicker table tr td.range.today.active {
color: #000;
background-color: #f4b747;
border-color: #bf800c;
}
.datepicker table tr td.range.today:active:hover,
.datepicker table tr td.range.today.active:hover,
.datepicker table tr td.range.today:active:focus,
.datepicker table tr td.range.today.active:focus,
.datepicker table tr td.range.today:active.focus,
.datepicker table tr td.range.today.active.focus {
color: #000;
background-color: #f2aa25;
border-color: #815608;
}
.datepicker table tr td.range.today.disabled:hover,
.datepicker table tr td.range.today[disabled]:hover,
fieldset[disabled] .datepicker table tr td.range.today:hover,
.datepicker table tr td.range.today.disabled:focus,
.datepicker table tr td.range.today[disabled]:focus,
fieldset[disabled] .datepicker table tr td.range.today:focus,
.datepicker table tr td.range.today.disabled.focus,
.datepicker table tr td.range.today[disabled].focus,
fieldset[disabled] .datepicker table tr td.range.today.focus {
background-color: #f7ca77;
border-color: #f1a417;
}
.datepicker table tr td.range.today.disabled,
.datepicker table tr td.range.today.disabled:active {
background: #f7ca77;
color: #777777;
}
.datepicker table tr td.selected,
.datepicker table tr td.selected.highlighted {
color: #fff;
background-color: #777777;
border-color: #555555;
text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);
}
.datepicker table tr td.selected:focus,
.datepicker table tr td.selected.highlighted:focus,
.datepicker table tr td.selected.focus,
.datepicker table tr td.selected.highlighted.focus {
color: #fff;
background-color: #5e5e5e;
border-color: #161616;
}
.datepicker table tr td.selected:hover,
.datepicker table tr td.selected.highlighted:hover {
color: #fff;
background-color: #5e5e5e;
border-color: #373737;
}
.datepicker table tr td.selected:active,
.datepicker table tr td.selected.highlighted:active,
.datepicker table tr td.selected.active,
.datepicker table tr td.selected.highlighted.active {
color: #fff;
background-color: #5e5e5e;
border-color: #373737;
}
.datepicker table tr td.selected:active:hover,
.datepicker table tr td.selected.highlighted:active:hover,
.datepicker table tr td.selected.active:hover,
.datepicker table tr td.selected.highlighted.active:hover,
.datepicker table tr td.selected:active:focus,
.datepicker table tr td.selected.highlighted:active:focus,
.datepicker table tr td.selected.active:focus,
.datepicker table tr td.selected.highlighted.active:focus,
.datepicker table tr td.selected:active.focus,
.datepicker table tr td.selected.highlighted:active.focus,
.datepicker table tr td.selected.active.focus,
.datepicker table tr td.selected.highlighted.active.focus {
color: #fff;
background-color: #4c4c4c;
border-color: #161616;
}
.datepicker table tr td.selected.disabled:hover,
.datepicker table tr td.selected.highlighted.disabled:hover,
.datepicker table tr td.selected[disabled]:hover,
.datepicker table tr td.selected.highlighted[disabled]:hover,
fieldset[disabled] .datepicker table tr td.selected:hover,
fieldset[disabled] .datepicker table tr td.selected.highlighted:hover,
.datepicker table tr td.selected.disabled:focus,
.datepicker table tr td.selected.highlighted.disabled:focus,
.datepicker table tr td.selected[disabled]:focus,
.datepicker table tr td.selected.highlighted[disabled]:focus,
fieldset[disabled] .datepicker table tr td.selected:focus,
fieldset[disabled] .datepicker table tr td.selected.highlighted:focus,
.datepicker table tr td.selected.disabled.focus,
.datepicker table tr td.selected.highlighted.disabled.focus,
.datepicker table tr td.selected[disabled].focus,
.datepicker table tr td.selected.highlighted[disabled].focus,
fieldset[disabled] .datepicker table tr td.selected.focus,
fieldset[disabled] .datepicker table tr td.selected.highlighted.focus {
background-color: #777777;
border-color: #555555;
}
.datepicker table tr td.active,
.datepicker table tr td.active.highlighted {
color: #fff;
background-color: #337ab7;
border-color: #2e6da4;
text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);
}
.datepicker table tr td.active:focus,
.datepicker table tr td.active.highlighted:focus,
.datepicker table tr td.active.focus,
.datepicker table tr td.active.highlighted.focus {
color: #fff;
background-color: #286090;
border-color: #122b40;
}
.datepicker table tr td.active:hover,
.datepicker table tr td.active.highlighted:hover {
color: #fff;
background-color: #286090;
border-color: #204d74;
}
.datepicker table tr td.active:active,
.datepicker table tr td.active.highlighted:active,
.datepicker table tr td.active.active,
.datepicker table tr td.active.highlighted.active {
color: #fff;
background-color: #286090;
border-color: #204d74;
}
.datepicker table tr td.active:active:hover,
.datepicker table tr td.active.highlighted:active:hover,
.datepicker table tr td.active.active:hover,
.datepicker table tr td.active.highlighted.active:hover,
.datepicker table tr td.active:active:focus,
.datepicker table tr td.active.highlighted:active:focus,
.datepicker table tr td.active.active:focus,
.datepicker table tr td.active.highlighted.active:focus,
.datepicker table tr td.active:active.focus,
.datepicker table tr td.active.highlighted:active.focus,
.datepicker table tr td.active.active.focus,
.datepicker table tr td.active.highlighted.active.focus {
color: #fff;
background-color: #204d74;
border-color: #122b40;
}
.datepicker table tr td.active.disabled:hover,
.datepicker table tr td.active.highlighted.disabled:hover,
.datepicker table tr td.active[disabled]:hover,
.datepicker table tr td.active.highlighted[disabled]:hover,
fieldset[disabled] .datepicker table tr td.active:hover,
fieldset[disabled] .datepicker table tr td.active.highlighted:hover,
.datepicker table tr td.active.disabled:focus,
.datepicker table tr td.active.highlighted.disabled:focus,
.datepicker table tr td.active[disabled]:focus,
.datepicker table tr td.active.highlighted[disabled]:focus,
fieldset[disabled] .datepicker table tr td.active:focus,
fieldset[disabled] .datepicker table tr td.active.highlighted:focus,
.datepicker table tr td.active.disabled.focus,
.datepicker table tr td.active.highlighted.disabled.focus,
.datepicker table tr td.active[disabled].focus,
.datepicker table tr td.active.highlighted[disabled].focus,
fieldset[disabled] .datepicker table tr td.active.focus,
fieldset[disabled] .datepicker table tr td.active.highlighted.focus {
background-color: #337ab7;
border-color: #2e6da4;
}
.datepicker table tr td span {
display: block;
width: 23%;
height: 54px;
line-height: 54px;
float: left;
margin: 1%;
cursor: pointer;
border-radius: 4px;
}
.datepicker table tr td span:hover,
.datepicker table tr td span.focused {
background: #eeeeee;
}
.datepicker table tr td span.disabled,
.datepicker table tr td span.disabled:hover {
background: none;
color: #777777;
cursor: default;
}
.datepicker table tr td span.active,
.datepicker table tr td span.active:hover,
.datepicker table tr td span.active.disabled,
.datepicker table tr td span.active.disabled:hover {
color: #fff;
background-color: #337ab7;
border-color: #2e6da4;
text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);
}
.datepicker table tr td span.active:focus,
.datepicker table tr td span.active:hover:focus,
.datepicker table tr td span.active.disabled:focus,
.datepicker table tr td span.active.disabled:hover:focus,
.datepicker table tr td span.active.focus,
.datepicker table tr td span.active:hover.focus,
.datepicker table tr td span.active.disabled.focus,
.datepicker table tr td span.active.disabled:hover.focus {
color: #fff;
background-color: #286090;
border-color: #122b40;
}
.datepicker table tr td span.active:hover,
.datepicker table tr td span.active:hover:hover,
.datepicker table tr td span.active.disabled:hover,
.datepicker table tr td span.active.disabled:hover:hover {
color: #fff;
background-color: #286090;
border-color: #204d74;
}
.datepicker table tr td span.active:active,
.datepicker table tr td span.active:hover:active,
.datepicker table tr td span.active.disabled:active,
.datepicker table tr td span.active.disabled:hover:active,
.datepicker table tr td span.active.active,
.datepicker table tr td span.active:hover.active,
.datepicker table tr td span.active.disabled.active,
.datepicker table tr td span.active.disabled:hover.active {
color: #fff;
background-color: #286090;
border-color: #204d74;
}
.datepicker table tr td span.active:active:hover,
.datepicker table tr td span.active:hover:active:hover,
.datepicker table tr td span.active.disabled:active:hover,
.datepicker table tr td span.active.disabled:hover:active:hover,
.datepicker table tr td span.active.active:hover,
.datepicker table tr td span.active:hover.active:hover,
.datepicker table tr td span.active.disabled.active:hover,
.datepicker table tr td span.active.disabled:hover.active:hover,
.datepicker table tr td span.active:active:focus,
.datepicker table tr td span.active:hover:active:focus,
.datepicker table tr td span.active.disabled:active:focus,
.datepicker table tr td span.active.disabled:hover:active:focus,
.datepicker table tr td span.active.active:focus,
.datepicker table tr td span.active:hover.active:focus,
.datepicker table tr td span.active.disabled.active:focus,
.datepicker table tr td span.active.disabled:hover.active:focus,
.datepicker table tr td span.active:active.focus,
.datepicker table tr td span.active:hover:active.focus,
.datepicker table tr td span.active.disabled:active.focus,
.datepicker table tr td span.active.disabled:hover:active.focus,
.datepicker table tr td span.active.active.focus,
.datepicker table tr td span.active:hover.active.focus,
.datepicker table tr td span.active.disabled.active.focus,
.datepicker table tr td span.active.disabled:hover.active.focus {
color: #fff;
background-color: #204d74;
border-color: #122b40;
}
.datepicker table tr td span.active.disabled:hover,
.datepicker table tr td span.active:hover.disabled:hover,
.datepicker table tr td span.active.disabled.disabled:hover,
.datepicker table tr td span.active.disabled:hover.disabled:hover,
.datepicker table tr td span.active[disabled]:hover,
.datepicker table tr td span.active:hover[disabled]:hover,
.datepicker table tr td span.active.disabled[disabled]:hover,
.datepicker table tr td span.active.disabled:hover[disabled]:hover,
fieldset[disabled] .datepicker table tr td span.active:hover,
fieldset[disabled] .datepicker table tr td span.active:hover:hover,
fieldset[disabled] .datepicker table tr td span.active.disabled:hover,
fieldset[disabled] .datepicker table tr td span.active.disabled:hover:hover,
.datepicker table tr td span.active.disabled:focus,
.datepicker table tr td span.active:hover.disabled:focus,
.datepicker table tr td span.active.disabled.disabled:focus,
.datepicker table tr td span.active.disabled:hover.disabled:focus,
.datepicker table tr td span.active[disabled]:focus,
.datepicker table tr td span.active:hover[disabled]:focus,
.datepicker table tr td span.active.disabled[disabled]:focus,
.datepicker table tr td span.active.disabled:hover[disabled]:focus,
fieldset[disabled] .datepicker table tr td span.active:focus,
fieldset[disabled] .datepicker table tr td span.active:hover:focus,
fieldset[disabled] .datepicker table tr td span.active.disabled:focus,
fieldset[disabled] .datepicker table tr td span.active.disabled:hover:focus,
.datepicker table tr td span.active.disabled.focus,
.datepicker table tr td span.active:hover.disabled.focus,
.datepicker table tr td span.active.disabled.disabled.focus,
.datepicker table tr td span.active.disabled:hover.disabled.focus,
.datepicker table tr td span.active[disabled].focus,
.datepicker table tr td span.active:hover[disabled].focus,
.datepicker table tr td span.active.disabled[disabled].focus,
.datepicker table tr td span.active.disabled:hover[disabled].focus,
fieldset[disabled] .datepicker table tr td span.active.focus,
fieldset[disabled] .datepicker table tr td span.active:hover.focus,
fieldset[disabled] .datepicker table tr td span.active.disabled.focus,
fieldset[disabled] .datepicker table tr td span.active.disabled:hover.focus {
background-color: #337ab7;
border-color: #2e6da4;
}
.datepicker table tr td span.old,
.datepicker table tr td span.new {
color: #777777;
}
.datepicker .datepicker-switch {
width: 145px;
}
.datepicker .datepicker-switch,
.datepicker .prev,
.datepicker .next,
.datepicker tfoot tr th {
cursor: pointer;
}
.datepicker .datepicker-switch:hover,
.datepicker .prev:hover,
.datepicker .next:hover,
.datepicker tfoot tr th:hover {
background: #eeeeee;
}
.datepicker .cw {
font-size: 10px;
width: 12px;
padding: 0 2px 0 5px;
vertical-align: middle;
}
.input-group.date .input-group-addon {
cursor: pointer;
}
.input-daterange {
width: 100%;
}
.input-daterange input {
text-align: center;
}
.input-daterange input:first-child {
border-radius: 3px 0 0 3px;
}
.input-daterange input:last-child {
border-radius: 0 3px 3px 0;
}
.input-daterange .input-group-addon {
width: auto;
min-width: 16px;
padding: 4px 5px;
line-height: 1.42857143;
text-shadow: 0 1px 0 #fff;
border-width: 1px 0;
margin-left: -5px;
margin-right: -5px;
}
.datepicker.dropdown-menu {
position: absolute;
top: 100%;
left: 0;
z-index: 1000;
display: none;
float: left;
min-width: 160px;
list-style: none;
background-color: #fff;
border: 1px solid #ccc;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 4px;
-webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
-moz-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
-webkit-background-clip: padding-box;
-moz-background-clip: padding;
background-clip: padding-box;
color: #333333;
font-size: 13px;
line-height: 1.42857143;
}
.datepicker.dropdown-menu th,
.datepicker.datepicker-inline th,
.datepicker.dropdown-menu td,
.datepicker.datepicker-inline td {
padding: 0px 5px;
}
/*# sourceMappingURL=bootstrap-datepicker3.standalone.css.map */

File diff suppressed because one or more lines are too long

View file

@ -1,224 +0,0 @@
/*!
* Datepicker for Bootstrap
*
* Copyright 2012 Stefan Petre
* Improvements by Andrew Rowls
* Licensed under the Apache License v2.0
* http://www.apache.org/licenses/LICENSE-2.0
*
*/
.datepicker {
top: 0;
left: 0;
padding: 4px;
margin-top: 1px;
-webkit-border-radius: 4px;
-moz-border-radius: 4px;
border-radius: 4px;
/*.dow {
border-top: 1px solid #ddd !important;
}*/
}
.datepicker:before {
content: '';
display: inline-block;
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-bottom: 7px solid #ccc;
border-bottom-color: rgba(0, 0, 0, 0.2);
position: absolute;
top: -7px;
left: 6px;
}
.datepicker:after {
content: '';
display: inline-block;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 6px solid #ffffff;
position: absolute;
top: -6px;
left: 7px;
}
.datepicker > div {
display: none;
}
.datepicker.days div.datepicker-days {
display: block;
}
.datepicker.months div.datepicker-months {
display: block;
}
.datepicker.years div.datepicker-years {
display: block;
}
.datepicker table {
width: 100%;
margin: 0;
}
.datepicker td,
.datepicker th {
text-align: center;
width: 20px;
height: 20px;
-webkit-border-radius: 4px;
-moz-border-radius: 4px;
border-radius: 4px;
}
.datepicker td.day:hover {
background: #eeeeee;
cursor: pointer;
}
.datepicker td.old,
.datepicker td.new {
color: #999999;
}
.datepicker td.disabled,
.datepicker td.disabled:hover {
background: none;
color: #999999;
cursor: default;
}
.datepicker td.active,
.datepicker td.active:hover,
.datepicker td.active.disabled,
.datepicker td.active.disabled:hover {
background-color: #006dcc;
background-image: -moz-linear-gradient(top, #0088cc, #0044cc);
background-image: -ms-linear-gradient(top, #0088cc, #0044cc);
background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#0088cc), to(#0044cc));
background-image: -webkit-linear-gradient(top, #0088cc, #0044cc);
background-image: -o-linear-gradient(top, #0088cc, #0044cc);
background-image: linear-gradient(top, #0088cc, #0044cc);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#0088cc', endColorstr='#0044cc', GradientType=0);
border-color: #0044cc #0044cc #002a80;
border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);
filter: progid:dximagetransform.microsoft.gradient(enabled=false);
color: #fff;
text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);
}
.datepicker td.active:hover,
.datepicker td.active:hover:hover,
.datepicker td.active.disabled:hover,
.datepicker td.active.disabled:hover:hover,
.datepicker td.active:active,
.datepicker td.active:hover:active,
.datepicker td.active.disabled:active,
.datepicker td.active.disabled:hover:active,
.datepicker td.active.active,
.datepicker td.active:hover.active,
.datepicker td.active.disabled.active,
.datepicker td.active.disabled:hover.active,
.datepicker td.active.disabled,
.datepicker td.active:hover.disabled,
.datepicker td.active.disabled.disabled,
.datepicker td.active.disabled:hover.disabled,
.datepicker td.active[disabled],
.datepicker td.active:hover[disabled],
.datepicker td.active.disabled[disabled],
.datepicker td.active.disabled:hover[disabled] {
background-color: #0044cc;
}
.datepicker td.active:active,
.datepicker td.active:hover:active,
.datepicker td.active.disabled:active,
.datepicker td.active.disabled:hover:active,
.datepicker td.active.active,
.datepicker td.active:hover.active,
.datepicker td.active.disabled.active,
.datepicker td.active.disabled:hover.active {
background-color: #003399 \9;
}
.datepicker td span {
display: block;
width: 47px;
height: 54px;
line-height: 54px;
float: left;
margin: 2px;
cursor: pointer;
-webkit-border-radius: 4px;
-moz-border-radius: 4px;
border-radius: 4px;
}
.datepicker td span:hover {
background: #eeeeee;
}
.datepicker td span.disabled,
.datepicker td span.disabled:hover {
background: none;
color: #999999;
cursor: default;
}
.datepicker td span.active,
.datepicker td span.active:hover,
.datepicker td span.active.disabled,
.datepicker td span.active.disabled:hover {
background-color: #006dcc;
background-image: -moz-linear-gradient(top, #0088cc, #0044cc);
background-image: -ms-linear-gradient(top, #0088cc, #0044cc);
background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#0088cc), to(#0044cc));
background-image: -webkit-linear-gradient(top, #0088cc, #0044cc);
background-image: -o-linear-gradient(top, #0088cc, #0044cc);
background-image: linear-gradient(top, #0088cc, #0044cc);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#0088cc', endColorstr='#0044cc', GradientType=0);
border-color: #0044cc #0044cc #002a80;
border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);
filter: progid:dximagetransform.microsoft.gradient(enabled=false);
color: #fff;
text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);
}
.datepicker td span.active:hover,
.datepicker td span.active:hover:hover,
.datepicker td span.active.disabled:hover,
.datepicker td span.active.disabled:hover:hover,
.datepicker td span.active:active,
.datepicker td span.active:hover:active,
.datepicker td span.active.disabled:active,
.datepicker td span.active.disabled:hover:active,
.datepicker td span.active.active,
.datepicker td span.active:hover.active,
.datepicker td span.active.disabled.active,
.datepicker td span.active.disabled:hover.active,
.datepicker td span.active.disabled,
.datepicker td span.active:hover.disabled,
.datepicker td span.active.disabled.disabled,
.datepicker td span.active.disabled:hover.disabled,
.datepicker td span.active[disabled],
.datepicker td span.active:hover[disabled],
.datepicker td span.active.disabled[disabled],
.datepicker td span.active.disabled:hover[disabled] {
background-color: #0044cc;
}
.datepicker td span.active:active,
.datepicker td span.active:hover:active,
.datepicker td span.active.disabled:active,
.datepicker td span.active.disabled:hover:active,
.datepicker td span.active.active,
.datepicker td span.active:hover.active,
.datepicker td span.active.disabled.active,
.datepicker td span.active.disabled:hover.active {
background-color: #003399 \9;
}
.datepicker td span.old {
color: #999999;
}
.datepicker th.switch {
width: 145px;
}
.datepicker thead tr:first-child th {
cursor: pointer;
}
.datepicker thead tr:first-child th:hover {
background: #eeeeee;
}
.input-append.date .add-on i,
.input-prepend.date .add-on i {
display: block;
cursor: pointer;
width: 16px;
height: 16px;
}

View file

@ -1,19 +1,29 @@
@import "bootstrap.min.css"; @import "bootstrap.min.css";
@import "datepicker.css"; @import "bootstrap-datepicker3.standalone.css";
@import "../fonts/fontfaces.css"; @import "../fonts/fontfaces.css";
/* General */ /* General */
body { body {
margin-top: 40px; /* For fixed navbar */
padding-top: 3.5rem;
padding-bottom: 2rem;
} }
/* Navbar */ /* Navbar */
.navbar h1{ margin-left: 75px; } .navbar h1 {
font-size: 1rem;
margin: 0;
padding: 0;
}
.navbar .primary-nav { padding-left: 75px; } .navbar .primary-nav { padding-left: 75px; }
.navbar .secondary-nav { padding-right: 75px; } .navbar .secondary-nav {
.brand{ font-family: 'Lobster', arial, serif; } text-align: right;
flex-direction: row-reverse;
}
.navbar-brand{ font-family: 'Lobster', arial, serif; }
/* Header */ /* Header */
@ -36,6 +46,14 @@ body {
#header .tryout { #header .tryout {
margin-right: 10em; margin-right: 10em;
color: #fff;
background-color: #414141;
border-color: #414141;
}
#header .tryout:hover {
background-color: #606060;
border-color: #606060;
} }
#header .additional-content { #header .additional-content {
@ -55,28 +73,20 @@ body {
background-position: center bottom; background-position: center bottom;
background-repeat: no-repeat; background-repeat: no-repeat;
height: 100%; height: 100%;
width: 230px;
padding-left: 10px;
padding-right: 20px;
padding-top: 10px;
margin-left: -20px;
margin-top: -10px;
margin-right: 15px;
color: black; color: black;
position: fixed; position: fixed;
} }
#add-member-form { padding-top: 1em; padding-bottom: 1em; } #add-member-form { padding-top: 1em; padding-bottom: 1em; }
#add-member-form input[type="text"] { width: 60%; } #add-member-form input[type="text"] { width: 60%; }
#add-member-form button { width: 35%; }
#table_overflow { overflow-y: auto; overflow-x: hidden; width: 235px; } #table_overflow { overflow-y: auto; overflow-x: hidden;}
/* Content */ /* Content */
.content { .content {
padding-top: 1em; margin-top: 1rem;
padding-left: 250px;
} }
/* Home */ /* Home */
@ -94,7 +104,9 @@ body {
height: 100px; height: 100px;
} }
#footer{ footer{
margin-left: -15px;
margin-right: -15px;
margin-top: 30px; margin-top: 30px;
position: fixed; position: fixed;
bottom: 0px; bottom: 0px;
@ -109,6 +121,16 @@ body {
float: right; float: right;
} }
#new-bill, .identifier {
margin-top: 16px;
margin-bottom: 16px;
}
/* Avoid text color flickering when it loose focus as the modal appears */
.btn-primary[data-toggle="modal"] {
color: #fff;
}
.password-reminder{ .password-reminder{
float: right; float: right;
margin-right: 20px; margin-right: 20px;
@ -186,6 +208,10 @@ tr.payer_line .balance-name{
color: white; color: white;
} }
.balance.table {
table-layout: fixed;
}
#bill-form > fieldset { #bill-form > fieldset {
margin-top: 10px; margin-top: 10px;
} }
@ -206,10 +232,6 @@ tr:hover .extra-info {
display: inline; display: inline;
} }
.modal-body {
max-height:455px;
}
/* Fluid Offsets for Boostrap */ /* Fluid Offsets for Boostrap */
.row-fluid > [class*="span"]:not([class*="offset"]):first-child{margin-left:0;} .row-fluid > [class*="span"]:not([class*="offset"]):first-child{margin-left:0;}

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,2 @@
!function(a){a.fn.datepicker.dates.fr={days:["dimanche","lundi","mardi","mercredi","jeudi","vendredi","samedi"],daysShort:["dim.","lun.","mar.","mer.","jeu.","ven.","sam."],daysMin:["d","l","ma","me","j","v","s"],months:["janvier","février","mars","avril","mai","juin","juillet","août","septembre","octobre","novembre","décembre"],monthsShort:["janv.","févr.","mars","avril","mai","juin","juil.","août","sept.","oct.","nov.","déc."],today:"Aujourd'hui",monthsTitle:"Mois",clear:"Effacer",weekStart:1,format:"dd/mm/yyyy"}}(jQuery);
console.log("foo", jQuery.fn.datepicker.dates);

File diff suppressed because one or more lines are too long

View file

@ -1,5 +1,5 @@
{% for field_name, field_errors in form.errors.items() if field_errors %} {% for field_name, field_errors in form.errors.items() if field_errors %}
{% for error in field_errors %} {% for error in field_errors %}
<p class="error">{{ form[field_name].label.text }}: {{ error }}</p> <p class="alert alert-danger"><strong>{{ form[field_name].label.text }}:</strong> {{ error }}</p>
{% endfor %} {% endfor %}
{% endfor %} {% endfor %}

View file

@ -10,6 +10,10 @@
{% block content %} {% block content %}
<h2>{{ _("Edit this project") }}</h2> <h2>{{ _("Edit this project") }}</h2>
<form class="form-horizontal" method="post"> <form class="form-horizontal" method="post">
{{ forms.edit_project(form) }} {{ forms.edit_project(edit_form) }}
</form></br>
<h2>{{ _("Download this project's data") }}</h2>
<form class="form-horizontal" method="post">
{{ forms.export_project(export_form) }}
</form> </form>
{% endblock %} {% endblock %}

View file

@ -1,9 +1,13 @@
{% macro input(field, multiple=False, class=None) -%} {% macro input(field, multiple=False, class='form-control', inline=False) -%}
<div class="control-group"> <div class="form-group{% if inline %} row{% endif %}">
{% if field.type != "SubmitField" %} {% if field.type != "SubmitField" %}
{{ field.label(class="control-label") }} {% if inline %}
{{ field.label(class="col-3") }}
{% else %}
{{ field.label() }}
{% endif %} {% endif %}
<div class="controls"> {% endif %}
<div class="controls{% if inline %} col-9{% endif %}">
{% if multiple == True %} {% if multiple == True %}
{{ field(multiple=True, class=class) }} {{ field(multiple=True, class=class) }}
{% else %} {% else %}
@ -77,11 +81,22 @@
{% if title %}<legend>{% if edit %}{{ _("Edit this bill") }} {% else %}{{ _("Add a bill") }} {% endif %}</legend>{% endif %} {% if title %}<legend>{% if edit %}{{ _("Edit this bill") }} {% else %}{{ _("Add a bill") }} {% endif %}</legend>{% endif %}
{% include "display_errors.html" %} {% include "display_errors.html" %}
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
{{ input(form.date, class="datepicker") }} {{ input(form.date, class="form-control datepicker", inline=True) }}
{{ input(form.what) }} {{ input(form.what, inline=True) }}
{{ input(form.payer) }} {{ input(form.payer, inline=True, class="form-control custom-select") }}
{{ input(form.amount) }} {{ input(form.amount, inline=True) }}
{{ input(form.payed_for) }}
<div class="form-group row">
<label class="col-3" for="payed_for">{{ _("For whom?") }}</label>
<div class="controls col-9">
<ul id="payed_for" class="inputs-list">
<p><a href="#" id="selectall" onclick="selectall()">{{ _("Select all") }}</a> | <a href="#" id="selectnone" onclick="selectnone()">{{_("Select none")}}</a></p>
{% for key, value, checked in form.payed_for.iter_choices() %}
<p class="form-check"><label for="payed_for-{{key}}" class="form-check-label"><input name="payed_for" type="checkbox" {% if checked %}checked{% endif %} class="form-check-input" value="{{key}}" id="payed_for-{{key}}"/><span>{{value}}</span></label></p>
{% endfor %}
</ul>
</div>
</div>
</fieldset> </fieldset>
<div class="actions"> <div class="actions">
{{ form.submit(class="btn btn-primary") }} {{ form.submit(class="btn btn-primary") }}
@ -92,7 +107,11 @@
{% macro add_member(form) %} {% macro add_member(form) %}
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
{{ form.name(placeholder=_("Type user name here")) }}<button class="btn">{{ _("Add") }}</button> <div class="input-group">
<label class="sr-only" for="name">_("Type user name here")</label>
{{ form.name(placeholder=_("Type user name here"), class="form-control") }}
<button class=" input-group-addon btn">{{ _("Add") }}</button>
</div>
{% endmacro %} {% endmacro %}
{% macro edit_member(form, title=True) %} {% macro edit_member(form, title=True) %}
@ -131,6 +150,17 @@
</div> </div>
{% endmacro %} {% endmacro %}
{% macro export_project(form) %}
<fieldset>
{{ form.hidden_tag() }}
{{ input(form.export_type) }}
{{ input(form.export_format) }}
</fieldset>
<div class="actions">
<button class="btn btn-primary">{{ _("Download") }}</button>
</div>
{% endmacro %}
{% macro remind_password(form) %} {% macro remind_password(form) %}
{% include "display_errors.html" %} {% include "display_errors.html" %}

View file

@ -1,26 +1,21 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block header %} {% block body %}
<div id="header" class="container-fluid"> <header id="header" class="row">
<div class="row-fluid"> <div class="col-5 offset-md-2">
<div class="span5 offset2">
<h2>{{ _("Manage your shared <br>expenses, easily") }}</h2> <h2>{{ _("Manage your shared <br>expenses, easily") }}</h2>
<a href="{{ url_for(".demo") }}" class="tryout btn btn-inverse pull-right">{{ _("Try out the demo") }}</a> <a href="{{ url_for(".demo") }}" class="tryout btn">{{ _("Try out the demo") }}</a>
</div> </div>
<div class="span4"> <div class="col-4">
<p class="additional-content">{{ _("You're sharing a house?") }}<br /> {{ _("Going on holidays with friends?") }}<br /> {{ _("Simply sharing money with others?") }} <br /><strong>{{ _("We can help!") }}</strong></p> <p class="additional-content">{{ _("You're sharing a house?") }}<br /> {{ _("Going on holidays with friends?") }}<br /> {{ _("Simply sharing money with others?") }} <br /><strong>{{ _("We can help!") }}</strong></p>
</div> </div>
</div> </header>
</div>
{% endblock %}
{% block body %} <main class="row home">
<div class="container-fluid"> <div class="col-4 offset-md-2">
<div class="row-fluid home">
<div class="span4 offset2">
<form id="authentication-form" class="form-horizontal" action="{{ url_for(".authenticate") }}" method="post"> <form id="authentication-form" class="form-horizontal" action="{{ url_for(".authenticate") }}" method="post">
<fieldset> <fieldset class="form-group">
<legend>{{ _("Log to an existing project") }}...</legend> <legend>{{ _("Log to an existing project") }}...</legend>
{{ forms.authenticate(auth_form, home=True) }} {{ forms.authenticate(auth_form, home=True) }}
</fieldset> </fieldset>
@ -30,9 +25,9 @@
</div> </div>
</form> </form>
</div> </div>
<div class="span4"> <div class="col-3 offset-md-1">
<form id="creation-form" class="form-horizontal" action="{{ url_for(".create_project") }}" method="post"> <form id="creation-form" class="form-horizontal" action="{{ url_for(".create_project") }}" method="post">
<fieldset> <fieldset class="form-group">
<legend>...{{ _("or create a new one") }}</legend> <legend>...{{ _("or create a new one") }}</legend>
{{ forms.create_project(project_form, home=True) }} {{ forms.create_project(project_form, home=True) }}
</fieldset> </fieldset>
@ -40,9 +35,8 @@
<button class="btn" type="submit">{{ _("let's get started") }}</button> <button class="btn" type="submit">{{ _("let's get started") }}</button>
</div> </div>
</form> </form>
</main>
</div> </div>
</div>
</div>
{% endblock %} {% endblock %}
{% block js %} {% block js %}

View file

@ -5,15 +5,15 @@
<title>{{ _("Account manager") }}{% block title %}{% endblock %}</title> <title>{{ _("Account manager") }}{% block title %}{% endblock %}</title>
<meta http-equiv="content-type" content="text/html; charset=utf-8"> <meta http-equiv="content-type" content="text/html; charset=utf-8">
<link rel=stylesheet type=text/css href="{{ url_for("static", filename='css/main.css') }}"> <link rel=stylesheet type=text/css href="{{ url_for("static", filename='css/main.css') }}">
<script src="{{ url_for("static", filename="js/jquery-1.7.2.min.js") }}"></script> <script src="{{ url_for("static", filename="js/jquery-3.1.1.min.js") }}"></script>
<script src="{{ url_for("static", filename="js/ihatemoney.js") }}"></script> <script src="{{ url_for("static", filename="js/ihatemoney.js") }}"></script>
<script src="{{ url_for("static", filename="js/tether.min.js") }}"></script>
<script src="{{ url_for("static", filename="js/bootstrap.min.js") }}"></script> <script src="{{ url_for("static", filename="js/bootstrap.min.js") }}"></script>
<script src="{{ url_for("static", filename="js/bootstrap-datepicker.js") }}"></script>
{% block head %}{% endblock %} {% block head %}{% endblock %}
<script type="text/javascript" charset="utf-8"> <script type="text/javascript" charset="utf-8">
$(document).ready(function(){ $(document).ready(function(){
var left = window.innerWidth/2-$('.flash').width()/2; var left = window.innerWidth/2-$('.flash').width()/2;
$(".flash").css({ "left": left+"px", "top":"45px" }); $(".flash").css({ "left": left+"px", "top":"0.6rem" });
setTimeout(function(){ setTimeout(function(){
$(".flash").fadeOut("slow", function () { $(".flash").fadeOut("slow", function () {
$(".flash").remove(); $(".flash").remove();
@ -35,62 +35,58 @@
</head> </head>
<body> <body>
<div class="navbar navbar-fixed-top"> <nav class="navbar navbar-toggleable-md navbar fixed-top navbar-inverse bg-inverse">
<div class="navbar-inner"> <h1 class="col-2"><a class="navbar-brand" href="{{ url_for(".home") }}">#! money?</a></h1>
<div class="container-fluid"> <ul class="navbar-nav col-5 offset-md-1">
<h1><a class="brand" href="{{ url_for(".home") }}">#! money?</a></h1>
{% if g.project %} {% if g.project %}
<ul class="nav primary-nav">
{% block navbar %} {% block navbar %}
<li class="active"><a href="{{ url_for(".list_bills") }}">{{ _("Bills") }}</a></li> <li class="nav-item{% if current_view == 'list_bills' %} active{% endif %}"><a class="nav-link" href="{{ url_for(".list_bills") }}">{{ _("Bills") }}</a></li>
<li><a href="{{ url_for(".settle_bill") }}">{{ _("Settle") }}</a></li> <li class="nav-item{% if current_view == 'settle_bill' %} active{% endif %}"><a class="nav-link" href="{{ url_for(".settle_bill") }}">{{ _("Settle") }}</a></li>
{% endblock %} {% endblock %}
</ul>
{% endif %} {% endif %}
<ul class="nav pull-right secondary-nav"> </ul>
<ul class="navbar-nav secondary-nav col-4">
{% if g.project %} {% if g.project %}
<li class="dropdown"> <li class="nav-item dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown"><strong>{{ g.project.name }}</strong> {{ _("options") }} <b class="caret"></b></a> <a href="#" class="nav-link dropdown-toggle" id="navbarDropdownMenuLink" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"><strong>{{ g.project.name }}</strong> {{ _("options") }} <b class="caret"></b></a>
<ul class="dropdown-menu"> <ul class="dropdown-menu" aria-labelledby="navbarDropdownMenuLink">
<li><a href="{{ url_for(".edit_project") }}">{{ _("Project settings") }}</a></li> <li><a class="dropdown-item" href="{{ url_for(".edit_project") }}">{{ _("Project settings") }}</a></li>
<li class="divider"></li> <li class="dropdown-divider"></li>
{% for id, name in session['projects'] %} {% for id, name in session['projects'] %}
{% if id != g.project.id %} {% if id != g.project.id %}
<li><a href="{{ url_for(".list_bills", project_id=id) }}">{{ _("switch to") }} {{ name }}</a></li> <li><a class="dropdown-item" href="{{ url_for(".list_bills", project_id=id) }}">{{ _("switch to") }} {{ name }}</a></li>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<li><a href="{{ url_for(".create_project") }}">{{ _("Start a new project") }}</a></li> <li><a class="dropdown-item" href="{{ url_for(".create_project") }}">{{ _("Start a new project") }}</a></li>
<li class="divider"></li> <li class="dropdown-divider"></li>
<li><a href="{{ url_for(".exit") }}">{{ _("Logout") }}</a></li> <li><a class="dropdown-item" href="{{ url_for(".exit") }}">{{ _("Logout") }}</a></li>
</ul> </ul>
</li> </li>
{% endif %} {% endif %}
<li{% if g.lang == "fr" %} class="active"{% endif %}><a href="{{ url_for(".change_lang", lang="fr") }}">fr</a></li> <li class="nav-item{% if g.lang == "fr" %} active{% endif %}"><a class="nav-link" href="{{ url_for(".change_lang", lang="fr") }}">fr</a></li>
<li{% if g.lang == "en" %} class="active"{% endif %}><a href="{{ url_for(".change_lang", lang="en") }}">en</a></li> <li class="nav-item{% if g.lang == "en" %} active{% endif %}"><a class="nav-link" href="{{ url_for(".change_lang", lang="en") }}">en</a></li>
</ul> </ul>
</div>
</div>
</div>
{% block header %}{% endblock %}
{% block body %} </nav>
<div class="container-fluid"> <div class="container-fluid">
{% block body %}
{% block sidebar %}{% endblock %} {% block sidebar %}{% endblock %}
<div class="content"> <main class="content offset-1 col-10">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </main>
</div> </div>
{% endblock %} {% endblock %}
{% for message in get_flashed_messages() %} {% for message in get_flashed_messages() %}
<div class="flash alert alert-success"><p>{{ message }}</p></div> <div class="flash alert alert-success">{{ message }}</div>
{% endfor %} {% endfor %}
{% block footer %} {% block footer %}
<div id="footer"> <footer>
<p><a href="https://github.com/spiral-project/ihatemoney">{{ _("This is a free software") }}</a>, {{ _("you can contribute and improve it!") }}</p> <p><a href="https://github.com/spiral-project/ihatemoney">{{ _("This is a free software") }}</a>, {{ _("you can contribute and improve it!") }}</p>
</div> </footer>
{% endblock %} {% endblock %}
</body> </body>

View file

@ -1,16 +1,11 @@
{% extends "layout.html" %} {% extends "sidebar_table_layout.html" %}
{% block title %} - {{ g.project.name }}{% endblock %} {% block title %} - {{ g.project.name }}{% endblock %}
{% block head %} {% block head %}
<script src="{{ url_for("static", filename="js/bootstrap-datepicker.js") }}"></script> <script src="{{ url_for("static", filename="js/bootstrap-datepicker.js") }}"></script>
<script src="{{ url_for("static", filename="js/locales/bootstrap-datepicker.fr.min.js") }}" charset="utf-8"></script>
{% endblock %} {% endblock %}
{% block js %} {% block js %}
$(window).resize(function() {
$("#sidebar").height( window.innerHeight-50 );
$("#table_overflow").height( $("#sidebar").height()-120 );
});
{% if add_bill %} $('#new-bill').click(); {% endif %} {% if add_bill %} $('#new-bill').click(); {% endif %}
// Hide all members actions // Hide all members actions
@ -56,16 +51,14 @@
{% endblock %} {% endblock %}
{% block sidebar %} {% block sidebar %}
<div id="sidebar" class="sidebar"> <form id="add-member-form" action="{{ url_for(".add_member") }}" method="post" class="form-inline">
<form id="add-member-form" action="{{ url_for(".add_member") }}" method="post" class="form-inline input-append">
{{ forms.add_member(member_form) }} {{ forms.add_member(member_form) }}
</form> </form>
<div id="table_overflow"> <div id="table_overflow">
<table class="balance table"> <table class="balance table">
{% set balance = g.project.balance %} {% set balance = g.project.balance %}
{% for member in g.project.members | sort(attribute='name') if member.activated or balance[member.id] != 0 %} {% for member in g.project.members | sort(attribute='name') if member.activated or balance[member.id]|round(2) != 0 %}
<tr id="bal-member-{{ member.id }}" action={% if member.activated %}delete{% else %}reactivate{% endif %}> <tr id="bal-member-{{ member.id }}" action={% if member.activated %}delete{% else %}reactivate{% endif %}>
<td class="balance-name">{{ member.name }} <td class="balance-name">{{ member.name }}
<span class="light{% if not g.project.uses_weights %} extra-info{% endif %}">(x{{ member.weight|minimal_round(1) }})</span> <span class="light{% if not g.project.uses_weights %} extra-info{% endif %}">(x{{ member.weight|minimal_round(1) }})</span>
@ -82,37 +75,39 @@
<form class="action reactivate" action="{{ url_for(".reactivate", member_id=member.id) }}" method="POST"> <form class="action reactivate" action="{{ url_for(".reactivate", member_id=member.id) }}" method="POST">
<button type="submit">{{ _("reactivate") }}</button></form></td> <button type="submit">{{ _("reactivate") }}</button></form></td>
{% endif %} {% endif %}
<td class="balance-value {% if balance[member.id] > 0 %}positive{% elif balance[member.id] < 0 %}negative{% endif %}"> <td class="balance-value {% if balance[member.id]|round(2) > 0 %}positive{% elif balance[member.id]|round(2) < 0 %}negative{% endif %}">
{% if balance[member.id] > 0 %}+{% endif %}{{ "%.2f" | format(balance[member.id]) }} {% if balance[member.id]|round(2) > 0 %}+{% endif %}{{ "%.2f" | format(balance[member.id]) }}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
</div> </div>
</div>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="identifier">{{ _("The project identifier is") }} <a href="{{ url_for(".list_bills") }}">{{ g.project.id }}</a>, {{ _("remember it!") }}</div> <div class="identifier">{{ _("The project identifier is") }} <a href="{{ url_for(".list_bills") }}">{{ g.project.id }}</a>, {{ _("remember it!") }}</div>
<a id="new-bill" href="{{ url_for(".add_bill") }}" class="btn btn-primary" data-toggle="modal" data-target="#bill-form">{{ _("Add a new bill") }}</a> <a id="new-bill" href="{{ url_for(".add_bill") }}" class="btn btn-primary" data-toggle="modal" data-target="#bill-form">{{ _("Add a new bill") }}</a>
<div id="bill-form" class="modal hide"> <div id="bill-form" class="modal fade show" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h3 class="modal-title">{{ _('Add a bill') }}</h3>
<a href="#" class="close" data-dismiss="modal">&times;</a> <a href="#" class="close" data-dismiss="modal">&times;</a>
<h3>{{ _('Add a bill') }}</h3>
</div> </div>
<form action="{{ url_for(".add_bill") }}" method="post" class="modal-body form-horizontal"> <form action="{{ url_for(".add_bill") }}" method="post" class="modal-body container">
{{ forms.add_bill(bill_form, title=False) }} {{ forms.add_bill(bill_form, title=False) }}
</form> </form>
</div> </div>
</div>
</div>
{% if bills.count() > 0 %} {% if bills.count() > 0 %}
<table id="bill_table" class="table table-striped"> <table id="bill_table" class="col table table-striped table-hover">
<thead><tr><th>{{ _("When?") }}</th><th>{{ _("Who paid?") }}</th><th>{{ _("For what?") }}</th><th>{{ _("For whom?") }}</th><th>{{ _("How much?") }}</th><th>{{ _("Actions") }}</th></tr></thead> <thead><tr><th>{{ _("When?") }}</th><th>{{ _("Who paid?") }}</th><th>{{ _("For what?") }}</th><th>{{ _("For whom?") }}</th><th>{{ _("How much?") }}</th><th>{{ _("Actions") }}</th></tr></thead>
<tbody> <tbody>
{% for bill in bills %} {% for bill in bills %}
<tr class="{{ loop.cycle("odd", "even") }}" owers={{bill.owers|join(',','id')}} payer={{bill.payer.id}}> <tr owers="{{bill.owers|join(',','id')}}" payer="{{bill.payer.id}}">
<td>{{ bill.date }}</td> <td>{{ bill.date }}</td>
<td>{{ bill.payer }}</td> <td>{{ bill.payer }}</td>
<td>{{ bill.what }}</td> <td>{{ bill.what }}</td>

View file

@ -1,28 +1,19 @@
{% extends "layout.html" %} {% extends "sidebar_table_layout.html" %}
{% block navbar %}
<li><a href="{{ url_for(".list_bills") }}">{{ _("Bills") }}</a></li>
<li class="active"><a href="{{ url_for(".settle_bill") }}">{{ _("Settle") }}</a></li>
{% endblock %}
{% block sidebar %} {% block sidebar %}
<div id="sidebar" class="sidebar">
<div id="table_overflow"> <div id="table_overflow">
<table class="balance table"> <table class="balance table">
{% set balance = g.project.balance %} {% set balance = g.project.balance %}
{% for member in g.project.members | sort(attribute='name') if member.activated or balance[member.id] != 0 %} {% for member in g.project.members | sort(attribute='name') if member.activated or balance[member.id]|round(2) != 0 %}
<tr id="bal-member-{{ member.id }}" action={% if member.activated %}delete{% else %}reactivate{% endif %}> <tr id="bal-member-{{ member.id }}" action={% if member.activated %}delete{% else %}reactivate{% endif %}>
<td class="balance-name">{{ member.name }}</td> <td class="balance-name">{{ member.name }}</td>
<td class="balance-value {% if balance[member.id] > 0 %}positive{% elif balance[member.id] < 0 %}negative{% endif %}"> <td class="balance-value {% if balance[member.id]|round(2) > 0 %}positive{% elif balance[member.id]|round(2) < 0 %}negative{% endif %}">
{% if balance[member.id] > 0 %}+{% endif %}{{ "%.2f" | format(balance[member.id]) }} {% if balance[member.id]|round(2) > 0 %}+{% endif %}{{ "%.2f" | format(balance[member.id]) }}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
</div> </div>
</div>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,14 @@
{% extends "layout.html" %}
{% block body %}
<div class="row" style="height: 100%">
<aside id="sidebar" class="sidebar col-3 " style="height: 100%">
{% block sidebar %}{% endblock %}
</aside>
<main class="offset-md-3 col-9">
{% block content %}{% endblock %}
</main>
</div>
{% endblock %}

View file

@ -398,12 +398,10 @@ class BudgetTestCase(TestCase):
'what': u'fromage à raclette', 'what': u'fromage à raclette',
'payer': members_ids[0], 'payer': members_ids[0],
'payed_for': members_ids, 'payed_for': members_ids,
# bill with a negative value should be converted to a positive
# value
'amount': '-25' 'amount': '-25'
}) })
bill = models.Bill.query.filter(models.Bill.date == '2011-08-12')[0] bill = models.Bill.query.filter(models.Bill.date == '2011-08-12')[0]
self.assertEqual(bill.amount, 25) self.assertEqual(bill.amount, -25)
#add a bill with a comma #add a bill with a comma
self.app.post("/raclette/add", data={ self.app.post("/raclette/add", data={
@ -500,7 +498,10 @@ class BudgetTestCase(TestCase):
result[models.Project.query.get("raclette").members[0].id] = 8.12 result[models.Project.query.get("raclette").members[0].id] = 8.12
result[models.Project.query.get("raclette").members[1].id] = 0.0 result[models.Project.query.get("raclette").members[1].id] = 0.0
result[models.Project.query.get("raclette").members[2].id] = -8.12 result[models.Project.query.get("raclette").members[2].id] = -8.12
self.assertDictEqual(balance, result) # Since we're using floating point to store currency, we can have some rounding issues that prevent test from working.
# However, we should obtain the same values as the theorical ones if we round to 2 decimals, like in the UI.
for key, value in balance.iteritems():
self.assertEqual(round(value, 2), result[key])
def test_edit_project(self): def test_edit_project(self):
# A project should be editable # A project should be editable
@ -582,6 +583,156 @@ class BudgetTestCase(TestCase):
self.assertEqual(a, balance[m.id]) self.assertEqual(a, balance[m.id])
return return
def test_settle_zero(self):
self.post_project("raclette")
# add members
self.app.post("/raclette/members/add", data={'name': 'alexis'})
self.app.post("/raclette/members/add", data={'name': 'fred'})
self.app.post("/raclette/members/add", data={'name': 'tata'})
# create bills
self.app.post("/raclette/add", data={
'date': '2016-12-31',
'what': u'fromage à raclette',
'payer': 1,
'payed_for': [1, 2, 3],
'amount': '10.0',
})
self.app.post("/raclette/add", data={
'date': '2016-12-31',
'what': u'red wine',
'payer': 2,
'payed_for': [1, 3],
'amount': '20',
})
self.app.post("/raclette/add", data={
'date': '2017-01-01',
'what': u'refund',
'payer': 3,
'payed_for': [2],
'amount': '13.33',
})
project = models.Project.query.get('raclette')
transactions = project.get_transactions_to_settle_bill()
members = defaultdict(int)
# There should not be any zero-amount transfer after rounding
for t in transactions:
rounded_amount = round(t['amount'], 2)
self.assertNotEqual(0.0, rounded_amount,
msg='%f is equal to zero after rounding' % t['amount'])
def test_export(self):
self.post_project("raclette")
# add members
self.app.post("/raclette/members/add", data={'name': 'alexis', 'weight': 2})
self.app.post("/raclette/members/add", data={'name': 'fred'})
self.app.post("/raclette/members/add", data={'name': 'tata'})
self.app.post("/raclette/members/add", data={'name': 'pépé'})
# create bills
self.app.post("/raclette/add", data={
'date': '2016-12-31',
'what': u'fromage à raclette',
'payer': 1,
'payed_for': [1, 2, 3, 4],
'amount': '10.0',
})
self.app.post("/raclette/add", data={
'date': '2016-12-31',
'what': u'red wine',
'payer': 2,
'payed_for': [1, 3],
'amount': '200',
})
self.app.post("/raclette/add", data={
'date': '2017-01-01',
'what': u'refund',
'payer': 3,
'payed_for': [2],
'amount': '13.33',
})
# generate json export of bills
resp = self.app.post("/raclette/edit", data={
'export_format': 'json',
'export_type': 'bills'
})
expected = [{u'date': u'2017-01-01', u'what': u'refund',
u'amount': 13.33, u'payer_name': u'tata', u'payer_weight': 1.0, u'owers': [u'fred']},
{u'date': u'2016-12-31', u'what': u'red wine',
u'amount': 200.0, u'payer_name': u'fred', u'payer_weight': 1.0, u'owers': [u'alexis', u'tata']},
{u'date': u'2016-12-31', u'what': u'fromage \xe0 raclette',
u'amount': 10.0, u'payer_name': u'alexis', u'payer_weight': 2.0, u'owers': [u'alexis', u'fred', u'tata', u'p\xe9p\xe9']}]
self.assertEqual(json.loads(resp.data), expected)
# generate csv export of bills
resp = self.app.post("/raclette/edit", data={
'export_format': 'csv',
'export_type': 'bills'
})
expected = ["date,what,amount,payer_name,payer_weight,owers",
"2017-01-01,refund,13.33,tata,1.0,fred",
"2016-12-31,red wine,200.0,fred,1.0,\"alexis, tata\"",
"2016-12-31,fromage à raclette,10.0,alexis,2.0,\"alexis, fred, tata, pépé\""]
received_lines = resp.data.split("\n")
for i, line in enumerate(expected):
self.assertEqual(
set(line.split(",")),
set(received_lines[i].strip("\r").split(","))
)
# generate json export of transactions
resp = self.app.post("/raclette/edit", data={
'export_format': 'json',
'export_type': 'transactions'
})
expected = [{u"amount": 127.33, u"receiver": u"fred", u"ower": u"alexis"},
{u"amount": 55.34, u"receiver": u"fred", u"ower": u"tata"},
{u"amount": 2.00, u"receiver": u"fred", u"ower": u"p\xe9p\xe9"}]
self.assertEqual(json.loads(resp.data), expected)
# generate csv export of transactions
resp = self.app.post("/raclette/edit", data={
'export_format': 'csv',
'export_type': 'transactions'
})
expected = ["amount,receiver,ower",
"127.33,fred,alexis",
"55.34,fred,tata",
"2.0,fred,pépé"]
received_lines = resp.data.split("\n")
for i, line in enumerate(expected):
self.assertEqual(
set(line.split(",")),
set(received_lines[i].strip("\r").split(","))
)
# wrong export_format should return a 200 and export form
resp = self.app.post("/raclette/edit", data={
'export_format': 'wrong_export_format',
'export_type': 'transactions'
})
self.assertEqual(resp.status_code, 200)
self.assertIn('id="export_format" name="export_format"', resp.data)
# wrong export_type should return a 200 and export form
resp = self.app.post("/raclette/edit", data={
'export_format': 'json',
'export_type': 'wrong_export_type'
})
self.assertEqual(resp.status_code, 200)
self.assertIn('id="export_format" name="export_format"', resp.data)
class APITestCase(TestCase): class APITestCase(TestCase):
@ -870,6 +1021,18 @@ class APITestCase(TestCase):
headers=self.get_auth("raclette")) headers=self.get_auth("raclette"))
self.assertStatus(404, req) self.assertStatus(404, req)
def test_username_xss(self):
# create a project
#self.api_create("raclette")
self.post_project("raclette")
self.login("raclette")
# add members
self.api_add_member("raclette", "<script>")
result = self.app.get('/raclette/')
self.assertNotIn("<script>", result.data)
def test_weighted_bills(self): def test_weighted_bills(self):
# create a project # create a project
self.api_create("raclette") self.api_create("raclette")
@ -936,6 +1099,7 @@ class ServerTestCase(APITestCase):
super(ServerTestCase, self).setUp() super(ServerTestCase, self).setUp()
def test_unprefixed(self): def test_unprefixed(self):
run.app.config['APPLICATION_ROOT'] = '/'
req = self.app.get("/foo/") req = self.app.get("/foo/")
self.assertStatus(303, req) self.assertStatus(303, req)

View file

@ -107,7 +107,7 @@ msgstr "Nom"
#: forms.py:155 #: forms.py:155
msgid "Weight" msgid "Weight"
msgstr "Poids" msgstr "Parts"
#: forms.py:155 templates/forms.html:95 #: forms.py:155 templates/forms.html:95
msgid "Add" msgid "Add"
@ -146,6 +146,22 @@ msgstr "Date de départ"
msgid "End date" msgid "End date"
msgstr "Date de fin" msgstr "Date de fin"
#: forms.py:202
msgid "What do you want to download ?"
msgstr "Que voulez-vous télécharger ?"
#: forms.py:205
msgid "bills"
msgstr "factures"
#: forms.py:205
msgid "transactions"
msgstr "remboursements"
#: forms.py:206
msgid "Export file format"
msgstr "Format du fichier d'export"
#: web.py:95 #: web.py:95
msgid "This private code is not the right one" msgid "This private code is not the right one"
msgstr "Le code que vous avez entré n'est pas correct" msgstr "Le code que vous avez entré n'est pas correct"
@ -292,6 +308,10 @@ msgstr "Ajouter une facture"
msgid "Type user name here" msgid "Type user name here"
msgstr "Nouveau participant" msgstr "Nouveau participant"
#: templates/forms.html:100
msgid "Edit this member"
msgstr "Éditer ce participant"
#: templates/forms.html:102 #: templates/forms.html:102
msgid "Send the invitations" msgid "Send the invitations"
msgstr "Envoyer les invitations" msgstr "Envoyer les invitations"
@ -308,6 +328,14 @@ msgstr "Créer une archive"
msgid "Create the archive" msgid "Create the archive"
msgstr "Créer l'archive" msgstr "Créer l'archive"
#: templates/forms.html:136
msgid "Download this project's data"
msgstr "Télécharger les données de ce projet"
#: templates/forms.html:136
msgid "Download"
msgstr "Télécharger"
#: templates/home.html:8 #: templates/home.html:8
msgid "Manage your shared <br>expenses, easily" msgid "Manage your shared <br>expenses, easily"
msgstr "Gérez vos dépenses<br> partagées, facilement" msgstr "Gérez vos dépenses<br> partagées, facilement"

View file

@ -2,8 +2,12 @@ import re
import inspect import inspect
from jinja2 import filters from jinja2 import filters
from json import dumps
from flask import redirect from flask import redirect
from werkzeug.routing import HTTPException, RoutingException from werkzeug.routing import HTTPException, RoutingException
from io import BytesIO
import csv
def slugify(value): def slugify(value):
@ -77,3 +81,30 @@ def minimal_round(*args, **kw):
# return depending on it # return depending on it
ires = int(res) ires = int(res)
return (res if res != ires else ires) return (res if res != ires else ires)
def list_of_dicts2json(dict_to_convert):
"""Take a list of dictionnaries and turns it into
a json in-memory file
"""
bytes_io = BytesIO()
bytes_io.write(dumps(dict_to_convert))
bytes_io.seek(0)
return bytes_io
def list_of_dicts2csv(dict_to_convert):
"""Take a list of dictionnaries and turns it into
a csv in-memory file, assume all dict have the same keys
"""
bytes_io = BytesIO()
try:
csv_data = [dict_to_convert[0].keys()]
for dic in dict_to_convert:
csv_data.append([dic[h].encode('utf8')
if isinstance(dic[h], unicode) else str(dic[h]).encode('utf8')
for h in dict_to_convert[0].keys()])
except (KeyError, IndexError):
csv_data = []
writer = csv.writer(bytes_io)
writer.writerows(csv_data)
bytes_io.seek(0)
return bytes_io

View file

@ -10,18 +10,19 @@ and `add_project_id` for a quick overview)
""" """
from flask import Blueprint, current_app, flash, g, redirect, \ from flask import Blueprint, current_app, flash, g, redirect, \
render_template, request, session, url_for render_template, request, session, url_for, send_file
from flask.ext.mail import Mail, Message from flask_mail import Mail, Message
from flask.ext.babel import get_locale, gettext as _ from flask_babel import get_locale, gettext as _
from smtplib import SMTPRecipientsRefused from smtplib import SMTPRecipientsRefused
import werkzeug import werkzeug
from sqlalchemy import orm
# local modules # local modules
from models import db, Project, Person, Bill from models import db, Project, Person, Bill
from forms import AuthenticationForm, CreateArchiveForm, EditProjectForm, \ from forms import AuthenticationForm, CreateArchiveForm, EditProjectForm, \
InviteForm, MemberForm, PasswordReminder, ProjectForm, get_billform_for InviteForm, MemberForm, PasswordReminder, ProjectForm, get_billform_for, \
from utils import Redirect303 ExportForm
from utils import Redirect303, list_of_dicts2json, list_of_dicts2csv
main = Blueprint("main", __name__) main = Blueprint("main", __name__)
mail = Mail() mail = Mail()
@ -196,20 +197,43 @@ def remind_password():
@main.route("/<project_id>/edit", methods=["GET", "POST"]) @main.route("/<project_id>/edit", methods=["GET", "POST"])
def edit_project(): def edit_project():
form = EditProjectForm() edit_form = EditProjectForm()
export_form = ExportForm()
if request.method == "POST": if request.method == "POST":
if form.validate(): if edit_form.validate():
project = form.update(g.project) project = edit_form.update(g.project)
db.session.commit() db.session.commit()
session[project.id] = project.password session[project.id] = project.password
return redirect(url_for(".list_bills")) 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) if export_form.validate():
export_format = export_form.export_format.data
export_type = export_form.export_type.data
if export_type == 'transactions':
export = g.project.get_transactions_to_settle_bill(
pretty_output=True)
if export_type == "bills":
export = g.project.get_pretty_bills(
export_format=export_format)
if export_format == "json":
file2export = list_of_dicts2json(export)
if export_format == "csv":
file2export = list_of_dicts2csv(export)
return send_file(file2export,
attachment_filename="%s-%s.%s" %
(g.project.name, export_type, export_format),
as_attachment=True
)
else:
edit_form.name.data = g.project.name
edit_form.password.data = g.project.password
edit_form.contact_email.data = g.project.contact_email
return render_template("edit_project.html", edit_form=edit_form, export_form=export_form)
@main.route("/<project_id>/delete") @main.route("/<project_id>/delete")
@ -277,12 +301,14 @@ def list_bills():
# set the last selected payer as default choice if exists # set the last selected payer as default choice if exists
if 'last_selected_payer' in session: if 'last_selected_payer' in session:
bill_form.payer.data = session['last_selected_payer'] bill_form.payer.data = session['last_selected_payer']
bills = g.project.get_bills() # Preload the "owers" relationship for all bills
bills = g.project.get_bills().options(orm.subqueryload(Bill.owers))
return render_template("list_bills.html", return render_template("list_bills.html",
bills=bills, member_form=MemberForm(g.project), bills=bills, member_form=MemberForm(g.project),
bill_form=bill_form, bill_form=bill_form,
add_bill=request.values.get('add_bill', False) add_bill=request.values.get('add_bill', False),
current_view="list_bills",
) )
@ -412,7 +438,11 @@ def change_lang(lang):
def settle_bill(): def settle_bill():
"""Compute the sum each one have to pay to each other and display it""" """Compute the sum each one have to pay to each other and display it"""
bills = g.project.get_transactions_to_settle_bill() bills = g.project.get_transactions_to_settle_bill()
return render_template("settle_bills.html", bills=bills) return render_template(
"settle_bills.html",
bills=bills,
current_view='settle_bill',
)
@main.route("/<project_id>/archives/create", methods=["GET", "POST"]) @main.route("/<project_id>/archives/create", methods=["GET", "POST"])

View file

@ -23,12 +23,9 @@ To interact with bills and members, and to do something else than creating
a project, you need to be authenticated. The only way to authenticate yourself a project, you need to be authenticated. The only way to authenticate yourself
currently is using the "basic" HTTP authentication. currently is using the "basic" HTTP authentication.
If you don't want your credentials to pass in clear trought the network, you
can use the ssl endpoint at https://ihatemoney.notmyidea.org
For instance, here is how to see the what's in a project, using curl:: For instance, here is how to see the what's in a project, using curl::
$ curl --basic -u demo:demo http://ihatemoney.notmyidea.org/api/projects/demo $ curl --basic -u demo:demo https://ihatemoney.org/api/projects/demo
Projects Projects
-------- --------
@ -50,7 +47,7 @@ A project needs the following arguments:
:: ::
$ curl -X POST https://ihatemoney.notmyidea.org/api/projects \ $ curl -X POST https://ihatemoney.org/api/projects \
-d 'name=yay&id=yay&password=yay&contact_email=yay@notmyidea.org' -d 'name=yay&id=yay&password=yay&contact_email=yay@notmyidea.org'
"yay" "yay"
@ -62,7 +59,7 @@ Getting information about the project
Getting information about the project:: Getting information about the project::
$ curl --basic -u demo:demo http://ihatemoney.notmyidea.org/api/projects/demo $ curl --basic -u demo:demo https://ihatemoney.org/api/projects/demo
{ {
"name": "demonstration", "name": "demonstration",
"contact_email": "demo@notmyidea.org", "contact_email": "demo@notmyidea.org",
@ -85,7 +82,7 @@ Updating a project
Updating a project is done with the `PUT` verb:: Updating a project is done with the `PUT` verb::
$ curl --basic -u yay:yay -X PUT\ $ curl --basic -u yay:yay -X PUT\
http://ihatemoney.notmyidea.org/api/projects/yay -d\ https://ihatemoney.org/api/projects/yay -d\
'name=yay&id=yay&password=yay&contact_email=youpi@notmyidea.org' 'name=yay&id=yay&password=yay&contact_email=youpi@notmyidea.org'
Deleting a project Deleting a project
@ -93,14 +90,14 @@ Deleting a project
Just send a DELETE request ont the project URI :: Just send a DELETE request ont the project URI ::
$ curl --basic -u demo:demo -X DELETE http://ihatemoney.notmyidea.org/api/projects/demo $ curl --basic -u demo:demo -X DELETE https://ihatemoney.org/api/projects/demo
Members Members
------- -------
You can get all the members with a `GET` on `/api/projects/<id>/members`:: You can get all the members with a `GET` on `/api/projects/<id>/members`::
$ curl --basic -u demo:demo http://ihatemoney.notmyidea.org/api/projects/demo/members\ $ curl --basic -u demo:demo https://ihatemoney.org/api/projects/demo/members\
[{"activated": true, "id": 31, "name": "Arnaud"}, [{"activated": true, "id": 31, "name": "Arnaud"},
{"activated": true, "id": 32, "name": "Alexis"}, {"activated": true, "id": 32, "name": "Alexis"},
{"activated": true, "id": 33, "name": "Olivier"}, {"activated": true, "id": 33, "name": "Olivier"},
@ -109,20 +106,20 @@ You can get all the members with a `GET` on `/api/projects/<id>/members`::
Add a member with a `POST` request on `/api/projects/<id>/members`:: Add a member with a `POST` request on `/api/projects/<id>/members`::
$ curl --basic -u demo:demo -X POST\ $ curl --basic -u demo:demo -X POST\
http://ihatemoney.notmyidea.org/api/projects/demo/members -d 'name=tatayoyo' https://ihatemoney.org/api/projects/demo/members -d 'name=tatayoyo'
35 35
You can also `PUT` a new version of a member (changing its name):: You can also `PUT` a new version of a member (changing its name)::
$ curl --basic -u demo:demo -X PUT\ $ curl --basic -u demo:demo -X PUT\
http://ihatemoney.notmyidea.org/api/projects/demo/members/36\ https://ihatemoney.org/api/projects/demo/members/36\
-d 'name=yeaaaaah' -d 'name=yeaaaaah'
{"activated": true, "id": 36, "name": "yeaaaaah"} {"activated": true, "id": 36, "name": "yeaaaaah"}
Delete a member with a `DELETE` request on `/api/projects/<id>/members/<member-id>`:: Delete a member with a `DELETE` request on `/api/projects/<id>/members/<member-id>`::
$ curl --basic -u demo:demo -X DELETE\ $ curl --basic -u demo:demo -X DELETE\
http://ihatemoney.notmyidea.org/api/projects/demo/members/35 https://ihatemoney.org/api/projects/demo/members/35
"OK "OK
Bills Bills
@ -130,21 +127,21 @@ Bills
You can get the list of bills by doing a `GET` on `/api/projects/<id>/bills` :: You can get the list of bills by doing a `GET` on `/api/projects/<id>/bills` ::
$ curl --basic -u demo:demo http://ihatemoney.notmyidea.org/api/projects/demo/bills $ curl --basic -u demo:demo https://ihatemoney.org/api/projects/demo/bills
Add a bill with a `POST` query on `/api/projects/<id>/bills`. you need the Add a bill with a `POST` query on `/api/projects/<id>/bills`. you need the
following params: following params:
* `date`: the date of the bill. (yy-mm-dd) * `date`: the date of the bill; defaults to current date if not provided. (yy-mm-dd)
* `what`: what have been payed * `what`: what have been payed
* `payer`: by who ? (id) * `payer`: by who ? (id)
* `payed_for`: list of ids * `payed_for`: for who ? (id, repeat the parameter to set multiple id)
* `amount`: amount payed * `amount`: amount payed
Returns the id of the created bill :: Returns the id of the created bill ::
$ curl --basic -u demo:demo -X POST\ $ curl --basic -u demo:demo -X POST\
http://ihatemoney.notmyidea.org/api/projects/demo/bills\ https://ihatemoney.org/api/projects/demo/bills\
-d "date=2011-09-10&what=raclette&payer=31&payed_for=31&amount=200" -d "date=2011-09-10&what=raclette&payer=31&payed_for=31&amount=200"
80 80
@ -152,12 +149,12 @@ You can also `PUT` a new version of the bill at
`/api/projects/<id>/bills/<bill-id>`:: `/api/projects/<id>/bills/<bill-id>`::
$ curl --basic -u demo:demo -X PUT\ $ curl --basic -u demo:demo -X PUT\
http://ihatemoney.notmyidea.org/api/projects/demo/bills/80\ https://ihatemoney.org/api/projects/demo/bills/80\
-d "date=2011-09-10&what=raclette&payer=31&payed_for=31&amount=250" -d "date=2011-09-10&what=raclette&payer=31&payed_for=31&amount=250"
80 80
And you can of course `DELETE` them at `/api/projects/<id>/bills/<bill-id>`:: And you can of course `DELETE` them at `/api/projects/<id>/bills/<bill-id>`::
$ curl --basic -u demo:demo -X DELETE\ $ curl --basic -u demo:demo -X DELETE\
http://ihatemoney.notmyidea.org/api/projects/demo/bills/80\ https://ihatemoney.org/api/projects/demo/bills/80\
"OK" "OK"