From df215cbb796e5320404ea904b7e2feca922d5cbc Mon Sep 17 00:00:00 2001 From: Jocelyn Delande Date: Thu, 30 Apr 2015 16:57:37 +0200 Subject: [PATCH 01/20] nicely deprecates DEFAULT_MAIL_SENDER as it changed upstream for flask-mail>=0.8 --- budget/default_settings.py | 2 +- budget/requirements.txt | 2 +- budget/run.py | 14 ++++++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/budget/default_settings.py b/budget/default_settings.py index 111abf2..394ab00 100644 --- a/budget/default_settings.py +++ b/budget/default_settings.py @@ -3,7 +3,7 @@ SQLALCHEMY_DATABASE_URI = 'sqlite:///budget.db' SQLACHEMY_ECHO = DEBUG SECRET_KEY = "tralala" -DEFAULT_MAIL_SENDER = ("Budget manager", "budget@notmyidea.org") +MAIL_DEFAULT_SENDER = ("Budget manager", "budget@notmyidea.org") try: from settings import * diff --git a/budget/requirements.txt b/budget/requirements.txt index 87d8966..5d7433a 100644 --- a/budget/requirements.txt +++ b/budget/requirements.txt @@ -1,7 +1,7 @@ flask>=0.9 flask-wtf==0.8 flask-sqlalchemy -flask-mail +flask-mail>=0.8 flask-babel flask-rest jinja2==2.6 diff --git a/budget/run.py b/budget/run.py index 845192d..1a65022 100644 --- a/budget/run.py +++ b/budget/run.py @@ -1,3 +1,5 @@ +import warnings + from flask import Flask, g, request, session from flask.ext.babel import Babel from raven.contrib.flask import Sentry @@ -9,6 +11,18 @@ from api import api app = Flask(__name__) app.config.from_object("default_settings") +# Deprecations +if 'DEFAULT_MAIL_SENDER' in app.config: + # Since flask-mail 0.8 + warnings.warn( + "DEFAULT_MAIL_SENDER is deprecated in favor of MAIL_DEFAULT_SENDER" + +" and will be removed in further version", + UserWarning + ) + if not 'MAIL_DEFAULT_SENDER' in app.config: + app.config['MAIL_DEFAULT_SENDER'] = DEFAULT_MAIL_SENDER + + app.register_blueprint(main) app.register_blueprint(api) From b9458db08ec12ce3645fe21fde71e82bf8fde3d6 Mon Sep 17 00:00:00 2001 From: Alexandre Avenel Date: Sun, 5 Jul 2015 22:16:38 +0200 Subject: [PATCH 02/20] Bugfix rounding settle algorithm In some cases, settle algorithm failed to deliver optimal solution due to a rounding bug. --- budget/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/budget/models.py b/budget/models.py index 27bd80b..727200f 100644 --- a/budget/models.py +++ b/budget/models.py @@ -62,7 +62,7 @@ class Project(db.Model): debts.append({"person": person, "balance": -balance[person.id]}) # Try and find exact matches for credit in credits: - match = self.exactmatch(credit["balance"], debts) + match = self.exactmatch(round(credit["balance"], 2), debts) if match: for m in match: transactions.append({"ower": m["person"], "receiver": credit["person"], "amount": m["balance"]}) From d6c514e7d176fcdf6be3e3ca6eb05ea2ea725d71 Mon Sep 17 00:00:00 2001 From: Jocelyn Delande Date: Wed, 19 Aug 2015 22:40:07 +0200 Subject: [PATCH 03/20] Adds a way to (re)configure the running app, reloading settings. Currently, there is no way to reset settings after modifying them, which is anoying for tests. --- budget/run.py | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/budget/run.py b/budget/run.py index 1a65022..2e18599 100644 --- a/budget/run.py +++ b/budget/run.py @@ -9,18 +9,25 @@ from api import api app = Flask(__name__) -app.config.from_object("default_settings") -# Deprecations -if 'DEFAULT_MAIL_SENDER' in app.config: - # Since flask-mail 0.8 - warnings.warn( - "DEFAULT_MAIL_SENDER is deprecated in favor of MAIL_DEFAULT_SENDER" - +" and will be removed in further version", - UserWarning - ) - if not 'MAIL_DEFAULT_SENDER' in app.config: - app.config['MAIL_DEFAULT_SENDER'] = DEFAULT_MAIL_SENDER + +def configure(): + """ A way to (re)configure the app, specially reset the settings + """ + app.config.from_object("default_settings") + + # Deprecations + if 'DEFAULT_MAIL_SENDER' in app.config: + # Since flask-mail 0.8 + warnings.warn( + "DEFAULT_MAIL_SENDER is deprecated in favor of MAIL_DEFAULT_SENDER" + +" and will be removed in further version", + UserWarning + ) + if not 'MAIL_DEFAULT_SENDER' in app.config: + app.config['MAIL_DEFAULT_SENDER'] = DEFAULT_MAIL_SENDER + +configure() app.register_blueprint(main) From 15091e28c070dc0d248b310fe1aa9638de92424a Mon Sep 17 00:00:00 2001 From: Jocelyn Delande Date: Wed, 19 Aug 2015 22:47:52 +0200 Subject: [PATCH 04/20] Made an URL prefix configurable in settings, includes tests. --- README.rst | 7 ++++++- budget/default_settings.py | 1 + budget/run.py | 3 ++- budget/tests.py | 15 +++++++++++++++ budget/utils.py | 31 +++++++++++++++++++++++++++++++ 5 files changed, 55 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 80e2fb0..6923a83 100644 --- a/README.rst +++ b/README.rst @@ -48,7 +48,12 @@ To deploy it, I'm using gunicorn and supervisord:: Don't forget to set the right permission for your files ! Also, create a `settings.py` file with the appropriate values if you need to -use a different database for instance. +use a different database for instance. You can also set `APPLICATION_ROOT` if +you want to prefix your URLs to serve ihatemonney in the *folder* of a domain, +e.g: + + APPLICATION_ROOT='/budget' + How about the REST API? ======================= diff --git a/budget/default_settings.py b/budget/default_settings.py index 394ab00..c80c783 100644 --- a/budget/default_settings.py +++ b/budget/default_settings.py @@ -4,6 +4,7 @@ SQLACHEMY_ECHO = DEBUG SECRET_KEY = "tralala" MAIL_DEFAULT_SENDER = ("Budget manager", "budget@notmyidea.org") +APPLICATION_ROOT = '/' try: from settings import * diff --git a/budget/run.py b/budget/run.py index 2e18599..bdb8f46 100644 --- a/budget/run.py +++ b/budget/run.py @@ -6,7 +6,7 @@ from raven.contrib.flask import Sentry from web import main, db, mail from api import api - +from utils import PrefixedWSGI app = Flask(__name__) @@ -15,6 +15,7 @@ def configure(): """ A way to (re)configure the app, specially reset the settings """ app.config.from_object("default_settings") + app.wsgi_app = PrefixedWSGI(app) # Deprecations if 'DEFAULT_MAIL_SENDER' in app.config: diff --git a/budget/tests.py b/budget/tests.py index 0f7c2a2..b151e7f 100644 --- a/budget/tests.py +++ b/budget/tests.py @@ -821,5 +821,20 @@ class APITestCase(TestCase): self.assertStatus(404, req) +class ServerTestCase(APITestCase): + def setUp(self): + run.configure() + super(ServerTestCase, self).setUp() + + def test_unprefixed(self): + req = self.app.get("/foo/") + self.assertStatus(303, req) + + def test_prefixed(self): + run.app.config['APPLICATION_ROOT'] = '/foo' + req = self.app.get("/foo/") + self.assertStatus(200, req) + + if __name__ == "__main__": unittest.main() diff --git a/budget/utils.py b/budget/utils.py index 60337fb..7717aaa 100644 --- a/budget/utils.py +++ b/budget/utils.py @@ -32,3 +32,34 @@ class Redirect303(HTTPException, RoutingException): def get_response(self, environ): return redirect(self.new_url, 303) + + +class PrefixedWSGI(object): + ''' + Wrap the application in this middleware and configure the + front-end server to add these headers, to let you quietly bind + this to a URL other than / and to an HTTP scheme that is + different than what is used locally. + + It relies on "APPLICATION_ROOT" app setting. + + Inspired from http://flask.pocoo.org/snippets/35/ + + :param app: the WSGI application + ''' + def __init__(self, app): + self.app = app + self.wsgi_app = app.wsgi_app + + def __call__(self, environ, start_response): + script_name = self.app.config['APPLICATION_ROOT'] + if script_name: + environ['SCRIPT_NAME'] = script_name + path_info = environ['PATH_INFO'] + if path_info.startswith(script_name): + environ['PATH_INFO'] = path_info[len(script_name):] + + scheme = environ.get('HTTP_X_SCHEME', '') + if scheme: + environ['wsgi.url_scheme'] = scheme + return self.wsgi_app(environ, start_response) From d9313ba40e42f0910323fa522b104257a81a1d74 Mon Sep 17 00:00:00 2001 From: Jocelyn Delande Date: Thu, 20 Aug 2015 12:02:04 +0200 Subject: [PATCH 05/20] Remove trailing whitespaces --- AUTHORS | 2 +- LICENSE | 2 +- README.rst | 4 +- budget/static/css/main.css | 2 +- budget/templates/authenticate.html | 2 +- budget/templates/forms.html | 12 ++-- budget/templates/invitation_mail.en | 2 +- budget/templates/list_bills.html | 6 +- budget/templates/password_reminder.en | 2 +- budget/templates/reminder_mail.en | 2 +- budget/templates/send_invites.html | 2 +- budget/tests.py | 2 +- conf/nginx.conf | 4 +- docs/_themes/pelican/static/pelican.css_t | 76 +++++++++++------------ docs/api.rst | 30 ++++----- 15 files changed, 75 insertions(+), 75 deletions(-) diff --git a/AUTHORS b/AUTHORS index f104070..89a2af3 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,2 +1,2 @@ -The project has been started by Alexis Métaireau and Frédéric Sureau. Friends are +The project has been started by Alexis Métaireau and Frédéric Sureau. Friends are helping since that in the persons of Arnaud Bos and Quentin Roy. diff --git a/LICENSE b/LICENSE index a668284..ecc7977 100644 --- a/LICENSE +++ b/LICENSE @@ -19,7 +19,7 @@ that the following conditions are met: promote products derived from this software without specific prior written permission. -* If you meet the authors of this software in person and you want to +* If you meet the authors of this software in person and you want to pay them a beer, you're encouraged to do so. Please, do. If you have homebrewed beer, this works as well (may even be better). diff --git a/README.rst b/README.rst index 80e2fb0..8bcc648 100644 --- a/README.rst +++ b/README.rst @@ -27,7 +27,7 @@ the following content:: DEBUG = True SQLACHEMY_ECHO = DEBUG -You can also set the `TESTING` flag to `True` so no mails are sent +You can also set the `TESTING` flag to `True` so no mails are sent (and no exception is raised) while you're on development mode. Deploy it @@ -66,7 +66,7 @@ As a developer -------------- The best way to contribute code is to write it and to make a pull request on -github. Please, think about updating and running the tests before asking for +github. Please, think about updating and running the tests before asking for a pull request as it will help us to maintain the code clean and running. To do so:: diff --git a/budget/static/css/main.css b/budget/static/css/main.css index 3274241..97a3e19 100644 --- a/budget/static/css/main.css +++ b/budget/static/css/main.css @@ -180,7 +180,7 @@ tr.payer_line .balance-name{ background: url('../images/reactivate.png') left no-repeat; color: white; } - + #bill-form > fieldset { margin-top: 10px; } diff --git a/budget/templates/authenticate.html b/budget/templates/authenticate.html index dc62a70..98914d0 100644 --- a/budget/templates/authenticate.html +++ b/budget/templates/authenticate.html @@ -3,7 +3,7 @@

Authentication

{% if create_project %} -

{{ _("The project you are trying to access do not exist, do you want +

{{ _("The project you are trying to access do not exist, do you want to") }} {{ _("create it") }}{{ _("?") }}

{% endif %} diff --git a/budget/templates/forms.html b/budget/templates/forms.html index 2904e0d..ec73515 100644 --- a/budget/templates/forms.html +++ b/budget/templates/forms.html @@ -76,12 +76,12 @@
{% if title %}{% if edit %}{{ _("Edit this bill") }} {% else %}{{ _("Add a bill") }} {% endif %}{% endif %} {% include "display_errors.html" %} - {{ form.hidden_tag() }} - {{ input(form.date, class="datepicker") }} - {{ input(form.what) }} - {{ input(form.payer) }} - {{ input(form.amount) }} - {{ input(form.payed_for) }} + {{ form.hidden_tag() }} + {{ input(form.date, class="datepicker") }} + {{ input(form.what) }} + {{ input(form.payer) }} + {{ input(form.amount) }} + {{ input(form.payed_for) }}
{{ form.submit(class="btn btn-primary") }} diff --git a/budget/templates/invitation_mail.en b/budget/templates/invitation_mail.en index 4f5bbf0..e4ff0e9 100644 --- a/budget/templates/invitation_mail.en +++ b/budget/templates/invitation_mail.en @@ -1,4 +1,4 @@ -Hi, +Hi, Someone using the email address {{ g.project.contact_email }} invited you to share your expenses for "{{ g.project.name }}". diff --git a/budget/templates/list_bills.html b/budget/templates/list_bills.html index f9d372a..899fdeb 100644 --- a/budget/templates/list_bills.html +++ b/budget/templates/list_bills.html @@ -42,13 +42,13 @@ }); $("#bal-member-"+payer_id).addClass("payer_line"); }; - + var unhighlight_owers = function(){ $('[id^="bal-member-"]').removeClass("ower_line payer_line"); }; - + $('#bill_table tbody tr').hover(highlight_owers, unhighlight_owers); - + {% endblock %} {% block sidebar %} diff --git a/budget/templates/password_reminder.en b/budget/templates/password_reminder.en index fc24a6f..31210aa 100644 --- a/budget/templates/password_reminder.en +++ b/budget/templates/password_reminder.en @@ -1,4 +1,4 @@ -Hi, +Hi, You requested to be reminded about your password for "{{ project.name }}". diff --git a/budget/templates/reminder_mail.en b/budget/templates/reminder_mail.en index b2e3a65..fe57be2 100644 --- a/budget/templates/reminder_mail.en +++ b/budget/templates/reminder_mail.en @@ -1,4 +1,4 @@ -Hi, +Hi, You have just (or someone else using your email address) created the project "{{ g.project.name }}" to share your expenses. diff --git a/budget/templates/send_invites.html b/budget/templates/send_invites.html index 9ddded1..7b3bdc5 100644 --- a/budget/templates/send_invites.html +++ b/budget/templates/send_invites.html @@ -9,7 +9,7 @@ {% endblock %} {% block content %}

{{ _("Invite people to join this project") }}

-

{{ _("Specify a (comma separated) list of email adresses you want to notify about the +

{{ _("Specify a (comma separated) list of email adresses you want to notify about the creation of this budget management project and we will send them an email for you.") }}

{{ _("If you prefer, you can") }} {{ _("skip this step") }} {{ _("and notify them yourself") }}

diff --git a/budget/tests.py b/budget/tests.py index 0f7c2a2..1fb9fe4 100644 --- a/budget/tests.py +++ b/budget/tests.py @@ -532,7 +532,7 @@ class BudgetTestCase(TestCase): for m, a in members.items(): self.assertEqual(a, balance[m.id]) return - + class APITestCase(TestCase): diff --git a/conf/nginx.conf b/conf/nginx.conf index c4a9ad5..9ae2ca0 100644 --- a/conf/nginx.conf +++ b/conf/nginx.conf @@ -2,9 +2,9 @@ server { server_name yourur; keepalive_timeout 5; - location /static/ { + location /static/ { alias /path/to/app/budget/static/; - } + } location / { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $http_host; diff --git a/docs/_themes/pelican/static/pelican.css_t b/docs/_themes/pelican/static/pelican.css_t index 3cb2a3c..8d8a9b2 100644 --- a/docs/_themes/pelican/static/pelican.css_t +++ b/docs/_themes/pelican/static/pelican.css_t @@ -6,11 +6,11 @@ * * :copyright: Copyright 2011 by Alexis Metaireau. */ - + @import url("basic.css"); - + /* -- page layout ----------------------------------------------------------- */ - + body { font-family: Arial, sans-serif; font-size: 100%; @@ -32,17 +32,17 @@ div.bodywrapper { hr { border: 1px solid #B1B4B6; } - + div.document { } - + div.body { background-color: #ffffff; color: #3E4349; padding: 0 30px 30px 30px; font-size: 0.9em; } - + div.footer { color: #555; width: 100%; @@ -50,12 +50,12 @@ div.footer { text-align: center; font-size: 75%; } - + div.footer a { color: #444; text-decoration: underline; } - + div.related { background-color: #6BA81E; line-height: 32px; @@ -63,11 +63,11 @@ div.related { text-shadow: 0px 1px 0 #444; font-size: 0.9em; } - + div.related a { color: #E2F3CC; } - + div.sphinxsidebar { font-size: 0.75em; line-height: 1.5em; @@ -76,7 +76,7 @@ div.sphinxsidebar { div.sphinxsidebarwrapper{ padding: 20px 0; } - + div.sphinxsidebar h3, div.sphinxsidebar h4 { font-family: Arial, sans-serif; @@ -92,30 +92,30 @@ div.sphinxsidebar h4 { div.sphinxsidebar h4{ font-size: 1.1em; } - + div.sphinxsidebar h3 a { color: #444; } - - + + div.sphinxsidebar p { color: #888; padding: 5px 20px; } - + div.sphinxsidebar p.topless { } - + div.sphinxsidebar ul { margin: 10px 20px; padding: 0; color: #000; } - + div.sphinxsidebar a { color: #444; } - + div.sphinxsidebar input { border: 1px solid #ccc; font-family: sans-serif; @@ -125,19 +125,19 @@ div.sphinxsidebar input { div.sphinxsidebar input[type=text]{ margin-left: 20px; } - + /* -- body styles ----------------------------------------------------------- */ - + a { color: #005B81; text-decoration: none; } - + a:hover { color: #E32E00; text-decoration: underline; } - + div.body h1, div.body h2, div.body h3, @@ -151,7 +151,7 @@ div.body h6 { padding: 5px 0 5px 10px; text-shadow: 0px 1px 0 white } - + {% if theme_index_logo %} div.indexwrapper h1 { text-indent: -999999px; @@ -159,10 +159,10 @@ div.indexwrapper h1 { height: {{ theme_index_logo_height }}; } {% endif %} -div.body h1 { - border-top: 20px solid white; - margin-top: 0; - font-size: 250%; +div.body h1 { + border-top: 20px solid white; + margin-top: 0; + font-size: 250%; text-align: center; } @@ -171,23 +171,23 @@ div.body h3 { font-size: 120%; background-color: #D8DEE3; } div.body h4 { font-size: 110%; background-color: #D8DEE3; } div.body h5 { font-size: 100%; background-color: #D8DEE3; } div.body h6 { font-size: 100%; background-color: #D8DEE3; } - + a.headerlink { color: #c60f0f; font-size: 0.8em; padding: 0 4px 0 4px; text-decoration: none; } - + a.headerlink:hover { background-color: #c60f0f; color: white; } - + div.body p, div.body dd, div.body li { line-height: 1.5em; } - + div.admonition p.admonition-title + p { display: inline; } @@ -200,29 +200,29 @@ div.note { background-color: #eee; border: 1px solid #ccc; } - + div.seealso { background-color: #ffc; border: 1px solid #ff6; } - + div.topic { background-color: #eee; } - + div.warning { background-color: #ffe4e4; border: 1px solid #f66; } - + p.admonition-title { display: inline; } - + p.admonition-title:after { content: ":"; } - + pre { padding: 10px; background-color: #111; @@ -234,7 +234,7 @@ pre { -webkit-box-shadow: 1px 1px 1px #d8d8d8; -moz-box-shadow: 1px 1px 1px #d8d8d8; } - + tt { background-color: #ecf0f3; color: #222; diff --git a/docs/api.rst b/docs/api.rst index 34edbdb..ce5df71 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -64,18 +64,18 @@ Getting information about the project:: $ curl --basic -u demo:demo http://ihatemoney.notmyidea.org/api/projects/demo { - "name": "demonstration", - "contact_email": "demo@notmyidea.org", - "password": "demo", + "name": "demonstration", + "contact_email": "demo@notmyidea.org", + "password": "demo", "id": "demo", - "active_members": [{"activated": true, "id": 31, "name": "Arnaud"}, - {"activated": true, "id": 32, "name": "Alexis"}, - {"activated": true, "id": 33, "name": "Olivier"}, - {"activated": true, "id": 34, "name": "Fred"}], - "members": [{"activated": true, "id": 31, "name": "Arnaud"}, - {"activated": true, "id": 32, "name": "Alexis"}, - {"activated": true, "id": 33, "name": "Olivier"}, - {"activated": true, "id": 34, "name": "Fred"}], + "active_members": [{"activated": true, "id": 31, "name": "Arnaud"}, + {"activated": true, "id": 32, "name": "Alexis"}, + {"activated": true, "id": 33, "name": "Olivier"}, + {"activated": true, "id": 34, "name": "Fred"}], + "members": [{"activated": true, "id": 31, "name": "Arnaud"}, + {"activated": true, "id": 32, "name": "Alexis"}, + {"activated": true, "id": 33, "name": "Olivier"}, + {"activated": true, "id": 34, "name": "Fred"}], } @@ -101,15 +101,15 @@ Members You can get all the members with a `GET` on `/api/projects//members`:: $ curl --basic -u demo:demo http://ihatemoney.notmyidea.org/api/projects/demo/members\ - [{"activated": true, "id": 31, "name": "Arnaud"}, - {"activated": true, "id": 32, "name": "Alexis"}, + [{"activated": true, "id": 31, "name": "Arnaud"}, + {"activated": true, "id": 32, "name": "Alexis"}, {"activated": true, "id": 33, "name": "Olivier"}, {"activated": true, "id": 34, "name": "Fred"}] Add a member with a `POST` request on `/api/projects//members`:: $ curl --basic -u demo:demo -X POST\ - http://ihatemoney.notmyidea.org/api/projects/demo/members -d 'name=tatayoyo' + http://ihatemoney.notmyidea.org/api/projects/demo/members -d 'name=tatayoyo' 35 You can also `PUT` a new version of a member (changing its name):: @@ -122,7 +122,7 @@ You can also `PUT` a new version of a member (changing its name):: Delete a member with a `DELETE` request on `/api/projects//members/`:: $ curl --basic -u demo:demo -X DELETE\ - http://ihatemoney.notmyidea.org/api/projects/demo/members/35 + http://ihatemoney.notmyidea.org/api/projects/demo/members/35 "OK Bills From c6c40f02c5d1c4fa29f7445097a4a12abbced499 Mon Sep 17 00:00:00 2001 From: Mathieu Leplatre Date: Fri, 25 Sep 2015 12:31:52 +0200 Subject: [PATCH 06/20] Prevent comma to be included in URL --- budget/templates/invitation_mail.fr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/budget/templates/invitation_mail.fr b/budget/templates/invitation_mail.fr index a46f0a0..53698dd 100644 --- a/budget/templates/invitation_mail.fr +++ b/budget/templates/invitation_mail.fr @@ -4,6 +4,6 @@ Quelqu'un avec l'addresse email "{{ g.project.contact_email }}" vous à invité C'est aussi simple que de dire qui à payé pour quoi, pour qui, et combien celà à coûté, on s'occuppe du reste. -Vous pouvez accéder à la page ici: {{ config['SITE_URL'] }}{{ url_for(".list_bills") }}, le code est "{{ g.project.password }}". +Vous pouvez accéder à la page ici: {{ config['SITE_URL'] }}{{ url_for(".list_bills") }} et le code est "{{ g.project.password }}". Have fun, From eff0f7c2df4cdbde11f91ca7413af89c3a0606c9 Mon Sep 17 00:00:00 2001 From: Mathieu Leplatre Date: Fri, 25 Sep 2015 12:32:56 +0200 Subject: [PATCH 07/20] Prevent comma to be included in URL --- budget/templates/invitation_mail.en | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/budget/templates/invitation_mail.en b/budget/templates/invitation_mail.en index e4ff0e9..03f5141 100644 --- a/budget/templates/invitation_mail.en +++ b/budget/templates/invitation_mail.en @@ -4,7 +4,7 @@ Someone using the email address {{ g.project.contact_email }} invited you to sha 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. -You can access it here: {{ config['SITE_URL'] }}{{ url_for(".list_bills") }}, the private code is "{{ g.project.password }}". +You can access it here: {{ config['SITE_URL'] }}{{ url_for(".list_bills") }} and the private code is "{{ g.project.password }}". Enjoy, Some weird guys (with beards) From b685fa74d641ceeb077005698cc63108350f1d36 Mon Sep 17 00:00:00 2001 From: Jocelyn Delande Date: Tue, 18 Aug 2015 19:06:24 +0200 Subject: [PATCH 08/20] Do not load user-overriden settings in unit tests. Loading not versioned settings.py during tests make them less predictable. That's inspired from django behaviour with DJANGO_SETTING_MODULE environment variable. --- budget/default_settings.py | 6 ------ budget/merged_settings.py | 10 ++++++++++ budget/run.py | 4 +++- budget/tests.py | 3 +++ 4 files changed, 16 insertions(+), 7 deletions(-) create mode 100644 budget/merged_settings.py diff --git a/budget/default_settings.py b/budget/default_settings.py index c80c783..d5a9a9b 100644 --- a/budget/default_settings.py +++ b/budget/default_settings.py @@ -4,9 +4,3 @@ SQLACHEMY_ECHO = DEBUG SECRET_KEY = "tralala" MAIL_DEFAULT_SENDER = ("Budget manager", "budget@notmyidea.org") -APPLICATION_ROOT = '/' - -try: - from settings import * -except ImportError: - pass diff --git a/budget/merged_settings.py b/budget/merged_settings.py new file mode 100644 index 0000000..f6b1f81 --- /dev/null +++ b/budget/merged_settings.py @@ -0,0 +1,10 @@ +""" +Merges default settings with user-defined settings +""" + +from default_settings import * + +try: + from settings import * +except ImportError: + pass diff --git a/budget/run.py b/budget/run.py index bdb8f46..1e5fe59 100644 --- a/budget/run.py +++ b/budget/run.py @@ -1,3 +1,4 @@ +import os import warnings from flask import Flask, g, request, session @@ -14,7 +15,8 @@ app = Flask(__name__) def configure(): """ A way to (re)configure the app, specially reset the settings """ - app.config.from_object("default_settings") + config_obj = os.environ.get('FLASK_SETTINGS_MODULE', 'merged_settings') + app.config.from_object(config_obj) app.wsgi_app = PrefixedWSGI(app) # Deprecations diff --git a/budget/tests.py b/budget/tests.py index fd30c63..760ffc0 100644 --- a/budget/tests.py +++ b/budget/tests.py @@ -5,9 +5,12 @@ except ImportError: import unittest # NOQA import base64 +import os import json from collections import defaultdict +os.environ['FLASK_SETTINGS_MODULE'] = 'default_settings' + from flask import session import run From 74995f99595d30b6b3383d3761cbd730b3cb7798 Mon Sep 17 00:00:00 2001 From: Jocelyn Delande Date: Sat, 21 May 2016 23:47:12 +0200 Subject: [PATCH 09/20] Handle migrations through alembic/flask-Migrations Auto-initialization now applies migrations instead of using db. create_all() fix #83 --- budget/migrations/README | 1 + budget/migrations/alembic.ini | 45 +++++++++++ budget/migrations/env.py | 85 +++++++++++++++++++++ budget/migrations/script.py.mako | 22 ++++++ budget/migrations/versions/b9a10d5d63ce_.py | 68 +++++++++++++++++ budget/requirements.txt | 1 + budget/run.py | 9 ++- 7 files changed, 230 insertions(+), 1 deletion(-) create mode 100755 budget/migrations/README create mode 100644 budget/migrations/alembic.ini create mode 100755 budget/migrations/env.py create mode 100755 budget/migrations/script.py.mako create mode 100644 budget/migrations/versions/b9a10d5d63ce_.py diff --git a/budget/migrations/README b/budget/migrations/README new file mode 100755 index 0000000..98e4f9c --- /dev/null +++ b/budget/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/budget/migrations/alembic.ini b/budget/migrations/alembic.ini new file mode 100644 index 0000000..f8ed480 --- /dev/null +++ b/budget/migrations/alembic.ini @@ -0,0 +1,45 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/budget/migrations/env.py b/budget/migrations/env.py new file mode 100755 index 0000000..cef89d2 --- /dev/null +++ b/budget/migrations/env.py @@ -0,0 +1,85 @@ +from __future__ import with_statement +from alembic import context +from sqlalchemy import engine_from_config, pool +from logging.config import fileConfig +import logging + +# This is the Alembic Config object, which provides access to the values within +# the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. This line sets up loggers +# basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + +# Add your model's MetaData object here for 'autogenerate' support from myapp +# import mymodel target_metadata = mymodel.Base.metadata. +from flask import current_app +config.set_main_option('sqlalchemy.url', + current_app.config.get('SQLALCHEMY_DATABASE_URI')) +target_metadata = current_app.extensions['migrate'].db.metadata + +# Other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # This callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema. + # reference: http://alembic.readthedocs.org/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + engine = engine_from_config(config.get_section(config.config_ini_section), + prefix='sqlalchemy.', + poolclass=pool.NullPool) + + connection = engine.connect() + context.configure(connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + **current_app.extensions['migrate'].configure_args) + + try: + with context.begin_transaction(): + context.run_migrations() + finally: + connection.close() + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/budget/migrations/script.py.mako b/budget/migrations/script.py.mako new file mode 100755 index 0000000..9570201 --- /dev/null +++ b/budget/migrations/script.py.mako @@ -0,0 +1,22 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision} +Create Date: ${create_date} + +""" + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/budget/migrations/versions/b9a10d5d63ce_.py b/budget/migrations/versions/b9a10d5d63ce_.py new file mode 100644 index 0000000..92bb446 --- /dev/null +++ b/budget/migrations/versions/b9a10d5d63ce_.py @@ -0,0 +1,68 @@ +"""Initial migration + +Revision ID: b9a10d5d63ce +Revises: None +Create Date: 2016-05-21 23:21:21.605076 + +""" + +# revision identifiers, used by Alembic. +revision = 'b9a10d5d63ce' +down_revision = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.create_table('project', + sa.Column('id', sa.String(length=64), nullable=False), + sa.Column('name', sa.UnicodeText(), nullable=True), + sa.Column('password', sa.String(length=128), nullable=True), + sa.Column('contact_email', sa.String(length=128), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('archive', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('project_id', sa.String(length=64), nullable=True), + sa.Column('name', sa.UnicodeText(), nullable=True), + sa.ForeignKeyConstraint(['project_id'], ['project.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('person', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('project_id', sa.String(length=64), nullable=True), + sa.Column('name', sa.UnicodeText(), nullable=True), + sa.Column('activated', sa.Boolean(), nullable=True), + sa.ForeignKeyConstraint(['project_id'], ['project.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('bill', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('payer_id', sa.Integer(), nullable=True), + sa.Column('amount', sa.Float(), nullable=True), + sa.Column('date', sa.Date(), nullable=True), + sa.Column('what', sa.UnicodeText(), nullable=True), + sa.Column('archive', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['archive'], ['archive.id'], ), + sa.ForeignKeyConstraint(['payer_id'], ['person.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('billowers', + sa.Column('bill_id', sa.Integer(), nullable=True), + sa.Column('person_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['bill_id'], ['bill.id'], ), + sa.ForeignKeyConstraint(['person_id'], ['person.id'], ) + ) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_table('billowers') + op.drop_table('bill') + op.drop_table('person') + op.drop_table('archive') + op.drop_table('project') + ### end Alembic commands ### diff --git a/budget/requirements.txt b/budget/requirements.txt index 5d7433a..d49767e 100644 --- a/budget/requirements.txt +++ b/budget/requirements.txt @@ -2,6 +2,7 @@ flask>=0.9 flask-wtf==0.8 flask-sqlalchemy flask-mail>=0.8 +Flask-Migrate==1.8.0 flask-babel flask-rest jinja2==2.6 diff --git a/budget/run.py b/budget/run.py index 1e5fe59..51670f2 100644 --- a/budget/run.py +++ b/budget/run.py @@ -3,6 +3,7 @@ import warnings from flask import Flask, g, request, session from flask.ext.babel import Babel +from flask.ext.migrate import Migrate, upgrade from raven.contrib.flask import Sentry from web import main, db, mail @@ -39,7 +40,13 @@ app.register_blueprint(api) # db db.init_app(app) db.app = app -db.create_all() + +# db migrations +migrate = Migrate(app, db) + +# auto-execute migrations on runtime +with app.app_context(): + upgrade() # mail mail.init_app(app) From 465deabd029e5c0701f371cc317cd2e78567b3ac Mon Sep 17 00:00:00 2001 From: Jocelyn Delande Date: Sun, 22 May 2016 00:04:50 +0200 Subject: [PATCH 10/20] Add a manage.py CLI (flask_script) As it's the Flask-Migrate way to expose its commands (./manage.py db command). In our case, it's specially useful for creating new migrations. --- budget/manage.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100755 budget/manage.py diff --git a/budget/manage.py b/budget/manage.py new file mode 100755 index 0000000..e0b25a7 --- /dev/null +++ b/budget/manage.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python + +from flask.ext.script import Manager +from flask.ext.migrate import Migrate, MigrateCommand + +from run import app +from models import db + +migrate = Migrate(app, db) + +manager = Manager(app) +manager.add_command('db', MigrateCommand) + + +if __name__ == '__main__': + manager.run() From 2b071a1a3bc752bfaa0fd6c0b2d8f8460721d6d8 Mon Sep 17 00:00:00 2001 From: Jocelyn Delande Date: Thu, 20 Aug 2015 10:33:43 +0200 Subject: [PATCH 11/20] Add members weight in models and budget backend refs #94 --- budget/forms.py | 2 ++ budget/migrations/versions/26d6a218c329_.py | 26 ++++++++++++++++++ budget/models.py | 9 ++++--- budget/tests.py | 30 +++++++++++++++++++++ 4 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 budget/migrations/versions/26d6a218c329_.py diff --git a/budget/forms.py b/budget/forms.py index 2dde57d..918e82a 100644 --- a/budget/forms.py +++ b/budget/forms.py @@ -152,6 +152,7 @@ class BillForm(Form): class MemberForm(Form): name = TextField(_("Name"), validators=[Required()]) + weight = CommaDecimalField(_("Weight"), default=1) submit = SubmitField(_("Add")) def __init__(self, project, *args, **kwargs): @@ -170,6 +171,7 @@ class MemberForm(Form): # if the user is already bound to the project, just reactivate him person.name = self.name.data person.project = project + person.weight = self.weight.data return person diff --git a/budget/migrations/versions/26d6a218c329_.py b/budget/migrations/versions/26d6a218c329_.py new file mode 100644 index 0000000..859b9af --- /dev/null +++ b/budget/migrations/versions/26d6a218c329_.py @@ -0,0 +1,26 @@ +"""Add Person.weight column + +Revision ID: 26d6a218c329 +Revises: b9a10d5d63ce +Create Date: 2016-06-15 09:22:04.069447 + +""" + +# revision identifiers, used by Alembic. +revision = '26d6a218c329' +down_revision = 'b9a10d5d63ce' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.add_column('person', sa.Column('weight', sa.Float(), nullable=True)) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_column('person', 'weight') + ### end Alembic commands ### diff --git a/budget/models.py b/budget/models.py index 727200f..16cc6c1 100644 --- a/budget/models.py +++ b/budget/models.py @@ -40,8 +40,9 @@ class Project(db.Model): bills = Bill.query.filter(Bill.owers.contains(person)) for bill in bills.all(): if person != bill.payer: - should_pay[person] += bill.pay_each() - should_receive[bill.payer] += bill.pay_each() + share = bill.pay_each() * person.weight + should_pay[person] += share + should_receive[bill.payer] += share for person in self.members: balance = should_receive[person] - should_pay[person] @@ -159,6 +160,7 @@ class Person(db.Model): bills = db.relationship("Bill", backref="payer") name = db.Column(db.UnicodeText) + weight = db.Column(db.Float, default=1) activated = db.Column(db.Boolean, default=True) def has_bills(self): @@ -219,7 +221,8 @@ class Bill(db.Model): def pay_each(self): """Compute what each person has to pay""" if self.owers: - return self.amount / len(self.owers) + # FIXME: SQL might dot that more efficiently + return self.amount / sum(i.weight for i in self.owers) else: return 0 diff --git a/budget/tests.py b/budget/tests.py index 760ffc0..021b425 100644 --- a/budget/tests.py +++ b/budget/tests.py @@ -416,6 +416,36 @@ class BudgetTestCase(TestCase): bill = models.Bill.query.filter(models.Bill.date == '2011-08-01')[0] self.assertEqual(bill.amount, 25.02) + def test_weighted_balance(self): + self.post_project("raclette") + + # add two persons + self.app.post("/raclette/members/add", data={'name': 'alexis'}) + self.app.post("/raclette/members/add", data={'name': 'freddy familly', 'weight': 4}) + + members_ids = [m.id for m in + models.Project.query.get("raclette").members] + + # test balance + self.app.post("/raclette/add", data={ + 'date': '2011-08-10', + 'what': u'fromage à raclette', + 'payer': members_ids[0], + 'payed_for': members_ids, + 'amount': '10', + }) + + self.app.post("/raclette/add", data={ + 'date': '2011-08-10', + 'what': u'pommes de terre', + 'payer': members_ids[1], + 'payed_for': members_ids, + 'amount': '10', + }) + + balance = models.Project.query.get("raclette").balance + self.assertEqual(set(balance.values()), set([6, -6])) + def test_rounding(self): self.post_project("raclette") From 06f10d050814e026ebac3ddedd2bec2d5d616eca Mon Sep 17 00:00:00 2001 From: Jocelyn Delande Date: Thu, 20 Aug 2015 11:11:09 +0200 Subject: [PATCH 12/20] Added member weights support to API --- budget/models.py | 4 +-- budget/tests.py | 72 ++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/budget/models.py b/budget/models.py index 16cc6c1..afd29f1 100644 --- a/budget/models.py +++ b/budget/models.py @@ -153,7 +153,7 @@ class Person(db.Model): query_class = PersonQuery - _to_serialize = ("id", "name", "activated") + _to_serialize = ("id", "name", "weight", "activated") id = db.Column(db.Integer, primary_key=True) project_id = db.Column(db.String(64), db.ForeignKey("project.id")) @@ -219,7 +219,7 @@ class Bill(db.Model): archive = db.Column(db.Integer, db.ForeignKey("archive.id")) def pay_each(self): - """Compute what each person has to pay""" + """Compute what each share has to pay""" if self.owers: # FIXME: SQL might dot that more efficiently return self.amount / sum(i.weight for i in self.owers) diff --git a/budget/tests.py b/budget/tests.py index 021b425..f4dfcce 100644 --- a/budget/tests.py +++ b/budget/tests.py @@ -583,9 +583,10 @@ class APITestCase(TestCase): 'contact_email': contact }) - def api_add_member(self, project, name): + def api_add_member(self, project, name, weight=1): self.app.post("/api/projects/%s/members" % project, - data={"name": name}, headers=self.get_auth(project)) + data={"name": name, "weight": weight}, + headers=self.get_auth(project)) def get_auth(self, username, password=None): password = password or username @@ -792,8 +793,8 @@ class APITestCase(TestCase): "what": "fromage", "payer_id": 1, "owers": [ - {"activated": True, "id": 1, "name": "alexis"}, - {"activated": True, "id": 2, "name": "fred"}], + {"activated": True, "id": 1, "name": "alexis", "weight": 1}, + {"activated": True, "id": 2, "name": "fred", "weight": 1}], "amount": 25.0, "date": "2011-08-10", "id": 1} @@ -835,8 +836,8 @@ class APITestCase(TestCase): "what": "beer", "payer_id": 2, "owers": [ - {"activated": True, "id": 1, "name": "alexis"}, - {"activated": True, "id": 2, "name": "fred"}], + {"activated": True, "id": 1, "name": "alexis", "weight": 1}, + {"activated": True, "id": 2, "name": "fred", "weight": 1}], "amount": 25.0, "date": "2011-09-10", "id": 1} @@ -853,6 +854,65 @@ class APITestCase(TestCase): headers=self.get_auth("raclette")) self.assertStatus(404, req) + def test_weighted_bills(self): + # create a project + self.api_create("raclette") + + # add members + self.api_add_member("raclette", "alexis") + self.api_add_member("raclette", "freddy familly", 4) + self.api_add_member("raclette", "arnaud") + + # add a bill + req = self.app.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 this bill details + req = self.app.get("/api/projects/raclette/bills/1", + headers=self.get_auth("raclette")) + + # compare with the added info + self.assertStatus(200, req) + expected = { + "what": "fromage", + "payer_id": 1, + "owers": [ + {"activated": True, "id": 1, "name": "alexis", "weight": 1}, + {"activated": True, "id": 2, "name": "freddy familly", "weight": 4}], + "amount": 25.0, + "date": "2011-08-10", + "id": 1} + self.assertDictEqual(expected, json.loads(req.data)) + + # getting it should return a 404 + req = self.app.get("/api/projects/raclette", + headers=self.get_auth("raclette")) + + expected = { + "active_members": [ + {"activated": True, "id": 1, "name": "alexis", "weight": 1.0}, + {"activated": True, "id": 2, "name": "freddy familly", "weight": 4.0}, + {"activated": True, "id": 3, "name": "arnaud", "weight": 1.0} + ], + "balance": {"1": 20.0, "2": -20.0, "3": 0}, + "contact_email": "raclette@notmyidea.org", + "id": "raclette", + + "members": [ + {"activated": True, "id": 1, "name": "alexis", "weight": 1.0}, + {"activated": True, "id": 2, "name": "freddy familly", "weight": 4.0}, + {"activated": True, "id": 3, "name": "arnaud", "weight": 1.0} + ], + "name": "raclette", + "password": "raclette"} + + self.assertStatus(200, req) + self.assertEqual(expected, json.loads(req.data)) class ServerTestCase(APITestCase): def setUp(self): From b57df5cd368eca946e5699237a954d37fb342b07 Mon Sep 17 00:00:00 2001 From: Jocelyn Delande Date: Thu, 20 Aug 2015 13:44:50 +0200 Subject: [PATCH 13/20] UI for showing user weights in user list --- budget/static/css/main.css | 4 ++++ budget/templates/list_bills.html | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/budget/static/css/main.css b/budget/static/css/main.css index 97a3e19..2b74dc0 100644 --- a/budget/static/css/main.css +++ b/budget/static/css/main.css @@ -189,6 +189,10 @@ tr.payer_line .balance-name{ position: absolute; } +.light { + opacity: 0.3; +} + .modal-body { max-height:455px; } diff --git a/budget/templates/list_bills.html b/budget/templates/list_bills.html index 899fdeb..b0ec89a 100644 --- a/budget/templates/list_bills.html +++ b/budget/templates/list_bills.html @@ -63,7 +63,7 @@ {% set balance = g.project.balance %} {% for member in g.project.members | sort(attribute='name') if member.activated or balance[member.id] != 0 %} - {{ member.name }} + {{ member.name }} (x{{ member.weight }}) {% if member.activated %}
From 85abc0b1fcbee6549425fad87b3cdd55669672d2 Mon Sep 17 00:00:00 2001 From: Jocelyn Delande Date: Thu, 20 Aug 2015 13:45:53 +0200 Subject: [PATCH 14/20] Added a template filter not to show zero decimals on user weights --- budget/run.py | 5 +++++ budget/templates/list_bills.html | 2 +- budget/utils.py | 14 ++++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/budget/run.py b/budget/run.py index 51670f2..807ad12 100644 --- a/budget/run.py +++ b/budget/run.py @@ -9,6 +9,8 @@ from raven.contrib.flask import Sentry from web import main, db, mail from api import api from utils import PrefixedWSGI +from utils import minimal_round + app = Flask(__name__) @@ -37,6 +39,9 @@ configure() app.register_blueprint(main) app.register_blueprint(api) +# custom jinja2 filters +app.jinja_env.filters['minimal_round'] = minimal_round + # db db.init_app(app) db.app = app diff --git a/budget/templates/list_bills.html b/budget/templates/list_bills.html index b0ec89a..1d8e922 100644 --- a/budget/templates/list_bills.html +++ b/budget/templates/list_bills.html @@ -63,7 +63,7 @@ {% set balance = g.project.balance %} {% for member in g.project.members | sort(attribute='name') if member.activated or balance[member.id] != 0 %} - {{ member.name }} (x{{ member.weight }}) + {{ member.name }} (x{{ member.weight|minimal_round(1) }}) {% if member.activated %} diff --git a/budget/utils.py b/budget/utils.py index 7717aaa..c849af0 100644 --- a/budget/utils.py +++ b/budget/utils.py @@ -1,6 +1,7 @@ import re import inspect +from jinja2 import filters from flask import redirect from werkzeug.routing import HTTPException, RoutingException @@ -63,3 +64,16 @@ class PrefixedWSGI(object): if scheme: environ['wsgi.url_scheme'] = scheme return self.wsgi_app(environ, start_response) + + +def minimal_round(*args, **kw): + """ Jinja2 filter: rounds, but display only non-zero decimals + + from http://stackoverflow.com/questions/28458524/ + """ + # Use the original round filter, to deal with the extra arguments + res = filters.do_round(*args, **kw) + # Test if the result is equivalent to an integer and + # return depending on it + ires = int(res) + return (res if res != ires else ires) From 1147f2ece8eaa868f5f0b89da583d03560ec23ee Mon Sep 17 00:00:00 2001 From: Jocelyn Delande Date: Sat, 22 Aug 2015 10:49:15 +0200 Subject: [PATCH 15/20] Ask for confirmation only for deleting users --- budget/templates/list_bills.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/budget/templates/list_bills.html b/budget/templates/list_bills.html index 1d8e922..2702d99 100644 --- a/budget/templates/list_bills.html +++ b/budget/templates/list_bills.html @@ -13,9 +13,13 @@ {% if add_bill %} $('#new-bill').click(); {% endif %} - // ask for confirmation before removing an user + // Hide all members actions $('.action').each(function(){ $(this).hide(); + }); + + // ask for confirmation before removing an user + $('.action.delete').each(function(){ var link = $(this).find('button'); link.click(function(){ if ($(this).hasClass("confirm")){ From 64c2cd56dfb58ef78936d39b2e71640cd631653e Mon Sep 17 00:00:00 2001 From: Jocelyn Delande Date: Sat, 22 Aug 2015 10:49:35 +0200 Subject: [PATCH 16/20] display an edit button on members list --- budget/static/css/main.css | 5 +++++ budget/templates/list_bills.html | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/budget/static/css/main.css b/budget/static/css/main.css index 2b74dc0..143ce8b 100644 --- a/budget/static/css/main.css +++ b/budget/static/css/main.css @@ -176,6 +176,11 @@ tr.payer_line .balance-name{ color: red; } +.edit button, .edit button:hover { + background: url('../images/edit.png') left no-repeat; + +} + .reactivate button, .reactivate button:hover { background: url('../images/reactivate.png') left no-repeat; color: white; diff --git a/budget/templates/list_bills.html b/budget/templates/list_bills.html index 2702d99..9bad344 100644 --- a/budget/templates/list_bills.html +++ b/budget/templates/list_bills.html @@ -71,7 +71,10 @@ {% if member.activated %} - + +
+
+ {% else %}
From ec8fe2326b209d580922876e9b1d32cbe98646b7 Mon Sep 17 00:00:00 2001 From: Jocelyn Delande Date: Sat, 22 Aug 2015 01:19:27 +0200 Subject: [PATCH 17/20] Added member edit form --- budget/forms.py | 14 ++++++++++---- budget/static/css/main.css | 1 - budget/templates/edit_member.html | 17 +++++++++++++++++ budget/templates/forms.html | 14 ++++++++++++++ budget/templates/list_bills.html | 2 +- budget/web.py | 18 ++++++++++++++++++ 6 files changed, 60 insertions(+), 6 deletions(-) create mode 100644 budget/templates/edit_member.html diff --git a/budget/forms.py b/budget/forms.py index 918e82a..7d6eb51 100644 --- a/budget/forms.py +++ b/budget/forms.py @@ -155,16 +155,18 @@ class MemberForm(Form): weight = CommaDecimalField(_("Weight"), default=1) submit = SubmitField(_("Add")) - def __init__(self, project, *args, **kwargs): + def __init__(self, project, edit=False, *args, **kwargs): super(MemberForm, self).__init__(*args, **kwargs) self.project = project + self.edit = edit def validate_name(form, field): if field.data == form.name.default: raise ValidationError(_("User name incorrect")) - if Person.query.filter(Person.name == field.data)\ - .filter(Person.project == form.project)\ - .filter(Person.activated == True).all(): + if (not form.edit and Person.query.filter( + Person.name == field.data, + Person.project == form.project, + Person.activated == True).all()): raise ValidationError(_("This project already have this member")) def save(self, project, person): @@ -175,6 +177,10 @@ class MemberForm(Form): return person + def fill(self, member): + self.name.data = member.name + self.weight.data = member.weight + class InviteForm(Form): emails = TextAreaField(_("People to notify")) diff --git a/budget/static/css/main.css b/budget/static/css/main.css index 143ce8b..55dad99 100644 --- a/budget/static/css/main.css +++ b/budget/static/css/main.css @@ -217,4 +217,3 @@ tr.payer_line .balance-name{ .row-fluid > .offset3{margin-left:25.5%;} .row-fluid > .offset2{margin-left:17%;} .row-fluid > .offset1{margin-left:8.5%;} - diff --git a/budget/templates/edit_member.html b/budget/templates/edit_member.html new file mode 100644 index 0000000..5f097f9 --- /dev/null +++ b/budget/templates/edit_member.html @@ -0,0 +1,17 @@ +{% extends "layout.html" %} + +{% block js %} + $('#cancel-form').click(function(){location.href={{ url_for(".list_bills") }};}); +{% endblock %} + + +{% block top_menu %} +{{ _("Back to the list") }} +{% endblock %} + +{% block content %} + + + {{ forms.edit_member(form, edit) }} + +{% endblock %} diff --git a/budget/templates/forms.html b/budget/templates/forms.html index ec73515..07e5b3d 100644 --- a/budget/templates/forms.html +++ b/budget/templates/forms.html @@ -95,6 +95,20 @@ {{ form.name(placeholder=_("Type user name here")) }} {% endmacro %} +{% macro edit_member(form, title=True) %} +
+ {% if title %}{{ _("Edit this member") }}{% endif %} + {% include "display_errors.html" %} + {{ form.hidden_tag() }} + {{ input(form.name) }} + {{ input(form.weight) }} +
+
+ {{ form.submit(class="btn btn-primary") }} +
+{% endmacro %} + + {% macro invites(form) %} {{ form.hidden_tag() }} {{ input(form.emails) }} diff --git a/budget/templates/list_bills.html b/budget/templates/list_bills.html index 9bad344..0e0efd8 100644 --- a/budget/templates/list_bills.html +++ b/budget/templates/list_bills.html @@ -72,7 +72,7 @@
-
+
{% else %} diff --git a/budget/web.py b/budget/web.py index 77de026..63fbe4d 100644 --- a/budget/web.py +++ b/budget/web.py @@ -322,6 +322,24 @@ def remove_member(member_id): return redirect(url_for(".list_bills")) +@main.route("//members//edit", + methods=["POST", "GET"]) +def edit_member(member_id): + member = Person.query.get(member_id, g.project) + if not member: + raise werkzeug.exceptions.NotFound() + form = MemberForm(g.project, edit=True) + + if request.method == 'POST' and form.validate(): + form.save(g.project, member) + db.session.commit() + flash(_("User '%(name)s' has been edited", name=member.name)) + return redirect(url_for(".list_bills")) + + form.fill(member) + return render_template("edit_member.html", form=form, edit=True) + + @main.route("//add", methods=["GET", "POST"]) def add_bill(): form = get_billform_for(g.project) From 7a630b78eae90a57033f785a2253fd490868352d Mon Sep 17 00:00:00 2001 From: Jocelyn Delande Date: Sun, 23 Aug 2015 11:58:27 +0200 Subject: [PATCH 18/20] Hide the member weights in members list if all weights are "1". --- budget/models.py | 4 ++++ budget/static/css/main.css | 8 ++++++++ budget/templates/list_bills.html | 4 +++- budget/tests.py | 16 ++++++++++++++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/budget/models.py b/budget/models.py index afd29f1..852b3e1 100644 --- a/budget/models.py +++ b/budget/models.py @@ -50,6 +50,10 @@ class Project(db.Model): return balances + @property + def uses_weights(self): + return len([i for i in self.members if i.weight != 1]) > 0 + def get_transactions_to_settle_bill(self): """Return a list of transactions that could be made to settle the bill""" #cache value for better performance diff --git a/budget/static/css/main.css b/budget/static/css/main.css index 55dad99..f3fe8a0 100644 --- a/budget/static/css/main.css +++ b/budget/static/css/main.css @@ -198,6 +198,14 @@ tr.payer_line .balance-name{ opacity: 0.3; } +.extra-info { + display: none; +} + +tr:hover .extra-info { + display: inline; +} + .modal-body { max-height:455px; } diff --git a/budget/templates/list_bills.html b/budget/templates/list_bills.html index 0e0efd8..f081334 100644 --- a/budget/templates/list_bills.html +++ b/budget/templates/list_bills.html @@ -67,7 +67,9 @@ {% set balance = g.project.balance %} {% for member in g.project.members | sort(attribute='name') if member.activated or balance[member.id] != 0 %} - {{ member.name }} (x{{ member.weight|minimal_round(1) }}) + {{ member.name }} + (x{{ member.weight|minimal_round(1) }}) + {% if member.activated %}
diff --git a/budget/tests.py b/budget/tests.py index f4dfcce..82465f9 100644 --- a/budget/tests.py +++ b/budget/tests.py @@ -446,6 +446,22 @@ class BudgetTestCase(TestCase): balance = models.Project.query.get("raclette").balance self.assertEqual(set(balance.values()), set([6, -6])) + def test_weighted_members_list(self): + self.post_project("raclette") + + # add two persons + self.app.post("/raclette/members/add", data={'name': 'alexis'}) + self.app.post("/raclette/members/add", data={'name': 'tata', 'weight': 1}) + + resp = self.app.get("/raclette/") + self.assertIn('extra-info', resp.data) + + self.app.post("/raclette/members/add", data={'name': 'freddy familly', 'weight': 4}) + + resp = self.app.get("/raclette/") + self.assertNotIn('extra-info', resp.data) + + def test_rounding(self): self.post_project("raclette") From d3bb04c1bfdd24cc09db939ac9bbaf540bfdfc0c Mon Sep 17 00:00:00 2001 From: Jocelyn Delande Date: Wed, 15 Jun 2016 10:19:08 +0200 Subject: [PATCH 19/20] Add migration to initialize Person weights That's for Persons that existed before the weights were added to model. --- ...ab0_initialize_all_members_weights_to_1.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 budget/migrations/versions/f629c8ef4ab0_initialize_all_members_weights_to_1.py diff --git a/budget/migrations/versions/f629c8ef4ab0_initialize_all_members_weights_to_1.py b/budget/migrations/versions/f629c8ef4ab0_initialize_all_members_weights_to_1.py new file mode 100644 index 0000000..5542146 --- /dev/null +++ b/budget/migrations/versions/f629c8ef4ab0_initialize_all_members_weights_to_1.py @@ -0,0 +1,39 @@ +"""Initialize all members weights to 1 + +Revision ID: f629c8ef4ab0 +Revises: 26d6a218c329 +Create Date: 2016-06-15 09:40:30.400862 + +""" + +# revision identifiers, used by Alembic. +revision = 'f629c8ef4ab0' +down_revision = '26d6a218c329' + +from alembic import op +import sqlalchemy as sa + +# Snapshot of the person table +person_helper = sa.Table( + 'person', sa.MetaData(), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('project_id', sa.String(length=64), nullable=True), + sa.Column('name', sa.UnicodeText(), nullable=True), + sa.Column('activated', sa.Boolean(), nullable=True), + sa.Column('weight', sa.Float(), nullable=True), + sa.ForeignKeyConstraint(['project_id'], ['project.id'], ), + sa.PrimaryKeyConstraint('id') +) + + +def upgrade(): + op.execute( + person_helper.update() + .where(person_helper.c.weight == None) + .values(weight=1) + ) + + +def downgrade(): + # Downgrade path is not possible, because information has been lost. + pass From c49a355eb082cff208806f785d52567ddd043c03 Mon Sep 17 00:00:00 2001 From: Jocelyn Delande Date: Wed, 15 Jun 2016 10:30:38 +0200 Subject: [PATCH 20/20] Update translation --- .../translations/fr/LC_MESSAGES/messages.mo | Bin 7894 -> 8551 bytes .../translations/fr/LC_MESSAGES/messages.po | 5 ++++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/budget/translations/fr/LC_MESSAGES/messages.mo b/budget/translations/fr/LC_MESSAGES/messages.mo index 59e62621a2bebac6b42b0d4848dbcf00b6a81076..558d835fc42ff0e74905f1fa174c87e0d574fcc9 100644 GIT binary patch delta 2545 zcmZA1eN5F=9LMo57p`1nc_=|mO%xO>k^)T#E@@QPhcV}VW3_Z_ zO>6nUW=j`yEA<~yb2+ChUFKS?X^iHqS z4prT#@xD(@*lm;uF@-oDZ}vXcCUT*iOESyAK`h3rn2X7y&El{GQ!s@3$s(i+t3y84 zz$F=*aSU$22e2FcW}fv@d60%(s0sArC>+EIcoE0rHB7}jP9MD`@O>hx!%Wmb1*qrC zkpZk01Go~IyEQr6a1`TP7nP~p*z9iX!8E=fz;XBqPQweBi8oObj2~+@6Gx*aQj9?i zA&*!iGFNNGeC$GHst+~6k8lj*+iBPEIcmn=p;q(*^0B*Il-hq#8SpbJwNFAlmxIb& zDQdt5)C8JQ&vl}>U!%4nLLSx8cGQFK;w0?DbUcRY@DeH` zKVcsJi$Tm|nYzCOZ{j+v!CGd!33sCU@sB6}xm2>qM-DDPt)vsRXWLOL+=ZIhK~yGA z;0!#2LA>tn$CDndI2|?d8K{gFIEzqcAmpr0CI6K)ETy3wcc5nW8R|@2MUrBF;VevI z7RpQrm600E!X{M5-Kc@zK%I$R=T2mf_MW?c2(^%-9u+=zf{Qx-0=0rGNH*<9RL6Hw zhwxALJ&TQeGmZm@n&^>koLLz`;m{G<&vcLf0)W_8m^;O zlE%^1jPp?sRv=^8D%6D5Ba5~O>TL9)GO`b~!UL}TB&whDsEH1t7J37P=*wAWfyGqI9TVTYn+5m%j= z`9v8}MXVxTB%UNx^fsuxN_Z7qw3k7GEJj})umW8u3yC$VxS|uS@&eI7v=Pc&3-KH= zlTgwAmPc#d85J9WLoya>^C du|42#3%7S{9QYt*c&xv^E8H3x*gR#8??1=e$Swc? delta 1884 zcmYM!ZD?0j90%~Dvo+g;x>}`9H%HD^+Ji>RP{BE*FrhakVN}$jKr0DCdT@V+;fn;N zvLdL^9!X}TBkPq2BFv)f#jqH(@{KKoiXJ2gvCkJT^ymKX=iYPA|D50Nch3F)+;?ha zZK8GBYsKej-*(^Oy4?N$S#fD8v)!)bQf^>3U*Z%VRnwS7OS&;)zy6EuWkLp3IURSzrXET?vi>o<@15AK5F5(U*kWbjj5hl|yrUE~)hi92e zwP{R%3)yJ?(pxpGVIsbV$!H^+xS6U|o@FX9RJHG6`t4&XcZiO1(kTIqGrvE>Ea9I_ zMVmcL0$r>vaM9!9J+5Px;y3DB>ijw#TX;3wcolD823*TjHbId zaa1{a>JMyXS;WZ1+6Hjk;q&Af)o*~tg0=QWzEyvkH^ zZ{>$n{W4s6m@C{LouU4>yEv~Q5#46C?1j6Twco-e9Aqjo%v9oY&f~Ytz`rt;`iI#I z7b>S&=&bd0r#zp_B+$hqbY0Cw242NPekUbg?qvpkl-YbwRrjy(#VMt{#RR&*D2ut8 zsoayC%AL$!*~Lt_ukvF$%0VYFmfG>^!6~+Bm|#l(7jNWwrUFasoD8&)slX%51TQcH z?XKL*RNx@RSPnDej#l>*%+mbM?7wpE;zgONS1QrMlxjAUc_&keHJs1&%!Hen%(pT9 z_R}hsubIGqVE%}jy~}w%iwXP|X1w)WmiK>yi)6BsiTE9+^G$QT<6OsXKF4If zk6EHGn9PpR_9$aa=9A3-z&Yl39inhq*3v4MhnY%lWfB-{SO5HQhlUKehkg8jt2oI7 zv`AVRxQ{92CNAYxCV>5vOgX}2{5`YA_0o