Merge branch 'master' into almet/fix-pbkdf2-check

This commit is contained in:
Alexis Métaireau 2018-08-05 14:37:50 +02:00
commit 633e3543b2
32 changed files with 930 additions and 590 deletions

View file

@ -3,15 +3,46 @@ Changelog
This document describes changes between each past release. This document describes changes between each past release.
2.1 (unreleased) 2.1.1 (unreleased)
---------------- ------------------
Fixed Fixed
===== =====
- Fix the generation of the supervisord template (#306) - Regenerate translations (#338)
- Fix broken install with pip ≥ 10 (#340)
- Fix the generation of the supervisord template (#309)
- Fix the validation of the hashed password (#310) - Fix the validation of the hashed password (#310)
Added
=====
- Document MySQL setup (#357)
2.1 (2018-02-16)
----------------
=======
Changed
=======
- Use flask-restful instead of deprecated flask-rest for the REST API (#315)
- Make sidebar scrollable. Usefull for large groups (#316)
Fixed
=====
- Fix the "IOError" crash when running `ihatemoney generate-config` (#308)
- Made the left-hand sidebar scrollable (#318)
- Fix and enhanche Docker support (#320, #321)
Added
=====
- Statistics API (#343)
- Allow to disable/enable member via API (#301)
- Enable basic Apache auth passthrough for API (#303)
2.0 (2017-12-27) 2.0 (2017-12-27)
---------------- ----------------

View file

@ -9,6 +9,7 @@ Alexis Metaireau <alexis@notmyidea.org>
Arnaud Bos <arnaud.tlse@gmail.com> Arnaud Bos <arnaud.tlse@gmail.com>
Baptiste Jonglez <git@bitsofnetworks.org> Baptiste Jonglez <git@bitsofnetworks.org>
Berteh <berteh@gmail.com> Berteh <berteh@gmail.com>
donkers <thedonkers@gmail.com>
Feth AREZKI <feth@tuttu.info> Feth AREZKI <feth@tuttu.info>
Frédéric Sureau <fredericsureau@gmail.com> Frédéric Sureau <fredericsureau@gmail.com>
Jocelyn Delalande <jocelyn@crapouillou.net> Jocelyn Delalande <jocelyn@crapouillou.net>

View file

@ -1,15 +1,15 @@
FROM python:3.6-alpine FROM python:3.6-alpine
RUN mkdir /ihatemoney &&\ RUN apk add gcc libc-dev libffi-dev openssl-dev &&\
mkdir /ihatemoney &&\
mkdir -p /etc/ihatemoney &&\ mkdir -p /etc/ihatemoney &&\
pip install --no-cache-dir gunicorn pymysql pip install --no-cache-dir gunicorn pymysql
WORKDIR /ihatemoney COPY . /ihatemoney
COPY . .
ARG INSTALL_FROM_PYPI="False" ARG INSTALL_FROM_PYPI="False"
RUN if [ "$INSTALL_FROM_PYPI" = True ]; then\ RUN if [ "$INSTALL_FROM_PYPI" = True ]; then\
pip install --no-cache-dir ihatemoney ; else\ pip install --no-cache-dir ihatemoney ; else\
pip install --no-cache-dir -e . ; \ pip install --no-cache-dir -e /ihatemoney ; \
fi fi
ENV DEBUG="False" \ ENV DEBUG="False" \
@ -26,9 +26,8 @@ ENV DEBUG="False" \
ACTIVATE_DEMO_PROJECT="True" \ ACTIVATE_DEMO_PROJECT="True" \
ADMIN_PASSWORD="" \ ADMIN_PASSWORD="" \
ALLOW_PUBLIC_PROJECT_CREATION="True" \ ALLOW_PUBLIC_PROJECT_CREATION="True" \
ACTIVATE_ADMIN_DASHBOARD="False" \ ACTIVATE_ADMIN_DASHBOARD="False"
GUNICORN_NUM_WORKERS="3"
VOLUME /database VOLUME /database
EXPOSE 8000 EXPOSE 8000
CMD ["/ihatemoney/conf/confandrun.sh"] ENTRYPOINT ["/ihatemoney/conf/confandrun.sh"]

View file

@ -1,3 +1,3 @@
include *.rst include *.rst
recursive-include ihatemoney *.rst *.py *.yaml *.po *.mo *.html *.css *.js *.eot *.svg *.woff *.txt *.png *.ini *.cfg recursive-include ihatemoney *.rst *.py *.yaml *.po *.mo *.html *.css *.js *.eot *.svg *.woff *.txt *.png *.ini *.cfg *.j2
include LICENSE CONTRIBUTORS CHANGELOG.rst requirements.txt include LICENSE CONTRIBUTORS CHANGELOG.rst requirements.txt

View file

@ -17,7 +17,8 @@ ADMIN_PASSWORD = "$ADMIN_PASSWORD"
ALLOW_PUBLIC_PROJECT_CREATION = $ALLOW_PUBLIC_PROJECT_CREATION ALLOW_PUBLIC_PROJECT_CREATION = $ALLOW_PUBLIC_PROJECT_CREATION
ACTIVATE_ADMIN_DASHBOARD = $ACTIVATE_ADMIN_DASHBOARD ACTIVATE_ADMIN_DASHBOARD = $ACTIVATE_ADMIN_DASHBOARD
EOF EOF
gunicorn ihatemoney.wsgi:application \ # Start gunicorn without forking
-b 0.0.0.0:8000 \ exec gunicorn ihatemoney.wsgi:application \
--log-syslog \ -b 0.0.0.0:8000 \
-w "$GUNICORN_NUM_WORKERS" --log-syslog \
"$@"

View file

@ -3,3 +3,4 @@ tox
pytest pytest
Flask-Testing Flask-Testing
Flake8 Flake8
mock; python_version < '3.3'

View file

@ -164,3 +164,25 @@ 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\
https://ihatemoney.org/api/projects/demo/bills/80\ https://ihatemoney.org/api/projects/demo/bills/80\
"OK" "OK"
Statistics
----------
You can get some project stats with a `GET` on `/api/projects/<id>/statistics`::
$ curl --basic -u demo:demo https://ihatemoney.org/api/projects/demo/statistics
[
{
"balance": 12.5,
"member": {"activated": True, "id": 1, "name": "alexis", "weight": 1.0},
"paid": 25.0,
"spent": 12.5
},
{
"balance": -12.5,
"member": {"activated": True, "id": 2, "name": "fred", "weight": 1.0},
"paid": 0,
"spent": 12.5
}
]

View file

@ -63,6 +63,22 @@ Once installed, you can start a test server::
And point your browser at `http://localhost:5000 <http://localhost:5000>`_. And point your browser at `http://localhost:5000 <http://localhost:5000>`_.
Configure database with MySQL/MariaDB (optional)
================================================
Only required if you prefer MySQL/MariaDB over SQLite.
1. Install PyMySQL dependencies. On Debian or Ubuntu, that would be::
apt install python3-dev libssl-dev
2. Install PyMySQL (within your virtualenv)::
pip install 'PyMySQL>=0.9,<0.10'
3. Create an empty database and a database user
4. Configure :ref:`SQLALCHEMY_DATABASE_URI <configuration>` accordingly
Deploy it Deploy it
========= =========
@ -154,9 +170,12 @@ A volume can also be specified to persist the default database file::
docker run -d -p 8000:8000 -v /host/path/to/database:/database ihatemoney docker run -d -p 8000:8000 -v /host/path/to/database:/database ihatemoney
The following gunicorn parameters are also available:: Additional gunicorn parameters can be passed using the docker ``CMD`` parameter.
For example, use the following command to add more gunicorn workers::
GUNICORN_NUM_WORKERS (default: 3) docker run -d -p 8000:8000 ihatemoney -w 3
.. _configuration:
Configuration Configuration
============= =============
@ -186,7 +205,7 @@ Production values are recommended values for use in production.
| | | **Production value:** `ihatemoney conf-example ihatemoney.cfg` sets it to something | | | | **Production value:** `ihatemoney conf-example ihatemoney.cfg` sets it to something |
| | | random, which is good. | | | | random, which is good. |
+-------------------------------+---------------------------------+----------------------------------------------------------------------------------------+ +-------------------------------+---------------------------------+----------------------------------------------------------------------------------------+
| MAIL_DEFAULT_SENDER | ``("Budget manager", | A python tuple describing the name and email address to use when sending | | MAIL_DEFAULT_SENDER | ``("Budget manager", | A python tuple describing the name and email address to use when sending |
| | "budget@notmyidea.org")`` | emails. | | | "budget@notmyidea.org")`` | emails. |
| | | | | | | |
| | | **Production value:** Any tuple you want. | | | | **Production value:** Any tuple you want. |

View file

@ -1,62 +1,75 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from flask import Blueprint, request from flask import Blueprint, request
from flask_rest import RESTResource, need_auth from flask_restful import Resource, Api, abort
from wtforms.fields.core import BooleanField from wtforms.fields.core import BooleanField
from ihatemoney.models import db, Project, Person, Bill from ihatemoney.models import db, Project, Person, Bill
from ihatemoney.forms import (ProjectForm, EditProjectForm, MemberForm, from ihatemoney.forms import (ProjectForm, EditProjectForm, MemberForm,
get_billform_for) get_billform_for)
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
from functools import wraps
api = Blueprint("api", __name__, url_prefix="/api") api = Blueprint("api", __name__, url_prefix="/api")
restful_api = Api(api)
def check_project(*args, **kwargs): def need_auth(f):
"""Check the request for basic authentication for a given project. """Check the request for basic authentication for a given project.
Return the project if the authorization is good, False otherwise Return the project if the authorization is good, abort the request with a 401 otherwise
""" """
auth = request.authorization @wraps(f)
def wrapper(*args, **kwargs):
auth = request.authorization
project_id = kwargs.get("project_id")
# project_id should be contained in kwargs and equal to the username if auth and project_id and auth.username == project_id:
if auth and "project_id" in kwargs and \ project = Project.query.get(auth.username)
auth.username == kwargs["project_id"]: if project and check_password_hash(project.password, auth.password):
project = Project.query.get(auth.username) # The whole project object will be passed instead of project_id
if project and check_password_hash(project.password, auth.password): kwargs.pop("project_id")
return project return f(*args, project=project, **kwargs)
return False abort(401)
return wrapper
class ProjectHandler(object): class ProjectsHandler(Resource):
def post(self):
def add(self):
form = ProjectForm(meta={'csrf': False}) form = ProjectForm(meta={'csrf': False})
if form.validate(): if form.validate():
project = form.save() project = form.save()
db.session.add(project) db.session.add(project)
db.session.commit() db.session.commit()
return 201, project.id return project.id, 201
return 400, form.errors return form.errors, 400
class ProjectHandler(Resource):
method_decorators = [need_auth]
@need_auth(check_project, "project")
def get(self, project): def get(self, project):
return 200, project return project
@need_auth(check_project, "project")
def delete(self, project): def delete(self, project):
db.session.delete(project) db.session.delete(project)
db.session.commit() db.session.commit()
return 200, "DELETED" return "DELETED"
@need_auth(check_project, "project") def put(self, project):
def update(self, project):
form = EditProjectForm(meta={'csrf': False}) form = EditProjectForm(meta={'csrf': False})
if form.validate(): if form.validate():
form.update(project) form.update(project)
db.session.commit() db.session.commit()
return 200, "UPDATED" return "UPDATED"
return 400, form.errors return form.errors, 400
class ProjectStatsHandler(Resource):
method_decorators = [need_auth]
def get(self, project):
return project.members_stats
class APIMemberForm(MemberForm): class APIMemberForm(MemberForm):
@ -71,98 +84,93 @@ class APIMemberForm(MemberForm):
return super(APIMemberForm, self).save(project, person) return super(APIMemberForm, self).save(project, person)
class MemberHandler(object): class MembersHandler(Resource):
method_decorators = [need_auth]
def get(self, project, member_id): def get(self, project):
member = Person.query.get(member_id, project) return project.members
if not member or member.project != project:
return 404, "Not Found"
return 200, member
def list(self, project): def post(self, project):
return 200, project.members
def add(self, project):
form = MemberForm(project, meta={'csrf': False}) form = MemberForm(project, meta={'csrf': False})
if form.validate(): if form.validate():
member = Person() member = Person()
form.save(project, member) form.save(project, member)
db.session.commit() db.session.commit()
return 201, member.id return member.id, 201
return 400, form.errors return form.errors, 400
def update(self, project, member_id):
class MemberHandler(Resource):
method_decorators = [need_auth]
def get(self, project, member_id):
member = Person.query.get(member_id, project)
if not member or member.project != project:
return "Not Found", 404
return member
def put(self, project, member_id):
form = APIMemberForm(project, meta={'csrf': False}, edit=True) form = APIMemberForm(project, meta={'csrf': False}, edit=True)
if form.validate(): if form.validate():
member = Person.query.get(member_id, project) member = Person.query.get(member_id, project)
form.save(project, member) form.save(project, member)
db.session.commit() db.session.commit()
return 200, member return member
return 400, form.errors return form.errors, 400
def delete(self, project, member_id): def delete(self, project, member_id):
if project.remove_member(member_id): if project.remove_member(member_id):
return 200, "OK" return "OK"
return 404, "Not Found" return "Not Found", 404
class BillHandler(object): class BillsHandler(Resource):
method_decorators = [need_auth]
def get(self, project, bill_id): def get(self, project):
bill = Bill.query.get(project, bill_id)
if not bill:
return 404, "Not Found"
return 200, bill
def list(self, project):
return project.get_bills().all() return project.get_bills().all()
def add(self, project): def post(self, project):
form = get_billform_for(project, True, meta={'csrf': False}) form = get_billform_for(project, True, meta={'csrf': False})
if form.validate(): if form.validate():
bill = Bill() bill = Bill()
form.save(bill, project) form.save(bill, project)
db.session.add(bill) db.session.add(bill)
db.session.commit() db.session.commit()
return 201, bill.id return bill.id, 201
return 400, form.errors return form.errors, 400
def update(self, project, bill_id):
class BillHandler(Resource):
method_decorators = [need_auth]
def get(self, project, bill_id):
bill = Bill.query.get(project, bill_id)
if not bill:
return "Not Found", 404
return bill, 200
def put(self, project, bill_id):
form = get_billform_for(project, True, meta={'csrf': False}) form = get_billform_for(project, True, meta={'csrf': False})
if form.validate(): if form.validate():
bill = Bill.query.get(project, bill_id) bill = Bill.query.get(project, bill_id)
form.save(bill, project) form.save(bill, project)
db.session.commit() db.session.commit()
return 200, bill.id return bill.id, 200
return 400, form.errors return form.errors, 400
def delete(self, project, bill_id): def delete(self, project, bill_id):
bill = Bill.query.delete(project, bill_id) bill = Bill.query.delete(project, bill_id)
db.session.commit() db.session.commit()
if not bill: if not bill:
return 404, "Not Found" return "Not Found", 404
return 200, "OK" return "OK", 200
project_resource = RESTResource( restful_api.add_resource(ProjectsHandler, '/projects')
name="project", restful_api.add_resource(ProjectHandler, '/projects/<string:project_id>')
route="/projects", restful_api.add_resource(MembersHandler, "/projects/<string:project_id>/members")
app=api, restful_api.add_resource(ProjectStatsHandler, "/projects/<string:project_id>/statistics")
actions=["add", "update", "delete", "get"], restful_api.add_resource(MemberHandler, "/projects/<string:project_id>/members/<int:member_id>")
handler=ProjectHandler()) restful_api.add_resource(BillsHandler, "/projects/<string:project_id>/bills")
restful_api.add_resource(BillHandler, "/projects/<string:project_id>/bills/<int:bill_id>")
member_resource = RESTResource(
name="member",
inject_name="project",
route="/projects/<project_id>/members",
app=api,
handler=MemberHandler(),
authentifier=check_project)
bill_resource = RESTResource(
name="bill",
inject_name="project",
route="/projects/<project_id>/bills",
app=api,
handler=BillHandler(),
authentifier=check_project)

View file

@ -1,8 +1,7 @@
<VirtualHost *:80> <VirtualHost *:80>
ServerAdmin admin@example.com # CUSTOMIZE ServerAdmin admin@example.com # CUSTOMIZE
ServerName ihatemoney.example.com # CUSTOMIZE ServerName ihatemoney.example.com # CUSTOMIZE
WSGIDaemonProcess ihatemoney user=www-data group=www-data threads=5 python-home={{ sys_prefix }}
WSGIDaemonProcess ihatemoney user=www-data group=www-data threads=5 python-path={{ pkg_path }} {% if venv_path %}python-home={{ venv_path }}{% endif %}
WSGIScriptAlias / {{ pkg_path }}/wsgi.py WSGIScriptAlias / {{ pkg_path }}/wsgi.py
WSGIPassAuthorization On WSGIPassAuthorization On

View file

@ -1,18 +1,17 @@
#!/usr/bin/env python #!/usr/bin/env python
import os import os
import pkgutil
import random import random
import sys import sys
from getpass import getpass import getpass
from flask_script import Manager, Command, Option from flask_script import Manager, Command, Option
from flask_migrate import Migrate, MigrateCommand from flask_migrate import Migrate, MigrateCommand
from jinja2 import Template
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
from ihatemoney.run import create_app from ihatemoney.run import create_app
from ihatemoney.models import db from ihatemoney.models import db
from ihatemoney.utils import create_jinja_env
class GeneratePasswordHash(Command): class GeneratePasswordHash(Command):
@ -20,11 +19,11 @@ class GeneratePasswordHash(Command):
"""Get password from user and hash it without printing it in clear text.""" """Get password from user and hash it without printing it in clear text."""
def run(self): def run(self):
password = getpass(prompt='Password: ') password = getpass.getpass(prompt='Password: ')
print(generate_password_hash(password)) print(generate_password_hash(password))
class ConfigTemplate(Command): class GenerateConfig(Command):
def get_options(self): def get_options(self):
return [ return [
Option('config_file', choices=[ Option('config_file', choices=[
@ -44,16 +43,16 @@ class ConfigTemplate(Command):
for i in range(50)]) for i in range(50)])
def run(self, config_file): def run(self, config_file):
template_content = pkgutil.get_data( env = create_jinja_env('conf-templates', strict_rendering=True)
'ihatemoney', template = env.get_template('%s.j2' % config_file)
os.path.join('conf-templates/', config_file) + '.j2'
).decode('utf-8')
bin_path = os.path.join(os.path.dirname(sys.executable)) bin_path = os.path.dirname(sys.executable)
pkg_path = os.path.abspath(os.path.dirname(__file__))
print(Template(template_content).render( print(template.render(
pkg_path=os.path.abspath(os.path.dirname(__file__)), pkg_path=pkg_path,
bin_path=bin_path, bin_path=bin_path,
sys_prefix=sys.prefix,
secret_key=self.gen_secret_key(), secret_key=self.gen_secret_key(),
)) ))
@ -76,7 +75,7 @@ def main():
manager = Manager(app) manager = Manager(app)
manager.add_command('db', MigrateCommand) manager.add_command('db', MigrateCommand)
manager.add_command('generate_password_hash', GeneratePasswordHash) manager.add_command('generate_password_hash', GeneratePasswordHash)
manager.add_command('generate-config', ConfigTemplate) manager.add_command('generate-config', GenerateConfig)
manager.run() manager.run()

View file

@ -1,111 +1,127 @@
# Translations template for PROJECT. # Translations template for PROJECT.
# Copyright (C) 2013 ORGANIZATION # Copyright (C) 2018 ORGANIZATION
# This file is distributed under the same license as the PROJECT project. # This file is distributed under the same license as the PROJECT project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2013. # FIRST AUTHOR <EMAIL@ADDRESS>, 2018.
# #
#, fuzzy #, fuzzy
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2013-10-13 21:32+0200\n" "POT-Creation-Date: 2018-05-15 21:43+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n" "Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 0.9.6\n" "Generated-By: Babel 2.5.3\n"
#: forms.py:22 #: forms.py:46
msgid "Select all"
msgstr ""
#: forms.py:22
msgid "Select none"
msgstr ""
#: forms.py:61
msgid "Project name" msgid "Project name"
msgstr "" msgstr ""
#: forms.py:62 forms.py:86 forms.py:102 #: forms.py:47 forms.py:71 forms.py:88
msgid "Private code" msgid "Private code"
msgstr "" msgstr ""
#: forms.py:63 #: forms.py:48
msgid "Email" msgid "Email"
msgstr "" msgstr ""
#: forms.py:85 forms.py:101 forms.py:107 #: forms.py:70 forms.py:87 forms.py:98
msgid "Project identifier" msgid "Project identifier"
msgstr "" msgstr ""
#: forms.py:87 templates/send_invites.html:5 #: forms.py:72
msgid "Create the project" msgid "Create the project"
msgstr "" msgstr ""
#: forms.py:92 #: forms.py:77
msgid "" msgid ""
"The project identifier is used to log in and for the URL of the project. " "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 " "We tried to generate an identifier for you but a project with this "
"identifier already exists. Please create a new identifier that you will " "identifier already exists. Please create a new identifier that you will "
"be able to remember." "be able to remember"
msgstr "" msgstr ""
#: forms.py:103 #: forms.py:89 forms.py:94
msgid "Get in" msgid "Get in"
msgstr "" msgstr ""
#: forms.py:108 #: forms.py:93
msgid "Admin password"
msgstr ""
#: forms.py:99
msgid "Send me the code by email" msgid "Send me the code by email"
msgstr "" msgstr ""
#: forms.py:112 #: forms.py:103
msgid "This project does not exists" msgid "This project does not exists"
msgstr "" msgstr ""
#: forms.py:116 #: forms.py:108
msgid "Password mismatch"
msgstr ""
#: forms.py:109
msgid "Password"
msgstr ""
#: forms.py:110
msgid "Password confirmation"
msgstr ""
#: forms.py:111
msgid "Reset password"
msgstr ""
#: forms.py:115
msgid "Date" msgid "Date"
msgstr "" msgstr ""
#: forms.py:117 #: forms.py:116
msgid "What?" msgid "What?"
msgstr "" msgstr ""
#: forms.py:118 #: forms.py:117
msgid "Payer" msgid "Payer"
msgstr "" msgstr ""
#: forms.py:119 #: forms.py:118
msgid "Amount paid" msgid "Amount paid"
msgstr "" msgstr ""
#: forms.py:120 templates/list_bills.html:103 #: forms.py:119 templates/forms.html:100 templates/list_bills.html:101
msgid "For whom?" msgid "For whom?"
msgstr "" msgstr ""
#: forms.py:122 #: forms.py:121
msgid "Submit" msgid "Submit"
msgstr "" msgstr ""
#: forms.py:123 #: forms.py:122
msgid "Submit and add a new one" msgid "Submit and add a new one"
msgstr "" msgstr ""
#: forms.py:149 #: forms.py:146
msgid "Bills can't be null" msgid "Bills can't be null"
msgstr "" msgstr ""
#: forms.py:154 #: forms.py:151
msgid "Name" msgid "Name"
msgstr "" msgstr ""
#: forms.py:155 templates/forms.html:95 #: forms.py:152
msgid "Weight"
msgstr ""
#: forms.py:153 templates/forms.html:123
msgid "Add" msgid "Add"
msgstr "" msgstr ""
#: forms.py:163 #: forms.py:162
msgid "User name incorrect" msgid "User name incorrect"
msgstr "" msgstr ""
@ -113,105 +129,151 @@ msgstr ""
msgid "This project already have this member" msgid "This project already have this member"
msgstr "" msgstr ""
#: forms.py:178 #: forms.py:183
msgid "People to notify" msgid "People to notify"
msgstr "" msgstr ""
#: forms.py:179 #: forms.py:184
msgid "Send invites" msgid "Send invites"
msgstr "" msgstr ""
#: forms.py:185 #: forms.py:190
#, python-format #, python-format
msgid "The email %(email)s is not valid" msgid "The email %(email)s is not valid"
msgstr "" msgstr ""
#: forms.py:191 #: forms.py:196
msgid "Start date" msgid "What do you want to download ?"
msgstr "" msgstr ""
#: forms.py:192 #: forms.py:199
msgid "End date" msgid "bills"
msgstr "" msgstr ""
#: web.py:95 #: forms.py:199
msgid "transactions"
msgstr ""
#: forms.py:201
msgid "Export file format"
msgstr ""
#: web.py:129
msgid "Too many failed login attempts, please retry later."
msgstr ""
#: web.py:144
#, python-format
msgid "This admin password is not the right one. Only %(num)d attempts left."
msgstr ""
#: web.py:167
msgid "You either provided a bad token or no project identifier."
msgstr ""
#: web.py:195
msgid "This private code is not the right one" msgid "This private code is not the right one"
msgstr "" msgstr ""
#: web.py:147 #: web.py:242
#, python-format #, python-format
msgid "You have just created '%(project)s' to share your expenses" msgid "You have just created '%(project)s' to share your expenses"
msgstr "" msgstr ""
#: web.py:165 #: web.py:260
#, python-format #, python-format
msgid "%(msg_compl)sThe project identifier is %(project)s" msgid "%(msg_compl)sThe project identifier is %(project)s"
msgstr "" msgstr ""
#: web.py:185 #: web.py:281
msgid "a mail has been sent to you with the password" msgid "A link to reset your password has been sent to your email."
msgstr "" msgstr ""
#: web.py:211 #: web.py:291
msgid "No token provided"
msgstr ""
#: web.py:294
msgid "Invalid token"
msgstr ""
#: web.py:297
msgid "Unknown project"
msgstr ""
#: web.py:303
msgid "Password successfully reset."
msgstr ""
#: web.py:351
msgid "Project successfully deleted" msgid "Project successfully deleted"
msgstr "" msgstr ""
#: web.py:254 #: web.py:401
#, python-format #, python-format
msgid "You have been invited to share your expenses for %(project)s" msgid "You have been invited to share your expenses for %(project)s"
msgstr "" msgstr ""
#: web.py:261 #: web.py:408
msgid "Your invitations have been sent" msgid "Your invitations have been sent"
msgstr "" msgstr ""
#: web.py:290 #: web.py:439
#, python-format #, python-format
msgid "%(member)s had been added" msgid "%(member)s had been added"
msgstr "" msgstr ""
#: web.py:303 #: web.py:452
#, python-format #, python-format
msgid "%(name)s is part of this project again" msgid "%(name)s is part of this project again"
msgstr "" msgstr ""
#: web.py:312 #: web.py:461
#, python-format #, python-format
msgid "User '%(name)s' has been deactivated" msgid ""
"User '%(name)s' has been deactivated. It will still appear in the users "
"list until its balance becomes zero."
msgstr "" msgstr ""
#: web.py:314 #: web.py:465
#, python-format #, python-format
msgid "User '%(name)s' has been removed" msgid "User '%(name)s' has been removed"
msgstr "" msgstr ""
#: web.py:331 #: web.py:480
#, python-format
msgid "User '%(name)s' has been edited"
msgstr ""
#: web.py:500
msgid "The bill has been added" msgid "The bill has been added"
msgstr "" msgstr ""
#: web.py:351 #: web.py:520
msgid "The bill has been deleted" msgid "The bill has been deleted"
msgstr "" msgstr ""
#: web.py:369 #: web.py:538
msgid "The bill has been modified" msgid "The bill has been modified"
msgstr "" msgstr ""
#: templates/add_bill.html:9 #: templates/add_bill.html:9 templates/edit_member.html:9
msgid "Back to the list" msgid "Back to the list"
msgstr "" msgstr ""
#: templates/authenticate.html:6 #: templates/admin.html:10
msgid "" msgid "Administration tasks are currently disabled."
"The project you are trying to access do not exist, do you want \n"
"to"
msgstr "" msgstr ""
#: templates/authenticate.html:7 #: templates/authenticate.html:6
msgid "The project you are trying to access do not exist, do you want to"
msgstr ""
#: templates/authenticate.html:8
msgid "create it" msgid "create it"
msgstr "" msgstr ""
#: templates/authenticate.html:7 #: templates/authenticate.html:8
msgid "?" msgid "?"
msgstr "" msgstr ""
@ -239,6 +301,24 @@ msgstr ""
msgid "Oldest bill" msgid "Oldest bill"
msgstr "" msgstr ""
#: templates/dashboard.html:5 templates/list_bills.html:101
msgid "Actions"
msgstr ""
#: templates/dashboard.html:17 templates/list_bills.html:65
#: templates/list_bills.html:111
msgid "edit"
msgstr ""
#: templates/dashboard.html:18 templates/forms.html:83
#: templates/list_bills.html:112
msgid "delete"
msgstr ""
#: templates/dashboard.html:25
msgid "The Dashboard is currently deactivated."
msgstr ""
#: templates/edit_project.html:6 templates/list_bills.html:24 #: templates/edit_project.html:6 templates/list_bills.html:24
msgid "you sure?" msgid "you sure?"
msgstr "" msgstr ""
@ -247,44 +327,59 @@ msgstr ""
msgid "Edit this project" msgid "Edit this project"
msgstr "" msgstr ""
#: templates/forms.html:23 #: templates/edit_project.html:15
msgid "Download this project's data"
msgstr ""
#: templates/forms.html:27
msgid "Can't remember the password?" msgid "Can't remember the password?"
msgstr "" msgstr ""
#: templates/forms.html:26 #: templates/forms.html:30
msgid "Cancel" msgid "Cancel"
msgstr "" msgstr ""
#: templates/forms.html:68 #: templates/forms.html:82
msgid "Edit the project" msgid "Edit the project"
msgstr "" msgstr ""
#: templates/forms.html:69 templates/list_bills.html:70 #: templates/forms.html:91
#: templates/list_bills.html:114
msgid "delete"
msgstr ""
#: templates/forms.html:77
msgid "Edit this bill" msgid "Edit this bill"
msgstr "" msgstr ""
#: templates/forms.html:77 templates/list_bills.html:94 #: templates/forms.html:91 templates/list_bills.html:89
msgid "Add a bill" msgid "Add a bill"
msgstr "" msgstr ""
#: templates/forms.html:95 #: templates/forms.html:103
msgid "Type user name here" msgid "Select all"
msgstr ""
#: templates/forms.html:102
msgid "Send the invitations"
msgstr "" msgstr ""
#: templates/forms.html:103 #: templates/forms.html:103
msgid "Select none"
msgstr ""
#: templates/forms.html:122
msgid "Type user name here"
msgstr ""
#: templates/forms.html:129
msgid "Edit this member"
msgstr ""
#: templates/forms.html:145
msgid "Send the invitations"
msgstr ""
#: templates/forms.html:146
msgid "No, thanks" msgid "No, thanks"
msgstr "" msgstr ""
#: templates/home.html:8 #: templates/forms.html:157
msgid "Download"
msgstr ""
#: templates/home.html:7
msgid "Manage your shared <br>expenses, easily" msgid "Manage your shared <br>expenses, easily"
msgstr "" msgstr ""
@ -292,39 +387,39 @@ msgstr ""
msgid "Try out the demo" msgid "Try out the demo"
msgstr "" msgstr ""
#: templates/home.html:12 #: templates/home.html:13
msgid "You're sharing a house?" msgid "You're sharing a house?"
msgstr "" msgstr ""
#: templates/home.html:12 #: templates/home.html:13
msgid "Going on holidays with friends?" msgid "Going on holidays with friends?"
msgstr "" msgstr ""
#: templates/home.html:12 #: templates/home.html:13
msgid "Simply sharing money with others?" msgid "Simply sharing money with others?"
msgstr "" msgstr ""
#: templates/home.html:12 #: templates/home.html:13
msgid "We can help!" msgid "We can help!"
msgstr "" msgstr ""
#: templates/home.html:24 #: templates/home.html:21
msgid "Log to an existing project" msgid "Log to an existing project"
msgstr "" msgstr ""
#: templates/home.html:28 #: templates/home.html:25
msgid "log in" msgid "log in"
msgstr "" msgstr ""
#: templates/home.html:29 #: templates/home.html:26
msgid "can't remember your password?" msgid "can't remember your password?"
msgstr "" msgstr ""
#: templates/home.html:36 #: templates/home.html:34 templates/home.html:42
msgid "or create a new one" msgid "or create a new one"
msgstr "" msgstr ""
#: templates/home.html:40 #: templates/home.html:38
msgid "let's get started" msgid "let's get started"
msgstr "" msgstr ""
@ -338,91 +433,91 @@ msgstr ""
msgid "Account manager" msgid "Account manager"
msgstr "" msgstr ""
#: templates/layout.html:45 templates/settle_bills.html:4 #: templates/layout.html:39
msgid "Bills" msgid "Bills"
msgstr "" msgstr ""
#: templates/layout.html:46 templates/settle_bills.html:5 #: templates/layout.html:40
msgid "Settle" msgid "Settle"
msgstr "" msgstr ""
#: templates/layout.html:53 #: templates/layout.html:41
msgid "Statistics"
msgstr ""
#: templates/layout.html:48
msgid "options" msgid "options"
msgstr "" msgstr ""
#: templates/layout.html:55 #: templates/layout.html:50
msgid "Project settings" msgid "Project settings"
msgstr "" msgstr ""
#: templates/layout.html:59 #: templates/layout.html:54
msgid "switch to" msgid "switch to"
msgstr "" msgstr ""
#: templates/layout.html:62 #: templates/layout.html:57
msgid "Start a new project" msgid "Start a new project"
msgstr "" msgstr ""
#: templates/layout.html:64 #: templates/layout.html:59
msgid "Logout" msgid "Logout"
msgstr "" msgstr ""
#: templates/layout.html:92 #: templates/layout.html:66
msgid "Dashboard"
msgstr ""
#: templates/layout.html:89
msgid "This is a free software" msgid "This is a free software"
msgstr "" msgstr ""
#: templates/layout.html:92 #: templates/layout.html:89
msgid "you can contribute and improve it!" msgid "you can contribute and improve it!"
msgstr "" msgstr ""
#: templates/list_bills.html:74 #: templates/list_bills.html:63
msgid "deactivate"
msgstr ""
#: templates/list_bills.html:70
msgid "reactivate" msgid "reactivate"
msgstr "" msgstr ""
#: templates/list_bills.html:88 #: templates/list_bills.html:82
msgid "The project identifier is" msgid "Invite people to join this project!"
msgstr "" msgstr ""
#: templates/list_bills.html:88 #: templates/list_bills.html:83
msgid "remember it!"
msgstr ""
#: templates/list_bills.html:89
msgid "Add a new bill" msgid "Add a new bill"
msgstr "" msgstr ""
#: templates/list_bills.html:103 #: templates/list_bills.html:101
msgid "When?" msgid "When?"
msgstr "" msgstr ""
#: templates/list_bills.html:103 #: templates/list_bills.html:101
msgid "Who paid?" msgid "Who paid?"
msgstr "" msgstr ""
#: templates/list_bills.html:103 #: templates/list_bills.html:101
msgid "For what?" msgid "For what?"
msgstr "" msgstr ""
#: templates/list_bills.html:103 templates/settle_bills.html:31 #: templates/list_bills.html:101 templates/settle_bills.html:22
msgid "How much?" msgid "How much?"
msgstr "" msgstr ""
#: templates/list_bills.html:103 #: templates/list_bills.html:109
msgid "Actions"
msgstr ""
#: templates/list_bills.html:111
msgid "each" msgid "each"
msgstr "" msgstr ""
#: templates/list_bills.html:113 #: templates/list_bills.html:120
msgid "edit"
msgstr ""
#: templates/list_bills.html:122
msgid "Nothing to list yet. You probably want to" msgid "Nothing to list yet. You probably want to"
msgstr "" msgstr ""
#: templates/list_bills.html:122 #: templates/list_bills.html:120
msgid "add a bill" msgid "add a bill"
msgstr "" msgstr ""
@ -434,43 +529,50 @@ msgstr ""
msgid "Your projects" msgid "Your projects"
msgstr "" msgstr ""
#: templates/send_invites.html:6 #: templates/reset_password.html:7
msgid "Invite people" msgid "Reset your password"
msgstr "" msgstr ""
#: templates/send_invites.html:7 #: templates/send_invites.html:4
msgid "Use it!"
msgstr ""
#: templates/send_invites.html:11
msgid "Invite people to join this project" msgid "Invite people to join this project"
msgstr "" msgstr ""
#: templates/send_invites.html:12 #: templates/send_invites.html:5
msgid "" msgid ""
"Specify a (coma separated) list of email adresses you want to notify " "Specify a (comma separated) list of email adresses you want to notify "
"about the \n" "about the\n"
"creation of this budget management project and we will send them an email" "creation of this budget management project and we will send them an email"
" for you." " for you."
msgstr "" msgstr ""
#: templates/send_invites.html:14 #: templates/send_invites.html:7
msgid "If you prefer, you can" msgid ""
"If you prefer, you can share the project identifier and the shared\n"
"password by other communication means. Or even directly share the "
"following link:"
msgstr "" msgstr ""
#: templates/send_invites.html:14 #: templates/settle_bills.html:22
msgid "skip this step"
msgstr ""
#: templates/send_invites.html:14
msgid "and notify them yourself"
msgstr ""
#: templates/settle_bills.html:31
msgid "Who pays?" msgid "Who pays?"
msgstr "" msgstr ""
#: templates/settle_bills.html:31 #: templates/settle_bills.html:22
msgid "To whom?" msgid "To whom?"
msgstr "" msgstr ""
#: templates/statistics.html:21
msgid "Who?"
msgstr ""
#: templates/statistics.html:21
msgid "Paid"
msgstr ""
#: templates/statistics.html:21
msgid "Spent"
msgstr ""
#: templates/statistics.html:21
msgid "Balance"
msgstr ""

View file

@ -52,6 +52,26 @@ class Project(db.Model):
return balances return balances
@property
def members_stats(self):
"""Compute what each member has paid
:return: one stat dict per member
:rtype list:
"""
return [{
'member': member,
'paid': sum([
bill.amount
for bill in self.get_member_bills(member.id).all()
]),
'spent': sum([
bill.pay_each() * member.weight
for bill in self.get_bills().all() if member in bill.owers
]),
'balance': self.balance[member.id]
} for member in self.active_members]
@property @property
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

View file

@ -11,7 +11,7 @@ from werkzeug.contrib.fixers import ProxyFix
from ihatemoney.api import api from ihatemoney.api import api
from ihatemoney.models import db from ihatemoney.models import db
from ihatemoney.utils import PrefixedWSGI, minimal_round from ihatemoney.utils import PrefixedWSGI, minimal_round, IhmJSONEncoder
from ihatemoney.web import main as web_interface from ihatemoney.web import main as web_interface
from ihatemoney import default_settings from ihatemoney import default_settings
@ -68,6 +68,8 @@ def load_configuration(app, configuration=None):
app.config.from_pyfile(env_var_config) app.config.from_pyfile(env_var_config)
else: else:
app.config.from_pyfile('ihatemoney.cfg', silent=True) app.config.from_pyfile('ihatemoney.cfg', silent=True)
# Configure custom JSONEncoder used by the API
app.config['RESTFUL_JSON'] = {'cls': IhmJSONEncoder}
def validate_configuration(app): def validate_configuration(app):

View file

@ -74,11 +74,13 @@ body {
background-repeat: no-repeat; background-repeat: no-repeat;
height: 100%; height: 100%;
color: black; color: black;
overflow-y: auto;
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.sidebar { .sidebar {
position: fixed; position: fixed;
padding-bottom: 4.5rem;
} }
} }

View file

@ -4,7 +4,7 @@
<table id="bill_table" class="table table-striped"> <table id="bill_table" class="table table-striped">
<thead><tr><th>{{ _("Project") }}</th><th>{{ _("Number of members") }}</th><th>{{ _("Number of bills") }}</th><th>{{_("Newest bill")}}</th><th>{{_("Oldest bill")}}</th><th>{{_("Actions")}}</th></tr></thead> <thead><tr><th>{{ _("Project") }}</th><th>{{ _("Number of members") }}</th><th>{{ _("Number of bills") }}</th><th>{{_("Newest bill")}}</th><th>{{_("Oldest bill")}}</th><th>{{_("Actions")}}</th></tr></thead>
<tbody>{% for project in projects|sort(attribute='name') %} <tbody>{% for project in projects|sort(attribute='name') %}
<tr class="{{ loop.cycle("odd", "even") }}"> <tr>
<td>{{ project.name }}</td><td>{{ project.members | count }}</td><td>{{ project.get_bills().count() }}</td> <td>{{ project.name }}</td><td>{{ project.members | count }}</td><td>{{ project.get_bills().count() }}</td>
{% if project.has_bills() %} {% if project.has_bills() %}
<td>{{ project.get_bills().all()[0].date }}</td> <td>{{ project.get_bills().all()[0].date }}</td>

View file

@ -2,10 +2,10 @@ Hi,
Someone using the email address {{ g.project.contact_email }} invited you to share your expenses for "{{ g.project.name }}". Someone using the email address {{ g.project.contact_email }} invited you to share your expenses for "{{ g.project.name }}".
It's as simple as saying what did you paid for, for who, and how much did it cost you, we are caring about the rest. It's as simple as saying what did you pay for, for whom, and how much did it cost you, we are caring about the rest.
You can log in using this link: {{ url_for(".authenticate", _external=True, token=g.project.generate_token()) }}. You can log in using this link: {{ url_for(".authenticate", _external=True, token=g.project.generate_token()) }}.
Once logged in you can use the following link which is easier to remember: {{ url_for(".list_bills", _external=True) }} Once logged-in, you can use the following link which is easier to remember: {{ url_for(".list_bills", _external=True) }}
If your cookie gets deleted or if you log out, you will need to log back in using the first link. If your cookie gets deleted or if you log out, you will need to log back in using the first link.
Enjoy, Enjoy,

View file

@ -1,11 +1,11 @@
Salut, Salut,
Quelqu'un avec l'addresse email "{{ g.project.contact_email }}" vous à invité à partager vos dépenses pour "{{ g.project.name }}". Quelqu'un avec l'adresse "{{ g.project.contact_email }}" vous à invité à partager vos dépenses pour "{{ g.project.name }}".
C'est aussi simple que de dire qui à payé pour quoi, pour qui, et combien celà à coûté, on s'occuppe du reste. C'est aussi simple que de dire qui à payé pour quoi, pour qui, et combien celà à coûté, on soccupe du reste.
Vous pouvez vous authentifier avec le lien suivant: {{ url_for(".authenticate", _external=True, token=g.project.generate_token()) }}. Vous pouvez vous authentifier avec le lien suivant: {{ url_for(".authenticate", _external=True, token=g.project.generate_token()) }}.
Une fois authentifié, vous pouvez utiliser le lien suivant qui est plus facile à mémoriser: {{ url_for(".list_bills", _external=True) }} Une fois authentifié, vous pouvez utiliser le lien suivant qui est plus facile à mémoriser: {{ url_for(".list_bills", _external=True) }}
Si votre cookie est supprimé ou si vous vous déconnectez, voous devrez vous authentifier en utilisant le premier lien. Si votre cookie est supprimé ou si vous vous déconnectez, vous devrez vous authentifier à nouveau en utilisant le premier lien.
Have fun, Have fun,

View file

@ -1,8 +1,8 @@
Hi, Hi,
You requested to reset the password of the following project: "{{ project.name }}". You requested to reset the password of the following project: "{{ project.name }}".
You can reset it here: {{ url_for(".reset_password", _external=True, token=project.generate_token(expiration=3600)) }}. You can reset it here: {{ url_for(".reset_password", _external=True, token=project.generate_token(expiration=3600)) }}.
This link is only valid for 1 hour. This link is only valid for one hour.
Hope this helps, Hope this helps,
Some weird guys (with beards) Some weird guys (with beards)

View file

@ -22,7 +22,7 @@
<thead><tr><th>{{ _("Who pays?") }}</th><th>{{ _("To whom?") }}</th><th>{{ _("How much?") }}</th></tr></thead> <thead><tr><th>{{ _("Who pays?") }}</th><th>{{ _("To whom?") }}</th><th>{{ _("How much?") }}</th></tr></thead>
<tbody> <tbody>
{% for bill in bills %} {% for bill in bills %}
<tr class="{{ loop.cycle("odd", "even") }}" receiver={{bill.receiver.id}}> <tr receiver={{bill.receiver.id}}>
<td>{{ bill.ower }}</td> <td>{{ bill.ower }}</td>
<td>{{ bill.receiver }}</td> <td>{{ bill.receiver }}</td>
<td>{{ "%0.2f"|format(bill.amount) }}</td> <td>{{ "%0.2f"|format(bill.amount) }}</td>

View file

@ -3,12 +3,11 @@
{% block sidebar %} {% block sidebar %}
<div id="table_overflow"> <div id="table_overflow">
<table class="balance table"> <table class="balance table">
{% set balance = g.project.balance %} {% for stat in members_stats| sort(attribute='member.name') %}
{% for member in g.project.members | sort(attribute='name') if member.activated or balance[member.id]|round(2) != 0 %} <tr>
<tr id="bal-member-{{ member.id }}" action={% if member.activated %}delete{% else %}reactivate{% endif %}> <td class="balance-name">{{ stat.member.name }}</td>
<td class="balance-name">{{ member.name }}</td> <td class="balance-value {% if stat.balance|round(2) > 0 %}positive{% elif stat.balance|round(2) < 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 stat.balance|round(2) > 0 %}+{% endif %}{{ "%.2f" | format(stat.balance) }}
{% if balance[member.id]|round(2) > 0 %}+{% endif %}{{ "%.2f" | format(balance[member.id]) }}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -21,12 +20,12 @@
<table id="bill_table" class="split_bills table table-striped"> <table id="bill_table" class="split_bills table table-striped">
<thead><tr><th>{{ _("Who?") }}</th><th>{{ _("Paid") }}</th><th>{{ _("Spent") }}</th><th>{{ _("Balance") }}</th></tr></thead> <thead><tr><th>{{ _("Who?") }}</th><th>{{ _("Paid") }}</th><th>{{ _("Spent") }}</th><th>{{ _("Balance") }}</th></tr></thead>
<tbody> <tbody>
{% for member in members %} {% for stat in members_stats %}
<tr class="{{ loop.cycle("odd", "even") }}"> <tr>
<td>{{ member.name }}</td> <td>{{ stat.member.name }}</td>
<td>{{ "%0.2f"|format(paid[member.id]) }}</td> <td>{{ "%0.2f"|format(stat.paid) }}</td>
<td>{{ "%0.2f"|format(spent[member.id]) }}</td> <td>{{ "%0.2f"|format(stat.spent) }}</td>
<td>{{ "%0.2f"|format(balance[member.id]) }}</td> <td>{{ "%0.2f"|format(stat.balance) }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View file

@ -4,6 +4,10 @@ try:
import unittest2 as unittest import unittest2 as unittest
except ImportError: except ImportError:
import unittest # NOQA import unittest # NOQA
try:
from unittest.mock import patch
except ImportError:
from mock import patch
import os import os
import json import json
@ -16,6 +20,7 @@ from flask import session
from flask_testing import TestCase from flask_testing import TestCase
from ihatemoney.run import create_app, db, load_configuration from ihatemoney.run import create_app, db, load_configuration
from ihatemoney.manage import GenerateConfig, GeneratePasswordHash
from ihatemoney import models from ihatemoney import models
from ihatemoney import utils from ihatemoney import utils
@ -745,24 +750,24 @@ class BudgetTestCase(IhatemoneyTestCase):
}) })
response = self.client.get("/raclette/statistics") response = self.client.get("/raclette/statistics")
self.assertIn("<td>alexis</td>\n " self.assertIn("<td>alexis</td>\n "
+ "<td>20.00</td>\n " + "<td>20.00</td>\n "
+ "<td>31.67</td>\n " + "<td>31.67</td>\n "
+ "<td>-11.67</td>\n", + "<td>-11.67</td>\n",
response.data.decode('utf-8')) response.data.decode('utf-8'))
self.assertIn("<td>fred</td>\n " self.assertIn("<td>fred</td>\n "
+ "<td>20.00</td>\n " + "<td>20.00</td>\n "
+ "<td>5.83</td>\n " + "<td>5.83</td>\n "
+ "<td>14.17</td>\n", + "<td>14.17</td>\n",
response.data.decode('utf-8')) response.data.decode('utf-8'))
self.assertIn("<td>tata</td>\n " self.assertIn("<td>tata</td>\n "
+ "<td>0.00</td>\n " + "<td>0.00</td>\n "
+ "<td>2.50</td>\n " + "<td>2.50</td>\n "
+ "<td>-2.50</td>\n", + "<td>-2.50</td>\n",
response.data.decode('utf-8')) response.data.decode('utf-8'))
self.assertIn("<td>toto</td>\n " self.assertIn("<td>toto</td>\n "
+ "<td>0.00</td>\n " + "<td>0.00</td>\n "
+ "<td>0.00</td>\n " + "<td>0.00</td>\n "
+ "<td>0.00</td>\n", + "<td>0.00</td>\n",
response.data.decode('utf-8')) response.data.decode('utf-8'))
@ -1048,7 +1053,7 @@ class APITestCase(IhatemoneyTestCase):
}) })
self.assertTrue(400, resp.status_code) self.assertTrue(400, resp.status_code)
self.assertEqual('{"contact_email": ["Invalid email address."]}', self.assertEqual('{"contact_email": ["Invalid email address."]}\n',
resp.data.decode('utf-8')) resp.data.decode('utf-8'))
# create it # create it
@ -1134,7 +1139,7 @@ class APITestCase(IhatemoneyTestCase):
headers=self.get_auth("raclette")) headers=self.get_auth("raclette"))
self.assertStatus(200, req) self.assertStatus(200, req)
self.assertEqual('[]', req.data.decode('utf-8')) self.assertEqual('[]\n', req.data.decode('utf-8'))
# add a member # add a member
req = self.client.post("/api/projects/raclette/members", data={ req = self.client.post("/api/projects/raclette/members", data={
@ -1143,7 +1148,7 @@ class APITestCase(IhatemoneyTestCase):
# the id of the new member should be returned # the id of the new member should be returned
self.assertStatus(201, req) self.assertStatus(201, req)
self.assertEqual("1", req.data.decode('utf-8')) self.assertEqual("1\n", req.data.decode('utf-8'))
# the list of members should contain one member # the list of members should contain one member
req = self.client.get("/api/projects/raclette/members", req = self.client.get("/api/projects/raclette/members",
@ -1218,7 +1223,7 @@ class APITestCase(IhatemoneyTestCase):
headers=self.get_auth("raclette")) headers=self.get_auth("raclette"))
self.assertStatus(200, req) self.assertStatus(200, req)
self.assertEqual('[]', req.data.decode('utf-8')) self.assertEqual('[]\n', req.data.decode('utf-8'))
def test_bills(self): def test_bills(self):
# create a project # create a project
@ -1234,7 +1239,7 @@ class APITestCase(IhatemoneyTestCase):
headers=self.get_auth("raclette")) headers=self.get_auth("raclette"))
self.assertStatus(200, req) self.assertStatus(200, req)
self.assertEqual("[]", req.data.decode('utf-8')) self.assertEqual("[]\n", req.data.decode('utf-8'))
# add a bill # add a bill
req = self.client.post("/api/projects/raclette/bills", data={ req = self.client.post("/api/projects/raclette/bills", data={
@ -1247,7 +1252,7 @@ class APITestCase(IhatemoneyTestCase):
# should return the id # should return the id
self.assertStatus(201, req) self.assertStatus(201, req)
self.assertEqual(req.data.decode('utf-8'), "1") self.assertEqual(req.data.decode('utf-8'), "1\n")
# get this bill details # get this bill details
req = self.client.get("/api/projects/raclette/bills/1", req = self.client.get("/api/projects/raclette/bills/1",
@ -1283,7 +1288,7 @@ class APITestCase(IhatemoneyTestCase):
}, headers=self.get_auth("raclette")) }, headers=self.get_auth("raclette"))
self.assertStatus(400, req) self.assertStatus(400, req)
self.assertEqual('{"date": ["This field is required."]}', req.data.decode('utf-8')) self.assertEqual('{"date": ["This field is required."]}\n', req.data.decode('utf-8'))
# edit a bill # edit a bill
req = self.client.put("/api/projects/raclette/bills/1", data={ req = self.client.put("/api/projects/raclette/bills/1", data={
@ -1320,6 +1325,40 @@ class APITestCase(IhatemoneyTestCase):
headers=self.get_auth("raclette")) headers=self.get_auth("raclette"))
self.assertStatus(404, req) self.assertStatus(404, req)
def test_statistics(self):
# create a project
self.api_create("raclette")
# add members
self.api_add_member("raclette", "alexis")
self.api_add_member("raclette", "fred")
# add a bill
req = self.client.post("/api/projects/raclette/bills", data={
'date': '2011-08-10',
'what': 'fromage',
'payer': "1",
'payed_for': ["1", "2"],
'amount': '25',
}, headers=self.get_auth("raclette"))
# get the list of bills (should be empty)
req = self.client.get("/api/projects/raclette/statistics",
headers=self.get_auth("raclette"))
self.assertStatus(200, req)
self.assertEqual([
{'balance': 12.5,
'member': {'activated': True, 'id': 1,
'name': 'alexis', 'weight': 1.0},
'paid': 25.0,
'spent': 12.5},
{'balance': -12.5,
'member': {'activated': True, 'id': 2,
'name': 'fred', 'weight': 1.0},
'paid': 0,
'spent': 12.5}],
json.loads(req.data.decode('utf-8')))
def test_username_xss(self): def test_username_xss(self):
# create a project # create a project
# self.api_create("raclette") # self.api_create("raclette")
@ -1406,5 +1445,27 @@ class ServerTestCase(IhatemoneyTestCase):
self.assertStatus(200, req) self.assertStatus(200, req)
class CommandTestCase(BaseTestCase):
def test_generate_config(self):
""" Simply checks that all config file generation
- raise no exception
- produce something non-empty
"""
cmd = GenerateConfig()
for config_file in cmd.get_options()[0].kwargs['choices']:
with patch('sys.stdout', new=six.StringIO()) as stdout:
cmd.run(config_file)
print(stdout.getvalue())
self.assertNotEqual(len(stdout.getvalue().strip()), 0)
def test_generate_password_hash(self):
cmd = GeneratePasswordHash()
with patch('sys.stdout', new=six.StringIO()) as stdout, \
patch('getpass.getpass', new=lambda prompt: 'secret'): # NOQA
cmd.run()
print(stdout.getvalue())
self.assertEqual(len(stdout.getvalue().strip()), 187)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -2,293 +2,289 @@
# Copyright (C) 2011 ORGANIZATION # Copyright (C) 2011 ORGANIZATION
# This file is distributed under the same license as the PROJECT project. # This file is distributed under the same license as the PROJECT project.
# Alexis Métaireau <alexis@notmyidea.org>, 2011. # Alexis Métaireau <alexis@notmyidea.org>, 2011.
# # Adrien CLERC, 2018.
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2013-10-13 21:32+0200\n" "POT-Creation-Date: 2018-05-15 21:43+0200\n"
"PO-Revision-Date: 2011-10-14 23:51+0200\n" "PO-Revision-Date: 2018-05-15 22:00+0200\n"
"Last-Translator: Quentin Roy <royque@gmail.com>\n" "Last-Translator: Adrien CLERC <>\n"
"Language-Team: fr <LL@li.org>\n" "Language-Team: fr <LL@li.org>\n"
"Plural-Forms: nplurals=2; plural=(n > 1)\n" "Language: fr\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n" "Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 0.9.6\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n"
"X-Generator: Virtaal 0.7.1\n"
"Generated-By: Babel 2.5.3\n"
#: forms.py:22 #: forms.py:46
msgid "Select all"
msgstr "Tout cocher"
#: forms.py:22
msgid "Select none"
msgstr "Tout décocher"
#: forms.py:61
msgid "Project name" msgid "Project name"
msgstr "Nom de projet" msgstr "Nom de projet"
#: forms.py:62 forms.py:86 forms.py:102 #: forms.py:47 forms.py:71 forms.py:88
msgid "Private code" msgid "Private code"
msgstr "Code d'accès" msgstr "Code daccès"
#: forms.py:63 #: forms.py:48
msgid "Email" msgid "Email"
msgstr "Email" msgstr "Email"
#: forms.py:85 forms.py:101 forms.py:107 #: forms.py:70 forms.py:87 forms.py:98
msgid "Project identifier" msgid "Project identifier"
msgstr "Identifiant du projet" msgstr "Identifiant du projet"
#: forms.py:87 #: forms.py:72
msgid "Admin password"
msgstr "Mot de passe administrateur"
#: forms.py:87 templates/send_invites.html:5
msgid "Create the project" msgid "Create the project"
msgstr "Créer le projet" msgstr "Créer le projet"
#: forms.py:92 #: forms.py:77
msgid "" msgid ""
"The project identifier is used to log in and for the URL of the project. " "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 " "We tried to generate an identifier for you but a project with this "
"identifier already exists. Please create a new identifier that you will " "identifier already exists. Please create a new identifier that you will "
"be able to remember." "be able to remember"
msgstr "" msgstr ""
"L'identifiant du projet est utilisé pour se connecter." "Lidentifiant du projet est utilisé pour se connecter et pour lURL du "
"Nous avons essayé de générer un identifiant mais " "projet. Nous avons essayé de générer un identifiant mais celui ci existe "
"celui ci existe déjà. Merci de créer un nouvel identifiant que vous serez" "déjà. Merci de créer un nouvel identifiant que vous serez capable de retenir"
" capable de retenir"
#: forms.py:103 #: forms.py:89 forms.py:94
msgid "Get in" msgid "Get in"
msgstr "Entrer" msgstr "Entrer"
#: forms.py:107 #: forms.py:93
msgid "Password mismatch" msgid "Admin password"
msgstr "Les mots de passe fournis ne sont pas les mêmes." msgstr "Mot de passe administrateur"
#: forms.py:109 #: forms.py:99
msgid "Password confirmation"
msgstr "Confirmation du mot de passe"
#: forms.py:107
msgid "Password"
msgstr "Mot de passe"
#: forms.py:108
msgid "Send me the code by email" msgid "Send me the code by email"
msgstr "Envoyez moi le code par email" msgstr "Envoyez moi le code par email"
#: forms.py:112 #: forms.py:103
msgid "This project does not exists" msgid "This project does not exists"
msgstr "Ce projet n'existe pas" msgstr "Ce projet nexiste pas"
#: forms.py:116 #: forms.py:108
msgid "Password mismatch"
msgstr "Les mots de passe sont différents"
#: forms.py:109
msgid "Password"
msgstr "Mot de passe"
#: forms.py:110
msgid "Password confirmation"
msgstr "Confirmation du mot de passe"
#: forms.py:111
msgid "Reset password"
msgstr "Réinitialiser le mot de passe"
#: forms.py:115
msgid "Date" msgid "Date"
msgstr "Date" msgstr "Date"
#: forms.py:117 #: forms.py:116
msgid "What?" msgid "What?"
msgstr "Quoi ?" msgstr "Quoi ?"
#: forms.py:118 #: forms.py:117
msgid "Payer" msgid "Payer"
msgstr "Payeur" msgstr "Payeur"
#: forms.py:119 #: forms.py:118
msgid "Amount paid" msgid "Amount paid"
msgstr "Montant" msgstr "Montant"
#: forms.py:120 templates/list_bills.html:103 #: forms.py:119 templates/forms.html:100 templates/list_bills.html:101
msgid "For whom?" msgid "For whom?"
msgstr "Pour qui ?" msgstr "Pour qui ?"
#: forms.py:122 #: forms.py:121
msgid "Submit" msgid "Submit"
msgstr "Valider" msgstr "Valider"
#: forms.py:123 #: forms.py:122
msgid "Submit and add a new one" msgid "Submit and add a new one"
msgstr "Valider et ajouter une autre facture" msgstr "Valider et ajouter une autre facture"
#: forms.py:149 #: forms.py:146
msgid "Bills can't be null" msgid "Bills can't be null"
msgstr "Le montant d'une facture ne peut pas être nul." msgstr "Le montant dune facture ne peut pas être nul"
#: forms.py:154 #: forms.py:151
msgid "Name" msgid "Name"
msgstr "Nom" msgstr "Nom"
#: forms.py:155 #: forms.py:152
msgid "Weight" msgid "Weight"
msgstr "Parts" msgstr "Parts"
#: forms.py:155 templates/forms.html:95 #: forms.py:153 templates/forms.html:123
msgid "Add" msgid "Add"
msgstr "Ajouter" msgstr "Ajouter"
#: forms.py:163 #: forms.py:162
msgid "User name incorrect" msgid "User name incorrect"
msgstr "Nom d'utilisateur incorrect" msgstr "Nom dutilisateur incorrect"
#: forms.py:167 #: forms.py:167
msgid "This project already have this member" msgid "This project already have this member"
msgstr "Ce membre existe déjà pour ce projet" msgstr "Ce membre existe déjà pour ce projet"
#: forms.py:178 #: forms.py:183
msgid "People to notify" msgid "People to notify"
msgstr "Personnes à prévenir" msgstr "Personnes à prévenir"
#: forms.py:179 #: forms.py:184
msgid "Send invites" msgid "Send invites"
msgstr "Envoyer les invitations" msgstr "Envoyer les invitations"
#: forms.py:185 #: forms.py:190
#, python-format #, python-format
msgid "The email %(email)s is not valid" msgid "The email %(email)s is not valid"
msgstr "L'email %(email)s est invalide" msgstr "Lemail %(email)s est invalide"
#: forms.py:191 #: forms.py:196
msgid "Start date"
msgstr "Date de départ"
#: forms.py:192
msgid "End date"
msgstr "Date de fin"
#: forms.py:202
msgid "What do you want to download ?" msgid "What do you want to download ?"
msgstr "Que voulez-vous télécharger ?" msgstr "Que voulez-vous télécharger ?"
#: forms.py:205 #: forms.py:199
msgid "bills" msgid "bills"
msgstr "factures" msgstr "factures"
#: forms.py:205 #: forms.py:199
msgid "transactions" msgid "transactions"
msgstr "remboursements" msgstr "remboursements"
#: forms.py:206 #: forms.py:201
msgid "Export file format" msgid "Export file format"
msgstr "Format du fichier d'export" msgstr "Format du fichier dexport"
#: web.py:95 #: web.py:129
msgid "You either provided a bad token or no project identifier."
msgstr "L'identifiant du projet ou le token fourni n'est pas correct."
#: web.py:95
msgid "This private code is not the right one"
msgstr "Le code que vous avez entré n'est pas correct"
#: web.py:106
msgid "This admin password is not the right one. Only %(num)d attempts left."
msgstr "Le mot de passe administrateur que vous avez entré n'est pas correct. Plus que %(num)d tentatives."
#: web.py:106
msgid "Too many failed login attempts, please retry later." msgid "Too many failed login attempts, please retry later."
msgstr "Trop d'échecs d'authentification successifs, veuillez réessayer plus tard." msgstr "Trop d'échecs dauthentification successifs, veuillez réessayer plus tard."
#: web.py:147 #: web.py:144
#, python-format
msgid "This admin password is not the right one. Only %(num)d attempts left."
msgstr ""
"Le mot de passe administrateur que vous avez entré nest pas correct. "
"Plus que %(num)d tentatives."
#: web.py:167
msgid "You either provided a bad token or no project identifier."
msgstr "Lidentifiant du projet ou le token fourni nest pas correct."
#: web.py:195
msgid "This private code is not the right one"
msgstr "Le code que vous avez entré nest pas correct"
#: web.py:242
#, python-format #, python-format
msgid "You have just created '%(project)s' to share your expenses" msgid "You have just created '%(project)s' to share your expenses"
msgstr "Vous venez de créer '%(project)s' pour partager vos dépenses" msgstr "Vous venez de créer « %(project)s » pour partager vos dépenses"
#: web.py:165 #: web.py:260
#, python-format #, python-format
msgid "%(msg_compl)sThe project identifier is %(project)s" msgid "%(msg_compl)sThe project identifier is %(project)s"
msgstr "L'identifiant de ce projet est '%(project)s'" msgstr "%(msg_compl)sLidentifiant de ce projet est %(project)s"
#: web.py:185 #: web.py:281
msgid "A link to reset your password has been sent to your email." msgid "A link to reset your password has been sent to your email."
msgstr "Un lien pour changer votre mot de passe vous a été envoyé par mail." msgstr "Un lien pour changer votre mot de passe vous a été envoyé par mail."
#: web.py:211 #: web.py:291
msgid "No token provided"
msgstr "Aucun token na été fourni"
#: web.py:294
msgid "Invalid token"
msgstr "Token invalide"
#: web.py:297
msgid "Unknown project"
msgstr "Project inconnu"
#: web.py:303
msgid "Password successfully reset."
msgstr "Le mot de passe a été changé avec succès."
#: web.py:351
msgid "Project successfully deleted" msgid "Project successfully deleted"
msgstr "Projet supprimé" msgstr "Projet supprimé"
#: web.py:254 #: web.py:401
#, python-format #, python-format
msgid "You have been invited to share your expenses for %(project)s" msgid "You have been invited to share your expenses for %(project)s"
msgstr "Vous avez été invité à partager vos dépenses pour %(project)s" msgstr "Vous avez été invité à partager vos dépenses pour %(project)s"
#: web.py:259 #: web.py:408
#, python-format
msgid ""No token provided""
msgstr "Aucun token n'a été fourni."
#: web.py:259
#, python-format
msgid "Unknown project"
msgstr "Project inconnu"
#: web.py:261
#, python-format
msgid "Invalid token"
msgstr "Token invalide"
#: web.py:267
#, python-format
msgid "Password successfully reset."
msgstr "Le mot de passe a été changé avec succès."
#: web.py:261
msgid "Your invitations have been sent" msgid "Your invitations have been sent"
msgstr "Vos invitations ont bien été envoyées" msgstr "Vos invitations ont bien été envoyées"
#: web.py:290 #: web.py:439
#, python-format #, python-format
msgid "%(member)s had been added" msgid "%(member)s had been added"
msgstr "%(member)s a bien été ajouté" msgstr "%(member)s a bien été ajouté"
#: web.py:303 #: web.py:452
#, python-format #, python-format
msgid "%(name)s is part of this project again" msgid "%(name)s is part of this project again"
msgstr "%(name)s a rejoint le projet" msgstr "%(name)s a rejoint le projet"
#: web.py:312 #: web.py:461
#, python-format #, python-format
msgid "User '%(name)s' has been deactivated" msgid ""
msgstr "Le membre '%(name)s' a été désactivé" "User '%(name)s' has been deactivated. It will still appear in the users "
"list until its balance becomes zero."
msgstr ""
"Le membre « %(name)s » a été désactivé. Il continuera dapparaître jusqu'à "
"ce que son solde soit nul."
#: web.py:314 #: web.py:465
#, python-format #, python-format
msgid "User '%(name)s' has been deactivated. It will still appear in the users list until its balance becomes zero." msgid "User '%(name)s' has been removed"
msgstr "Le membre '%(name)s' a été désactivé. Il continuera d'apparaître jusqu'à ce que sa balance devienne égale à zéro." msgstr "Le membre « %(name)s » a été supprimé"
#: web.py:331 #: web.py:480
#, python-format
msgid "User '%(name)s' has been edited"
msgstr "Le membre « %(name)s » a été édité"
#: web.py:500
msgid "The bill has been added" msgid "The bill has been added"
msgstr "La facture a bien été ajoutée" msgstr "La facture a bien été ajoutée"
#: web.py:351 #: web.py:520
msgid "The bill has been deleted" msgid "The bill has been deleted"
msgstr "La facture a été supprimée" msgstr "La facture a été supprimée"
#: web.py:369 #: web.py:538
msgid "The bill has been modified" msgid "The bill has been modified"
msgstr "La facture a été modifiée" msgstr "La facture a été modifiée"
#: templates/add_bill.html:9 #: templates/add_bill.html:9 templates/edit_member.html:9
msgid "Back to the list" msgid "Back to the list"
msgstr "Retourner à la liste" msgstr "Retourner à la liste"
#: templates/authenticate.html:6 #: templates/admin.html:10
msgid "" msgid "Administration tasks are currently disabled."
"The project you are trying to access do not exist, do you want to" msgstr "Les tâches dadministration sont actuellement désactivées."
msgstr "Le projet auquel vous essayez d'acceder n'existe pas. Souhaitez vous"
#: templates/authenticate.html:7 #: templates/authenticate.html:6
msgid "The project you are trying to access do not exist, do you want to"
msgstr "Le projet auquel vous essayez daccéder nexiste pas, souhaitez vous"
#: templates/authenticate.html:8
msgid "create it" msgid "create it"
msgstr "le créer" msgstr "le créer"
#: templates/authenticate.html:7 #: templates/authenticate.html:8
msgid "?" msgid "?"
msgstr " ?" msgstr " ?"
#: templates/authenticate.html:7
msgid "Administration tasks are currently disabled."
msgstr "Les tâches d'administration sont actuellement désactivées."
#: templates/create_project.html:4 #: templates/create_project.html:4
msgid "Create a new project" msgid "Create a new project"
@ -314,272 +310,316 @@ msgstr "Facture la plus récente"
msgid "Oldest bill" msgid "Oldest bill"
msgstr "Facture la plus ancienne" msgstr "Facture la plus ancienne"
#: templates/dashboard.html:5 templates/list_bills.html:101
msgid "Actions"
msgstr "Actions"
#: templates/dashboard.html:17 templates/list_bills.html:65
#: templates/list_bills.html:111
msgid "edit"
msgstr "éditer"
#: templates/dashboard.html:18 templates/forms.html:83
#: templates/list_bills.html:112
msgid "delete"
msgstr "supprimer"
#: templates/dashboard.html:25 #: templates/dashboard.html:25
msgid "The Dashboard is currently deactivated." msgid "The Dashboard is currently deactivated."
msgstr "La page d'administration est actuellement désactivée." msgstr "Le tableau de bord est actuellement désactivée."
#: templates/edit_project.html:6 templates/list_bills.html:24 #: templates/edit_project.html:6 templates/list_bills.html:24
msgid "you sure?" msgid "you sure?"
msgstr "c'est sûr ?" msgstr "cest sûr ?"
#: templates/edit_project.html:11 #: templates/edit_project.html:11
msgid "Edit this project" msgid "Edit this project"
msgstr "Éditer ce projet" msgstr "Éditer ce projet"
#: templates/forms.html:23 #: templates/edit_project.html:15
msgid "Can't remember the password?"
msgstr "Vous ne vous souvenez plus du code d'accès ?"
#: templates/forms.html:26
msgid "Cancel"
msgstr "Annuler"
#: templates/forms.html:68
msgid "Edit the project"
msgstr "Éditer le projet"
#: templates/list_bills.html:70
msgid "deactivate"
msgstr "désactiver"
#: templates/forms.html:69 templates/list_bills.html:70
#: templates/list_bills.html:114
msgid "delete"
msgstr "supprimer"
#: templates/forms.html:77
msgid "Edit this bill"
msgstr "Éditer cette facture"
#: templates/forms.html:77 templates/list_bills.html:94
msgid "Add a bill"
msgstr "Ajouter une facture"
#: templates/forms.html:95
msgid "Type user name here"
msgstr "Nouveau participant"
#: templates/forms.html:100
msgid "Edit this member"
msgstr "Éditer ce participant"
#: templates/forms.html:102
msgid "Send the invitations"
msgstr "Envoyer les invitations"
#: templates/forms.html:103
msgid "No, thanks"
msgstr "Non merci"
#: templates/forms.html:136
msgid "Download this project's data" msgid "Download this project's data"
msgstr "Télécharger les données de ce projet" msgstr "Télécharger les données de ce projet"
#: templates/forms.html:136 #: templates/forms.html:27
msgid "Can't remember the password?"
msgstr "Vous ne vous souvenez plus du code daccès ?"
#: templates/forms.html:30
msgid "Cancel"
msgstr "Annuler"
#: templates/forms.html:82
msgid "Edit the project"
msgstr "Éditer le projet"
#: templates/forms.html:91
msgid "Edit this bill"
msgstr "Éditer cette facture"
#: templates/forms.html:91 templates/list_bills.html:89
msgid "Add a bill"
msgstr "Ajouter une facture"
#: templates/forms.html:103
msgid "Select all"
msgstr "Tout cocher"
#: templates/forms.html:103
msgid "Select none"
msgstr "Tout décocher"
#: templates/forms.html:122
msgid "Type user name here"
msgstr "Nouveau participant"
#: templates/forms.html:129
msgid "Edit this member"
msgstr "Éditer ce participant"
#: templates/forms.html:145
msgid "Send the invitations"
msgstr "Envoyer les invitations"
#: templates/forms.html:146
msgid "No, thanks"
msgstr "Non merci"
#: templates/forms.html:157
msgid "Download" msgid "Download"
msgstr "Télécharger" msgstr "Télécharger"
#: templates/home.html:8 #: templates/home.html:7
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"
#: templates/home.html:9 #: templates/home.html:9
msgid "Try out the demo" msgid "Try out the demo"
msgstr "Essayez la démo" msgstr "Essayez la démo"
#: templates/home.html:12 #: templates/home.html:13
msgid "You're sharing a house?" msgid "You're sharing a house?"
msgstr "Vous êtes en colocation ?" msgstr "Vous êtes en colocation ?"
#: templates/home.html:12 #: templates/home.html:13
msgid "Going on holidays with friends?" msgid "Going on holidays with friends?"
msgstr "Partez en vacances avec des amis ?" msgstr "Partez en vacances avec des amis ?"
#: templates/home.html:12 #: templates/home.html:13
msgid "Simply sharing money with others?" msgid "Simply sharing money with others?"
msgstr "Ça vous arrive de partager de l'argent avec d'autres ?" msgstr "Ça vous arrive de partager de largent avec dautres ?"
#: templates/home.html:12 #: templates/home.html:13
msgid "We can help!" msgid "We can help!"
msgstr "On peut vous aider !" msgstr "On peut vous aider !"
#: templates/home.html:24 #: templates/home.html:21
msgid "Log to an existing project" msgid "Log to an existing project"
msgstr "Se connecter à un projet existant" msgstr "Se connecter à un projet existant"
#: templates/home.html:28 #: templates/home.html:25
msgid "log in" msgid "log in"
msgstr "se connecter" msgstr "se connecter"
#: templates/home.html:29 #: templates/home.html:26
msgid "can't remember your password?" msgid "can't remember your password?"
msgstr "vous ne vous souvenez plus du code d'accès ?" msgstr "vous ne vous souvenez plus du code daccès ?"
#: templates/home.html:36 #: templates/home.html:34 templates/home.html:42
msgid "or create a new one" msgid "or create a new one"
msgstr "ou créez en un nouveau" msgstr "ou créez en un nouveau"
#: templates/home.html:40 #: templates/home.html:38
msgid "let's get started" msgid "let's get started"
msgstr "c'est parti !" msgstr "cest parti !"
#: templates/home.html:51 #: templates/home.html:51
msgid "" msgid ""
"This access code will be sent to your friends. It is stored as-is by the " "This access code will be sent to your friends. It is stored as-is by the "
"server, so don\\'t reuse a personal password!" "server, so don\\'t reuse a personal password!"
msgstr "" msgstr ""
"Ce code d\\'accès va être envoyé à vos amis et stocké en clair sur le " "Ce code daccès va être envoyé à vos amis et stocké en clair sur le serveur. "
"serveur.N\\'utilisez pas un mot de passe personnel !" "Nutilisez pas un mot de passe personnel !"
#: templates/layout.html:5 #: templates/layout.html:5
msgid "Account manager" msgid "Account manager"
msgstr "Gestion de comptes" msgstr "Gestion de comptes"
#: templates/layout.html:45 templates/settle_bills.html:4 #: templates/layout.html:39
msgid "Bills" msgid "Bills"
msgstr "Factures" msgstr "Factures"
#: templates/layout.html:46 templates/settle_bills.html:5 #: templates/layout.html:40
msgid "Settle" msgid "Settle"
msgstr "Remboursements" msgstr "Remboursements"
#: templates/layout.html:50 #: templates/layout.html:41
msgid "Statistics" msgid "Statistics"
msgstr "Statistiques" msgstr "Statistiques"
#: templates/layout.html:53 #: templates/layout.html:48
msgid "options" msgid "options"
msgstr "options" msgstr "options"
#: templates/layout.html:55 #: templates/layout.html:50
msgid "Project settings" msgid "Project settings"
msgstr "Options du projet" msgstr "Options du projet"
#: templates/layout.html:59 #: templates/layout.html:54
msgid "switch to" msgid "switch to"
msgstr "aller à" msgstr "aller à"
#: templates/layout.html:62 #: templates/layout.html:57
msgid "Start a new project" msgid "Start a new project"
msgstr "Nouveau projet" msgstr "Nouveau projet"
#: templates/layout.html:64 #: templates/layout.html:59
msgid "Logout" msgid "Logout"
msgstr "Se déconnecter" msgstr "Se déconnecter"
#: templates/layout.html:92 #: templates/layout.html:66
msgid "Dashboard"
msgstr "Tableau de bord"
#: templates/layout.html:89
msgid "This is a free software" msgid "This is a free software"
msgstr "Ceci est un logiciel libre" msgstr "Ceci est un logiciel libre"
#: templates/layout.html:92 #: templates/layout.html:89
msgid "you can contribute and improve it!" msgid "you can contribute and improve it!"
msgstr "vous pouvez y contribuer et l'améliorer" msgstr "vous pouvez y contribuer et laméliorer !"
#: templates/list_bills.html:74 #: templates/list_bills.html:63
msgid "deactivate"
msgstr "désactiver"
#: templates/list_bills.html:70
msgid "reactivate" msgid "reactivate"
msgstr "ré-activer" msgstr "ré-activer"
#: templates/list_bills.html:88 #: templates/list_bills.html:82
msgid "Invite"
msgstr "Invitez"
#: templates/list_bills.html:88
msgid "Invite people to join this project!" msgid "Invite people to join this project!"
msgstr "Invitez d'autres personnes à rejoindre ce projet !" msgstr "Invitez dautres personnes à rejoindre ce projet !"
#: templates/list_bills.html:89 #: templates/list_bills.html:83
msgid "Add a new bill" msgid "Add a new bill"
msgstr "Nouvelle facture" msgstr "Nouvelle facture"
#: templates/list_bills.html:103 #: templates/list_bills.html:101
msgid "When?" msgid "When?"
msgstr "Quand ?" msgstr "Quand ?"
#: templates/list_bills.html:103 #: templates/list_bills.html:101
msgid "Who paid?" msgid "Who paid?"
msgstr "Qui a payé ?" msgstr "Qui a payé ?"
#: templates/list_bills.html:103 #: templates/list_bills.html:101
msgid "For what?" msgid "For what?"
msgstr "Pour quoi ?" msgstr "Pour quoi ?"
#: templates/list_bills.html:103 templates/settle_bills.html:31 #: templates/list_bills.html:101 templates/settle_bills.html:22
msgid "How much?" msgid "How much?"
msgstr "Combien ?" msgstr "Combien ?"
#: templates/list_bills.html:103 #: templates/list_bills.html:109
msgid "Actions"
msgstr "Actions"
#: templates/list_bills.html:111
msgid "each" msgid "each"
msgstr "chacun" msgstr "chacun"
#: templates/list_bills.html:113 #: templates/list_bills.html:120
msgid "edit"
msgstr "éditer"
#: templates/list_bills.html:122
msgid "Nothing to list yet. You probably want to" msgid "Nothing to list yet. You probably want to"
msgstr "Rien à lister pour l'instant. Vous voulez surement" msgstr "Rien à lister pour linstant. Vous voulez surement"
#: templates/list_bills.html:122 #: templates/list_bills.html:120
msgid "add a bill" msgid "add a bill"
msgstr "ajouter une facture" msgstr "ajouter une facture"
#: templates/password_reminder.html:4 #: templates/password_reminder.html:4
msgid "Password reminder" msgid "Password reminder"
msgstr "Rappel du code d'accès" msgstr "Rappel du code daccès"
#: templates/recent_projects.html:2 #: templates/recent_projects.html:2
msgid "Your projects" msgid "Your projects"
msgstr "Vos projets" msgstr "Vos projets"
#: templates/reset_password.html:2 #: templates/reset_password.html:7
msgid "Reset your password" msgid "Reset your password"
msgstr "Changez votre mot de passe" msgstr "Changez votre mot de passe"
#: templates/send_invites.html:11 #: templates/send_invites.html:4
msgid "Invite people to join this project" msgid "Invite people to join this project"
msgstr "Invitez des personnes à rejoindre ce projet" msgstr "Invitez des personnes à rejoindre ce projet"
#: templates/send_invites.html:12 #: templates/send_invites.html:5
msgid "" msgid ""
"Specify a (comma separated) list of email adresses you want to notify " "Specify a (comma separated) list of email adresses you want to notify "
"about the\n" "about the\n"
"creation of this budget management project and we will send them an email" "creation of this budget management project and we will send them an email"
" for you." " for you."
msgstr "" msgstr ""
"Entrez les addresses des personnes que vous souhaitez inviter, séparées " "Entrez les adresses des personnes que vous souhaitez inviter,\n"
"par des virgules. On s'occupe de leur envoyer un email." "séparées par des virgules, on soccupe de leur envoyer un email."
#: templates/send_invites.html:14 #: templates/send_invites.html:7
msgid "If you prefer, you can share the project identifier and the shared\n" msgid ""
"password by other communication means. Or even directly share the following link:" "If you prefer, you can share the project identifier and the shared\n"
msgstr "Si vous préférez vous pouvez partager l'identifiant du projet et son mot " "password by other communication means. Or even directly share the "
"de passe par un autre moyen de communication. Ou directement partager le lien " "following link:"
"suivant :" msgstr ""
"Si vous préférez vous pouvez partager lidentifiant du projet\n"
"et son mot de passe par un autre moyen de communication. Ou directement "
"partager le lien suivant :"
#: templates/settle_bills.html:31 #: templates/settle_bills.html:22
msgid "Who pays?" msgid "Who pays?"
msgstr "Qui doit payer ?" msgstr "Qui doit payer ?"
#: templates/settle_bills.html:31 #: templates/settle_bills.html:22
msgid "To whom?" msgid "To whom?"
msgstr "Pour qui ?" msgstr "Pour qui ?"
#: templates/statistics.html:22 #: templates/statistics.html:21
msgid "Who?" msgid "Who?"
msgstr "Qui ?" msgstr "Qui ?"
#: templates/statistics.html:22 #: templates/statistics.html:21
msgid "Paid" msgid "Paid"
msgstr "A payé" msgstr "A payé"
#: templates/statistics.html:22 #: templates/statistics.html:21
msgid "Spent" msgid "Spent"
msgstr "A dépensé" msgstr "A dépensé"
#: templates/statistics.html:22 #: templates/statistics.html:21
msgid "Balance" msgid "Balance"
msgstr "Solde" msgstr "Solde"
#~ msgid ""
#~ "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."
#~ msgstr ""
#~ "Lidentifiant du projet est utilisé pour"
#~ " se connecter.Nous avons essayé de "
#~ "générer un identifiant mais celui ci "
#~ "existe déjà. Merci de créer un "
#~ "nouvel identifiant que vous serez "
#~ "capable de retenir"
#~ msgid "Start date"
#~ msgstr "Date de départ"
#~ msgid "End date"
#~ msgstr "Date de fin"
#~ msgid "\"No token provided\""
#~ msgstr "Aucun token na été fourni."
#~ msgid "User '%(name)s' has been deactivated"
#~ msgstr "Le membre '%(name)s' a été désactivé"
#~ msgid "Invite"
#~ msgstr "Invitez"

View file

@ -2,8 +2,8 @@ import base64
import re import re
from io import BytesIO, StringIO from io import BytesIO, StringIO
from jinja2 import filters import jinja2
from json import dumps from json import dumps, JSONEncoder
from flask import redirect from flask import redirect
from werkzeug.routing import HTTPException, RoutingException from werkzeug.routing import HTTPException, RoutingException
import six import six
@ -83,7 +83,7 @@ def minimal_round(*args, **kw):
from http://stackoverflow.com/questions/28458524/ from http://stackoverflow.com/questions/28458524/
""" """
# Use the original round filter, to deal with the extra arguments # Use the original round filter, to deal with the extra arguments
res = filters.do_round(*args, **kw) res = jinja2.filters.do_round(*args, **kw)
# Test if the result is equivalent to an integer and # Test if the result is equivalent to an integer and
# return depending on it # return depending on it
ires = int(res) ires = int(res)
@ -170,3 +170,43 @@ class LoginThrottler():
def reset(self, ip): def reset(self, ip):
self._attempts.pop(ip, None) self._attempts.pop(ip, None)
def create_jinja_env(folder, strict_rendering=False):
"""Creates and return a Jinja2 Environment object, used, to load the
templates.
:param strict_rendering:
if set to `True`, all templates which use an undefined variable will
throw an exception (default to `False`).
"""
loader = jinja2.PackageLoader('ihatemoney', folder)
kwargs = {'loader': loader}
if strict_rendering:
kwargs['undefined'] = jinja2.StrictUndefined
return jinja2.Environment(**kwargs)
class IhmJSONEncoder(JSONEncoder):
"""Subclass of the default encoder to support custom objects.
Taken from the deprecated flask-rest package."""
def default(self, o):
if hasattr(o, "_to_serialize"):
# build up the object
data = {}
for attr in o._to_serialize:
data[attr] = getattr(o, attr)
return data
elif hasattr(o, "isoformat"):
return o.isoformat()
else:
try:
from flask_babel import speaklater
if isinstance(o, speaklater.LazyString):
try:
return unicode(o) # For python 2.
except NameError:
return str(o) # For python 3.
except ImportError:
pass
return JSONEncoder.default(self, o)

View file

@ -242,7 +242,7 @@ def create_project():
message_title = _("You have just created '%(project)s' " message_title = _("You have just created '%(project)s' "
"to share your expenses", project=g.project.name) "to share your expenses", project=g.project.name)
message_body = render_template("reminder_mail.%s" % message_body = render_template("reminder_mail.%s.j2" %
get_locale().language) get_locale().language)
msg = Message(message_title, msg = Message(message_title,
@ -273,7 +273,7 @@ def remind_password():
project = Project.query.get(form.id.data) project = Project.query.get(form.id.data)
# send a link to reset the password # send a link to reset the password
password_reminder = "password_reminder.%s" % get_locale().language password_reminder = "password_reminder.%s.j2" % get_locale().language
current_app.mail.send(Message( current_app.mail.send(Message(
"password recovery", "password recovery",
body=render_template(password_reminder, project=project), body=render_template(password_reminder, project=project),
@ -395,7 +395,7 @@ def invite():
if form.validate(): if form.validate():
# send the email # send the email
message_body = render_template("invitation_mail.%s" % message_body = render_template("invitation_mail.%s.j2" %
get_locale().language) get_locale().language)
message_title = _("You have been invited to share your " message_title = _("You have been invited to share your "
@ -566,21 +566,9 @@ def settle_bill():
@main.route("/<project_id>/statistics") @main.route("/<project_id>/statistics")
def statistics(): def statistics():
"""Compute what each member has paid and spent and display it""" """Compute what each member has paid and spent and display it"""
members = g.project.active_members
balance = g.project.balance
paid = {}
spent = {}
for member in members:
paid[member.id] = sum([bill.amount
for bill in g.project.get_member_bills(member.id).all()])
spent[member.id] = sum([bill.pay_each() * member.weight
for bill in g.project.get_bills().all() if member in bill.owers])
return render_template( return render_template(
"statistics.html", "statistics.html",
members=members, members_stats=g.project.members_stats,
balance=balance,
paid=paid,
spent=spent,
current_view='statistics', current_view='statistics',
) )

View file

@ -5,7 +5,7 @@ flask-mail>=0.8
Flask-Migrate>=1.8.0 Flask-Migrate>=1.8.0
Flask-script Flask-script
flask-babel flask-babel
flask-rest>=1.3 flask-restful>=0.3.6
jinja2>=2.6 jinja2>=2.6
raven raven
blinker blinker

View file

@ -2,16 +2,6 @@
import codecs import codecs
import os import os
from setuptools import setup, find_packages from setuptools import setup, find_packages
try:
from pip.req import parse_requirements
from pip.download import PipSession
except ImportError:
print('Cannot find pip.')
raise
# Get requirements from the requirements.txt file.
pip_requirements = parse_requirements("requirements.txt", session=PipSession())
install_requires = [str(ir.req) for ir in pip_requirements]
here = os.path.abspath(os.path.dirname(__file__)) here = os.path.abspath(os.path.dirname(__file__))
@ -23,6 +13,13 @@ def read_file(filename):
return content return content
def parse_requirements(filename):
""" load requirements from a pip requirements file """
with open(filename) as lines:
lineiter = (line.strip() for line in lines)
return [line for line in lineiter if line and not line.startswith("#")]
README = read_file('README.rst') README = read_file('README.rst')
CHANGELOG = read_file('CHANGELOG.rst') CHANGELOG = read_file('CHANGELOG.rst')
@ -37,7 +34,7 @@ ENTRY_POINTS = {
setup(name='ihatemoney', setup(name='ihatemoney',
version='2.1.dev0', version='2.1.1.dev0',
description='A simple shared budget manager web application.', description='A simple shared budget manager web application.',
long_description="{}\n\n{}".format(README.encode('utf-8'), CHANGELOG.encode('utf-8')), long_description="{}\n\n{}".format(README.encode('utf-8'), CHANGELOG.encode('utf-8')),
license='Custom BSD Beerware', license='Custom BSD Beerware',
@ -59,5 +56,5 @@ setup(name='ihatemoney',
packages=find_packages(), packages=find_packages(),
include_package_data=True, include_package_data=True,
zip_safe=False, zip_safe=False,
install_requires=install_requires, install_requires=parse_requirements('requirements.txt'),
entry_points=ENTRY_POINTS) entry_points=ENTRY_POINTS)

13
tox.ini
View file

@ -6,22 +6,31 @@ skip_missing_interpreters = True
commands = commands =
python --version python --version
py.test ihatemoney/tests/tests.py py.test --pyargs ihatemoney.tests.tests
deps = deps =
-rdev-requirements.txt -rdev-requirements.txt
-rrequirements.txt -rrequirements.txt
install_command = pip install --pre {opts} {packages} # To be sure we are importing ihatemoney pkg from pip-installed version
changedir = /tmp
install_command =
pip install --pre {opts} {packages}
pip install .
[testenv:docs] [testenv:docs]
commands = sphinx-build -a -n -b html -d docs/_build/doctrees docs docs/_build/html commands = sphinx-build -a -n -b html -d docs/_build/doctrees docs docs/_build/html
deps = deps =
-rdocs/requirements.txt -rdocs/requirements.txt
changedir = {toxinidir}
[testenv:lint] [testenv:lint]
commands = flake8 ihatemoney commands = flake8 ihatemoney
deps = deps =
-rdev-requirements.txt -rdev-requirements.txt
changedir = {toxinidir}
[flake8] [flake8]
exclude = migrations exclude = migrations