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.
2.1 (unreleased)
----------------
2.1.1 (unreleased)
------------------
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)
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)
----------------

View file

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

View file

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

View file

@ -1,3 +1,3 @@
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

View file

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

View file

@ -3,3 +3,4 @@ tox
pytest
Flask-Testing
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\
https://ihatemoney.org/api/projects/demo/bills/80\
"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>`_.
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
=========
@ -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
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
=============

View file

@ -1,62 +1,75 @@
# -*- coding: utf-8 -*-
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 ihatemoney.models import db, Project, Person, Bill
from ihatemoney.forms import (ProjectForm, EditProjectForm, MemberForm,
get_billform_for)
from werkzeug.security import check_password_hash
from functools import wraps
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.
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
"""
@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" in kwargs and \
auth.username == kwargs["project_id"]:
if auth and project_id and auth.username == project_id:
project = Project.query.get(auth.username)
if project and check_password_hash(project.password, auth.password):
return project
return False
# The whole project object will be passed instead of project_id
kwargs.pop("project_id")
return f(*args, project=project, **kwargs)
abort(401)
return wrapper
class ProjectHandler(object):
def add(self):
class ProjectsHandler(Resource):
def post(self):
form = ProjectForm(meta={'csrf': False})
if form.validate():
project = form.save()
db.session.add(project)
db.session.commit()
return 201, project.id
return 400, form.errors
return project.id, 201
return form.errors, 400
class ProjectHandler(Resource):
method_decorators = [need_auth]
@need_auth(check_project, "project")
def get(self, project):
return 200, project
return project
@need_auth(check_project, "project")
def delete(self, project):
db.session.delete(project)
db.session.commit()
return 200, "DELETED"
return "DELETED"
@need_auth(check_project, "project")
def update(self, project):
def put(self, project):
form = EditProjectForm(meta={'csrf': False})
if form.validate():
form.update(project)
db.session.commit()
return 200, "UPDATED"
return 400, form.errors
return "UPDATED"
return form.errors, 400
class ProjectStatsHandler(Resource):
method_decorators = [need_auth]
def get(self, project):
return project.members_stats
class APIMemberForm(MemberForm):
@ -71,98 +84,93 @@ class APIMemberForm(MemberForm):
return super(APIMemberForm, self).save(project, person)
class MemberHandler(object):
class MembersHandler(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 404, "Not Found"
return 200, member
def get(self, project):
return project.members
def list(self, project):
return 200, project.members
def add(self, project):
def post(self, project):
form = MemberForm(project, meta={'csrf': False})
if form.validate():
member = Person()
form.save(project, member)
db.session.commit()
return 201, member.id
return 400, form.errors
return member.id, 201
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)
if form.validate():
member = Person.query.get(member_id, project)
form.save(project, member)
db.session.commit()
return 200, member
return 400, form.errors
return member
return form.errors, 400
def delete(self, project, member_id):
if project.remove_member(member_id):
return 200, "OK"
return 404, "Not Found"
return "OK"
return "Not Found", 404
class BillHandler(object):
class BillsHandler(Resource):
method_decorators = [need_auth]
def get(self, project, bill_id):
bill = Bill.query.get(project, bill_id)
if not bill:
return 404, "Not Found"
return 200, bill
def list(self, project):
def get(self, project):
return project.get_bills().all()
def add(self, project):
def post(self, project):
form = get_billform_for(project, True, meta={'csrf': False})
if form.validate():
bill = Bill()
form.save(bill, project)
db.session.add(bill)
db.session.commit()
return 201, bill.id
return 400, form.errors
return bill.id, 201
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})
if form.validate():
bill = Bill.query.get(project, bill_id)
form.save(bill, project)
db.session.commit()
return 200, bill.id
return 400, form.errors
return bill.id, 200
return form.errors, 400
def delete(self, project, bill_id):
bill = Bill.query.delete(project, bill_id)
db.session.commit()
if not bill:
return 404, "Not Found"
return 200, "OK"
return "Not Found", 404
return "OK", 200
project_resource = RESTResource(
name="project",
route="/projects",
app=api,
actions=["add", "update", "delete", "get"],
handler=ProjectHandler())
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)
restful_api.add_resource(ProjectsHandler, '/projects')
restful_api.add_resource(ProjectHandler, '/projects/<string:project_id>')
restful_api.add_resource(MembersHandler, "/projects/<string:project_id>/members")
restful_api.add_resource(ProjectStatsHandler, "/projects/<string:project_id>/statistics")
restful_api.add_resource(MemberHandler, "/projects/<string:project_id>/members/<int:member_id>")
restful_api.add_resource(BillsHandler, "/projects/<string:project_id>/bills")
restful_api.add_resource(BillHandler, "/projects/<string:project_id>/bills/<int:bill_id>")

View file

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

View file

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

View file

@ -1,111 +1,127 @@
# Translations template for PROJECT.
# Copyright (C) 2013 ORGANIZATION
# Copyright (C) 2018 ORGANIZATION
# This file is distributed under the same license as the PROJECT project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2013.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2018.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\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"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 0.9.6\n"
"Generated-By: Babel 2.5.3\n"
#: forms.py:22
msgid "Select all"
msgstr ""
#: forms.py:22
msgid "Select none"
msgstr ""
#: forms.py:61
#: forms.py:46
msgid "Project name"
msgstr ""
#: forms.py:62 forms.py:86 forms.py:102
#: forms.py:47 forms.py:71 forms.py:88
msgid "Private code"
msgstr ""
#: forms.py:63
#: forms.py:48
msgid "Email"
msgstr ""
#: forms.py:85 forms.py:101 forms.py:107
#: forms.py:70 forms.py:87 forms.py:98
msgid "Project identifier"
msgstr ""
#: forms.py:87 templates/send_invites.html:5
#: forms.py:72
msgid "Create the project"
msgstr ""
#: forms.py:92
#: forms.py:77
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."
"be able to remember"
msgstr ""
#: forms.py:103
#: forms.py:89 forms.py:94
msgid "Get in"
msgstr ""
#: forms.py:108
#: forms.py:93
msgid "Admin password"
msgstr ""
#: forms.py:99
msgid "Send me the code by email"
msgstr ""
#: forms.py:112
#: forms.py:103
msgid "This project does not exists"
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"
msgstr ""
#: forms.py:117
#: forms.py:116
msgid "What?"
msgstr ""
#: forms.py:118
#: forms.py:117
msgid "Payer"
msgstr ""
#: forms.py:119
#: forms.py:118
msgid "Amount paid"
msgstr ""
#: forms.py:120 templates/list_bills.html:103
#: forms.py:119 templates/forms.html:100 templates/list_bills.html:101
msgid "For whom?"
msgstr ""
#: forms.py:122
#: forms.py:121
msgid "Submit"
msgstr ""
#: forms.py:123
#: forms.py:122
msgid "Submit and add a new one"
msgstr ""
#: forms.py:149
#: forms.py:146
msgid "Bills can't be null"
msgstr ""
#: forms.py:154
#: forms.py:151
msgid "Name"
msgstr ""
#: forms.py:155 templates/forms.html:95
#: forms.py:152
msgid "Weight"
msgstr ""
#: forms.py:153 templates/forms.html:123
msgid "Add"
msgstr ""
#: forms.py:163
#: forms.py:162
msgid "User name incorrect"
msgstr ""
@ -113,105 +129,151 @@ msgstr ""
msgid "This project already have this member"
msgstr ""
#: forms.py:178
#: forms.py:183
msgid "People to notify"
msgstr ""
#: forms.py:179
#: forms.py:184
msgid "Send invites"
msgstr ""
#: forms.py:185
#: forms.py:190
#, python-format
msgid "The email %(email)s is not valid"
msgstr ""
#: forms.py:191
msgid "Start date"
#: forms.py:196
msgid "What do you want to download ?"
msgstr ""
#: forms.py:192
msgid "End date"
#: forms.py:199
msgid "bills"
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"
msgstr ""
#: web.py:147
#: web.py:242
#, python-format
msgid "You have just created '%(project)s' to share your expenses"
msgstr ""
#: web.py:165
#: web.py:260
#, python-format
msgid "%(msg_compl)sThe project identifier is %(project)s"
msgstr ""
#: web.py:185
msgid "a mail has been sent to you with the password"
#: web.py:281
msgid "A link to reset your password has been sent to your email."
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"
msgstr ""
#: web.py:254
#: web.py:401
#, python-format
msgid "You have been invited to share your expenses for %(project)s"
msgstr ""
#: web.py:261
#: web.py:408
msgid "Your invitations have been sent"
msgstr ""
#: web.py:290
#: web.py:439
#, python-format
msgid "%(member)s had been added"
msgstr ""
#: web.py:303
#: web.py:452
#, python-format
msgid "%(name)s is part of this project again"
msgstr ""
#: web.py:312
#: web.py:461
#, 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 ""
#: web.py:314
#: web.py:465
#, python-format
msgid "User '%(name)s' has been removed"
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"
msgstr ""
#: web.py:351
#: web.py:520
msgid "The bill has been deleted"
msgstr ""
#: web.py:369
#: web.py:538
msgid "The bill has been modified"
msgstr ""
#: templates/add_bill.html:9
#: templates/add_bill.html:9 templates/edit_member.html:9
msgid "Back to the list"
msgstr ""
#: templates/authenticate.html:6
msgid ""
"The project you are trying to access do not exist, do you want \n"
"to"
#: templates/admin.html:10
msgid "Administration tasks are currently disabled."
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"
msgstr ""
#: templates/authenticate.html:7
#: templates/authenticate.html:8
msgid "?"
msgstr ""
@ -239,6 +301,24 @@ msgstr ""
msgid "Oldest bill"
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
msgid "you sure?"
msgstr ""
@ -247,44 +327,59 @@ msgstr ""
msgid "Edit this project"
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?"
msgstr ""
#: templates/forms.html:26
#: templates/forms.html:30
msgid "Cancel"
msgstr ""
#: templates/forms.html:68
#: templates/forms.html:82
msgid "Edit the project"
msgstr ""
#: templates/forms.html:69 templates/list_bills.html:70
#: templates/list_bills.html:114
msgid "delete"
msgstr ""
#: templates/forms.html:77
#: templates/forms.html:91
msgid "Edit this bill"
msgstr ""
#: templates/forms.html:77 templates/list_bills.html:94
#: templates/forms.html:91 templates/list_bills.html:89
msgid "Add a bill"
msgstr ""
#: templates/forms.html:95
msgid "Type user name here"
msgstr ""
#: templates/forms.html:102
msgid "Send the invitations"
#: templates/forms.html:103
msgid "Select all"
msgstr ""
#: 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"
msgstr ""
#: templates/home.html:8
#: templates/forms.html:157
msgid "Download"
msgstr ""
#: templates/home.html:7
msgid "Manage your shared <br>expenses, easily"
msgstr ""
@ -292,39 +387,39 @@ msgstr ""
msgid "Try out the demo"
msgstr ""
#: templates/home.html:12
#: templates/home.html:13
msgid "You're sharing a house?"
msgstr ""
#: templates/home.html:12
#: templates/home.html:13
msgid "Going on holidays with friends?"
msgstr ""
#: templates/home.html:12
#: templates/home.html:13
msgid "Simply sharing money with others?"
msgstr ""
#: templates/home.html:12
#: templates/home.html:13
msgid "We can help!"
msgstr ""
#: templates/home.html:24
#: templates/home.html:21
msgid "Log to an existing project"
msgstr ""
#: templates/home.html:28
#: templates/home.html:25
msgid "log in"
msgstr ""
#: templates/home.html:29
#: templates/home.html:26
msgid "can't remember your password?"
msgstr ""
#: templates/home.html:36
#: templates/home.html:34 templates/home.html:42
msgid "or create a new one"
msgstr ""
#: templates/home.html:40
#: templates/home.html:38
msgid "let's get started"
msgstr ""
@ -338,91 +433,91 @@ msgstr ""
msgid "Account manager"
msgstr ""
#: templates/layout.html:45 templates/settle_bills.html:4
#: templates/layout.html:39
msgid "Bills"
msgstr ""
#: templates/layout.html:46 templates/settle_bills.html:5
#: templates/layout.html:40
msgid "Settle"
msgstr ""
#: templates/layout.html:53
#: templates/layout.html:41
msgid "Statistics"
msgstr ""
#: templates/layout.html:48
msgid "options"
msgstr ""
#: templates/layout.html:55
#: templates/layout.html:50
msgid "Project settings"
msgstr ""
#: templates/layout.html:59
#: templates/layout.html:54
msgid "switch to"
msgstr ""
#: templates/layout.html:62
#: templates/layout.html:57
msgid "Start a new project"
msgstr ""
#: templates/layout.html:64
#: templates/layout.html:59
msgid "Logout"
msgstr ""
#: templates/layout.html:92
#: templates/layout.html:66
msgid "Dashboard"
msgstr ""
#: templates/layout.html:89
msgid "This is a free software"
msgstr ""
#: templates/layout.html:92
#: templates/layout.html:89
msgid "you can contribute and improve it!"
msgstr ""
#: templates/list_bills.html:74
#: templates/list_bills.html:63
msgid "deactivate"
msgstr ""
#: templates/list_bills.html:70
msgid "reactivate"
msgstr ""
#: templates/list_bills.html:88
msgid "The project identifier is"
#: templates/list_bills.html:82
msgid "Invite people to join this project!"
msgstr ""
#: templates/list_bills.html:88
msgid "remember it!"
msgstr ""
#: templates/list_bills.html:89
#: templates/list_bills.html:83
msgid "Add a new bill"
msgstr ""
#: templates/list_bills.html:103
#: templates/list_bills.html:101
msgid "When?"
msgstr ""
#: templates/list_bills.html:103
#: templates/list_bills.html:101
msgid "Who paid?"
msgstr ""
#: templates/list_bills.html:103
#: templates/list_bills.html:101
msgid "For what?"
msgstr ""
#: templates/list_bills.html:103 templates/settle_bills.html:31
#: templates/list_bills.html:101 templates/settle_bills.html:22
msgid "How much?"
msgstr ""
#: templates/list_bills.html:103
msgid "Actions"
msgstr ""
#: templates/list_bills.html:111
#: templates/list_bills.html:109
msgid "each"
msgstr ""
#: templates/list_bills.html:113
msgid "edit"
msgstr ""
#: templates/list_bills.html:122
#: templates/list_bills.html:120
msgid "Nothing to list yet. You probably want to"
msgstr ""
#: templates/list_bills.html:122
#: templates/list_bills.html:120
msgid "add a bill"
msgstr ""
@ -434,43 +529,50 @@ msgstr ""
msgid "Your projects"
msgstr ""
#: templates/send_invites.html:6
msgid "Invite people"
#: templates/reset_password.html:7
msgid "Reset your password"
msgstr ""
#: templates/send_invites.html:7
msgid "Use it!"
msgstr ""
#: templates/send_invites.html:11
#: templates/send_invites.html:4
msgid "Invite people to join this project"
msgstr ""
#: templates/send_invites.html:12
#: templates/send_invites.html:5
msgid ""
"Specify a (coma separated) list of email adresses you want to notify "
"about the \n"
"Specify a (comma separated) list of email adresses you want to notify "
"about the\n"
"creation of this budget management project and we will send them an email"
" for you."
msgstr ""
#: templates/send_invites.html:14
msgid "If you prefer, you can"
#: templates/send_invites.html:7
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 ""
#: templates/send_invites.html:14
msgid "skip this step"
msgstr ""
#: templates/send_invites.html:14
msgid "and notify them yourself"
msgstr ""
#: templates/settle_bills.html:31
#: templates/settle_bills.html:22
msgid "Who pays?"
msgstr ""
#: templates/settle_bills.html:31
#: templates/settle_bills.html:22
msgid "To whom?"
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
@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
def uses_weights(self):
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.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 import default_settings
@ -68,6 +68,8 @@ def load_configuration(app, configuration=None):
app.config.from_pyfile(env_var_config)
else:
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):

View file

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

View file

@ -4,7 +4,7 @@
<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>
<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>
{% if project.has_bills() %}
<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 }}".
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()) }}.
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.
Enjoy,

View file

@ -1,11 +1,11 @@
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()) }}.
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,

View file

@ -2,7 +2,7 @@ Hi,
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)) }}.
This link is only valid for 1 hour.
This link is only valid for one hour.
Hope this helps,
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>
<tbody>
{% 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.receiver }}</td>
<td>{{ "%0.2f"|format(bill.amount) }}</td>

View file

@ -3,12 +3,11 @@
{% block sidebar %}
<div id="table_overflow">
<table class="balance table">
{% set balance = g.project.balance %}
{% 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 %}>
<td class="balance-name">{{ member.name }}</td>
<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]|round(2) > 0 %}+{% endif %}{{ "%.2f" | format(balance[member.id]) }}
{% for stat in members_stats| sort(attribute='member.name') %}
<tr>
<td class="balance-name">{{ stat.member.name }}</td>
<td class="balance-value {% if stat.balance|round(2) > 0 %}positive{% elif stat.balance|round(2) < 0 %}negative{% endif %}">
{% if stat.balance|round(2) > 0 %}+{% endif %}{{ "%.2f" | format(stat.balance) }}
</td>
</tr>
{% endfor %}
@ -21,12 +20,12 @@
<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>
<tbody>
{% for member in members %}
<tr class="{{ loop.cycle("odd", "even") }}">
<td>{{ member.name }}</td>
<td>{{ "%0.2f"|format(paid[member.id]) }}</td>
<td>{{ "%0.2f"|format(spent[member.id]) }}</td>
<td>{{ "%0.2f"|format(balance[member.id]) }}</td>
{% for stat in members_stats %}
<tr>
<td>{{ stat.member.name }}</td>
<td>{{ "%0.2f"|format(stat.paid) }}</td>
<td>{{ "%0.2f"|format(stat.spent) }}</td>
<td>{{ "%0.2f"|format(stat.balance) }}</td>
</tr>
{% endfor %}
</tbody>

View file

@ -4,6 +4,10 @@ try:
import unittest2 as unittest
except ImportError:
import unittest # NOQA
try:
from unittest.mock import patch
except ImportError:
from mock import patch
import os
import json
@ -16,6 +20,7 @@ from flask import session
from flask_testing import TestCase
from ihatemoney.run import create_app, db, load_configuration
from ihatemoney.manage import GenerateConfig, GeneratePasswordHash
from ihatemoney import models
from ihatemoney import utils
@ -1048,7 +1053,7 @@ class APITestCase(IhatemoneyTestCase):
})
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'))
# create it
@ -1134,7 +1139,7 @@ class APITestCase(IhatemoneyTestCase):
headers=self.get_auth("raclette"))
self.assertStatus(200, req)
self.assertEqual('[]', req.data.decode('utf-8'))
self.assertEqual('[]\n', req.data.decode('utf-8'))
# add a member
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
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
req = self.client.get("/api/projects/raclette/members",
@ -1218,7 +1223,7 @@ class APITestCase(IhatemoneyTestCase):
headers=self.get_auth("raclette"))
self.assertStatus(200, req)
self.assertEqual('[]', req.data.decode('utf-8'))
self.assertEqual('[]\n', req.data.decode('utf-8'))
def test_bills(self):
# create a project
@ -1234,7 +1239,7 @@ class APITestCase(IhatemoneyTestCase):
headers=self.get_auth("raclette"))
self.assertStatus(200, req)
self.assertEqual("[]", req.data.decode('utf-8'))
self.assertEqual("[]\n", req.data.decode('utf-8'))
# add a bill
req = self.client.post("/api/projects/raclette/bills", data={
@ -1247,7 +1252,7 @@ class APITestCase(IhatemoneyTestCase):
# should return the id
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
req = self.client.get("/api/projects/raclette/bills/1",
@ -1283,7 +1288,7 @@ class APITestCase(IhatemoneyTestCase):
}, headers=self.get_auth("raclette"))
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
req = self.client.put("/api/projects/raclette/bills/1", data={
@ -1320,6 +1325,40 @@ class APITestCase(IhatemoneyTestCase):
headers=self.get_auth("raclette"))
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):
# create a project
# self.api_create("raclette")
@ -1406,5 +1445,27 @@ class ServerTestCase(IhatemoneyTestCase):
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__":
unittest.main()

View file

@ -2,293 +2,289 @@
# Copyright (C) 2011 ORGANIZATION
# This file is distributed under the same license as the PROJECT project.
# Alexis Métaireau <alexis@notmyidea.org>, 2011.
#
# Adrien CLERC, 2018.
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2013-10-13 21:32+0200\n"
"PO-Revision-Date: 2011-10-14 23:51+0200\n"
"Last-Translator: Quentin Roy <royque@gmail.com>\n"
"POT-Creation-Date: 2018-05-15 21:43+0200\n"
"PO-Revision-Date: 2018-05-15 22:00+0200\n"
"Last-Translator: Adrien CLERC <>\n"
"Language-Team: fr <LL@li.org>\n"
"Plural-Forms: nplurals=2; plural=(n > 1)\n"
"Language: fr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\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
msgid "Select all"
msgstr "Tout cocher"
#: forms.py:22
msgid "Select none"
msgstr "Tout décocher"
#: forms.py:61
#: forms.py:46
msgid "Project name"
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"
msgstr "Code d'accès"
msgstr "Code daccès"
#: forms.py:63
#: forms.py:48
msgid "Email"
msgstr "Email"
#: forms.py:85 forms.py:101 forms.py:107
#: forms.py:70 forms.py:87 forms.py:98
msgid "Project identifier"
msgstr "Identifiant du projet"
#: forms.py:87
msgid "Admin password"
msgstr "Mot de passe administrateur"
#: forms.py:87 templates/send_invites.html:5
#: forms.py:72
msgid "Create the project"
msgstr "Créer le projet"
#: forms.py:92
#: forms.py:77
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."
"be able to remember"
msgstr ""
"L'identifiant 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"
"Lidentifiant du projet est utilisé pour se connecter et pour lURL du "
"projet. 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"
#: forms.py:103
#: forms.py:89 forms.py:94
msgid "Get in"
msgstr "Entrer"
#: forms.py:107
msgid "Password mismatch"
msgstr "Les mots de passe fournis ne sont pas les mêmes."
#: forms.py:93
msgid "Admin password"
msgstr "Mot de passe administrateur"
#: forms.py:109
msgid "Password confirmation"
msgstr "Confirmation du mot de passe"
#: forms.py:107
msgid "Password"
msgstr "Mot de passe"
#: forms.py:108
#: forms.py:99
msgid "Send me the code by email"
msgstr "Envoyez moi le code par email"
#: forms.py:112
#: forms.py:103
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"
msgstr "Date"
#: forms.py:117
#: forms.py:116
msgid "What?"
msgstr "Quoi ?"
#: forms.py:118
#: forms.py:117
msgid "Payer"
msgstr "Payeur"
#: forms.py:119
#: forms.py:118
msgid "Amount paid"
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?"
msgstr "Pour qui ?"
#: forms.py:122
#: forms.py:121
msgid "Submit"
msgstr "Valider"
#: forms.py:123
#: forms.py:122
msgid "Submit and add a new one"
msgstr "Valider et ajouter une autre facture"
#: forms.py:149
#: forms.py:146
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"
msgstr "Nom"
#: forms.py:155
#: forms.py:152
msgid "Weight"
msgstr "Parts"
#: forms.py:155 templates/forms.html:95
#: forms.py:153 templates/forms.html:123
msgid "Add"
msgstr "Ajouter"
#: forms.py:163
#: forms.py:162
msgid "User name incorrect"
msgstr "Nom d'utilisateur incorrect"
msgstr "Nom dutilisateur incorrect"
#: forms.py:167
msgid "This project already have this member"
msgstr "Ce membre existe déjà pour ce projet"
#: forms.py:178
#: forms.py:183
msgid "People to notify"
msgstr "Personnes à prévenir"
#: forms.py:179
#: forms.py:184
msgid "Send invites"
msgstr "Envoyer les invitations"
#: forms.py:185
#: forms.py:190
#, python-format
msgid "The email %(email)s is not valid"
msgstr "L'email %(email)s est invalide"
msgstr "Lemail %(email)s est invalide"
#: forms.py:191
msgid "Start date"
msgstr "Date de départ"
#: forms.py:192
msgid "End date"
msgstr "Date de fin"
#: forms.py:202
#: forms.py:196
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"
msgstr "factures"
#: forms.py:205
#: forms.py:199
msgid "transactions"
msgstr "remboursements"
#: forms.py:206
#: forms.py:201
msgid "Export file format"
msgstr "Format du fichier d'export"
msgstr "Format du fichier dexport"
#: web.py:95
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
#: web.py:129
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
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
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."
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"
msgstr "Projet supprimé"
#: web.py:254
#: web.py:401
#, python-format
msgid "You have been invited to share your expenses for %(project)s"
msgstr "Vous avez été invité à partager vos dépenses pour %(project)s"
#: web.py:259
#, 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
#: web.py:408
msgid "Your invitations have been sent"
msgstr "Vos invitations ont bien été envoyées"
#: web.py:290
#: web.py:439
#, python-format
msgid "%(member)s had been added"
msgstr "%(member)s a bien été ajouté"
#: web.py:303
#: web.py:452
#, python-format
msgid "%(name)s is part of this project again"
msgstr "%(name)s a rejoint le projet"
#: web.py:312
#: web.py:461
#, python-format
msgid "User '%(name)s' has been deactivated"
msgstr "Le membre '%(name)s' a été désactivé"
msgid ""
"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
msgid "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 d'apparaître jusqu'à ce que sa balance devienne égale à zéro."
msgid "User '%(name)s' has been removed"
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"
msgstr "La facture a bien été ajoutée"
#: web.py:351
#: web.py:520
msgid "The bill has been deleted"
msgstr "La facture a été supprimée"
#: web.py:369
#: web.py:538
msgid "The bill has been modified"
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"
msgstr "Retourner à la liste"
#: 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 d'acceder n'existe pas. Souhaitez vous"
#: templates/admin.html:10
msgid "Administration tasks are currently disabled."
msgstr "Les tâches dadministration sont actuellement désactivées."
#: 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"
msgstr "le créer"
#: templates/authenticate.html:7
#: templates/authenticate.html:8
msgid "?"
msgstr " ?"
#: templates/authenticate.html:7
msgid "Administration tasks are currently disabled."
msgstr "Les tâches d'administration sont actuellement désactivées."
msgstr " ?"
#: templates/create_project.html:4
msgid "Create a new project"
@ -314,272 +310,316 @@ msgstr "Facture la plus récente"
msgid "Oldest bill"
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
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
msgid "you sure?"
msgstr "c'est sûr ?"
msgstr "cest sûr ?"
#: templates/edit_project.html:11
msgid "Edit this project"
msgstr "Éditer ce projet"
#: templates/forms.html:23
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
#: templates/edit_project.html:15
msgid "Download this project's data"
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"
msgstr "Télécharger"
#: templates/home.html:8
#: templates/home.html:7
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
msgid "Try out the demo"
msgstr "Essayez la démo"
#: templates/home.html:12
#: templates/home.html:13
msgid "You're sharing a house?"
msgstr "Vous êtes en colocation ?"
#: templates/home.html:12
#: templates/home.html:13
msgid "Going on holidays with friends?"
msgstr "Partez en vacances avec des amis ?"
#: templates/home.html:12
#: templates/home.html:13
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!"
msgstr "On peut vous aider !"
#: templates/home.html:24
#: templates/home.html:21
msgid "Log to an existing project"
msgstr "Se connecter à un projet existant"
#: templates/home.html:28
#: templates/home.html:25
msgid "log in"
msgstr "se connecter"
#: templates/home.html:29
#: templates/home.html:26
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"
msgstr "ou créez en un nouveau"
#: templates/home.html:40
#: templates/home.html:38
msgid "let's get started"
msgstr "c'est parti !"
msgstr "cest parti !"
#: templates/home.html:51
msgid ""
"This access code will be sent to your friends. It is stored as-is by the "
"server, so don\\'t reuse a personal password!"
msgstr ""
"Ce code d\\'accès va être envoyé à vos amis et stocké en clair sur le "
"serveur.N\\'utilisez pas un mot de passe personnel !"
"Ce code daccès va être envoyé à vos amis et stocké en clair sur le serveur. "
"Nutilisez pas un mot de passe personnel !"
#: templates/layout.html:5
msgid "Account manager"
msgstr "Gestion de comptes"
#: templates/layout.html:45 templates/settle_bills.html:4
#: templates/layout.html:39
msgid "Bills"
msgstr "Factures"
#: templates/layout.html:46 templates/settle_bills.html:5
#: templates/layout.html:40
msgid "Settle"
msgstr "Remboursements"
#: templates/layout.html:50
#: templates/layout.html:41
msgid "Statistics"
msgstr "Statistiques"
#: templates/layout.html:53
#: templates/layout.html:48
msgid "options"
msgstr "options"
#: templates/layout.html:55
#: templates/layout.html:50
msgid "Project settings"
msgstr "Options du projet"
#: templates/layout.html:59
#: templates/layout.html:54
msgid "switch to"
msgstr "aller à"
#: templates/layout.html:62
#: templates/layout.html:57
msgid "Start a new project"
msgstr "Nouveau projet"
#: templates/layout.html:64
#: templates/layout.html:59
msgid "Logout"
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"
msgstr "Ceci est un logiciel libre"
#: templates/layout.html:92
#: templates/layout.html:89
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"
msgstr "ré-activer"
#: templates/list_bills.html:88
msgid "Invite"
msgstr "Invitez"
#: templates/list_bills.html:88
#: templates/list_bills.html:82
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"
msgstr "Nouvelle facture"
#: templates/list_bills.html:103
#: templates/list_bills.html:101
msgid "When?"
msgstr "Quand ?"
#: templates/list_bills.html:103
#: templates/list_bills.html:101
msgid "Who paid?"
msgstr "Qui a payé ?"
#: templates/list_bills.html:103
#: templates/list_bills.html:101
msgid "For what?"
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?"
msgstr "Combien ?"
#: templates/list_bills.html:103
msgid "Actions"
msgstr "Actions"
#: templates/list_bills.html:111
#: templates/list_bills.html:109
msgid "each"
msgstr "chacun"
#: templates/list_bills.html:113
msgid "edit"
msgstr "éditer"
#: templates/list_bills.html:122
#: templates/list_bills.html:120
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"
msgstr "ajouter une facture"
#: templates/password_reminder.html:4
msgid "Password reminder"
msgstr "Rappel du code d'accès"
msgstr "Rappel du code daccès"
#: templates/recent_projects.html:2
msgid "Your projects"
msgstr "Vos projets"
#: templates/reset_password.html:2
#: templates/reset_password.html:7
msgid "Reset your password"
msgstr "Changez votre mot de passe"
#: templates/send_invites.html:11
#: templates/send_invites.html:4
msgid "Invite people to join this project"
msgstr "Invitez des personnes à rejoindre ce projet"
#: templates/send_invites.html:12
#: templates/send_invites.html:5
msgid ""
"Specify a (comma separated) list of email adresses you want to notify "
"about the\n"
"creation of this budget management project and we will send them an email"
" for you."
msgstr ""
"Entrez les addresses des personnes que vous souhaitez inviter, séparées "
"par des virgules. On s'occupe de leur envoyer un email."
"Entrez les adresses des personnes que vous souhaitez inviter,\n"
"séparées par des virgules, on soccupe de leur envoyer un email."
#: templates/send_invites.html:14
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 "Si vous préférez vous pouvez partager l'identifiant du projet et son mot "
"de passe par un autre moyen de communication. Ou directement partager le lien "
"suivant :"
#: templates/send_invites.html:7
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 ""
"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?"
msgstr "Qui doit payer ?"
#: templates/settle_bills.html:31
#: templates/settle_bills.html:22
msgid "To whom?"
msgstr "Pour qui ?"
#: templates/statistics.html:22
#: templates/statistics.html:21
msgid "Who?"
msgstr "Qui ?"
#: templates/statistics.html:22
#: templates/statistics.html:21
msgid "Paid"
msgstr "A payé"
#: templates/statistics.html:22
#: templates/statistics.html:21
msgid "Spent"
msgstr "A dépensé"
#: templates/statistics.html:22
#: templates/statistics.html:21
msgid "Balance"
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
from io import BytesIO, StringIO
from jinja2 import filters
from json import dumps
import jinja2
from json import dumps, JSONEncoder
from flask import redirect
from werkzeug.routing import HTTPException, RoutingException
import six
@ -83,7 +83,7 @@ def minimal_round(*args, **kw):
from http://stackoverflow.com/questions/28458524/
"""
# 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
# return depending on it
ires = int(res)
@ -170,3 +170,43 @@ class LoginThrottler():
def reset(self, ip):
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' "
"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)
msg = Message(message_title,
@ -273,7 +273,7 @@ def remind_password():
project = Project.query.get(form.id.data)
# 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(
"password recovery",
body=render_template(password_reminder, project=project),
@ -395,7 +395,7 @@ def invite():
if form.validate():
# send the email
message_body = render_template("invitation_mail.%s" %
message_body = render_template("invitation_mail.%s.j2" %
get_locale().language)
message_title = _("You have been invited to share your "
@ -566,21 +566,9 @@ def settle_bill():
@main.route("/<project_id>/statistics")
def statistics():
"""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(
"statistics.html",
members=members,
balance=balance,
paid=paid,
spent=spent,
members_stats=g.project.members_stats,
current_view='statistics',
)

View file

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

View file

@ -2,16 +2,6 @@
import codecs
import os
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__))
@ -23,6 +13,13 @@ def read_file(filename):
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')
CHANGELOG = read_file('CHANGELOG.rst')
@ -37,7 +34,7 @@ ENTRY_POINTS = {
setup(name='ihatemoney',
version='2.1.dev0',
version='2.1.1.dev0',
description='A simple shared budget manager web application.',
long_description="{}\n\n{}".format(README.encode('utf-8'), CHANGELOG.encode('utf-8')),
license='Custom BSD Beerware',
@ -59,5 +56,5 @@ setup(name='ihatemoney',
packages=find_packages(),
include_package_data=True,
zip_safe=False,
install_requires=install_requires,
install_requires=parse_requirements('requirements.txt'),
entry_points=ENTRY_POINTS)

13
tox.ini
View file

@ -6,22 +6,31 @@ skip_missing_interpreters = True
commands =
python --version
py.test ihatemoney/tests/tests.py
py.test --pyargs ihatemoney.tests.tests
deps =
-rdev-requirements.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]
commands = sphinx-build -a -n -b html -d docs/_build/doctrees docs docs/_build/html
deps =
-rdocs/requirements.txt
changedir = {toxinidir}
[testenv:lint]
commands = flake8 ihatemoney
deps =
-rdev-requirements.txt
changedir = {toxinidir}
[flake8]
exclude = migrations