diff --git a/sources/AUTHORS b/sources/AUTHORS index f104070..89a2af3 100644 --- a/sources/AUTHORS +++ b/sources/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/sources/LICENSE b/sources/LICENSE index a668284..ecc7977 100644 --- a/sources/LICENSE +++ b/sources/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/sources/budget/default_settings.py b/sources/budget/default_settings.py index 111abf2..d5a9a9b 100644 --- a/sources/budget/default_settings.py +++ b/sources/budget/default_settings.py @@ -3,9 +3,4 @@ SQLALCHEMY_DATABASE_URI = 'sqlite:///budget.db' SQLACHEMY_ECHO = DEBUG SECRET_KEY = "tralala" -DEFAULT_MAIL_SENDER = ("Budget manager", "budget@notmyidea.org") - -try: - from settings import * -except ImportError: - pass +MAIL_DEFAULT_SENDER = ("Budget manager", "budget@notmyidea.org") diff --git a/sources/budget/forms.py b/sources/budget/forms.py index 2dde57d..7d6eb51 100644 --- a/sources/budget/forms.py +++ b/sources/budget/forms.py @@ -152,27 +152,35 @@ 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): + 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): # 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 + 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/sources/budget/manage.py b/sources/budget/manage.py new file mode 100755 index 0000000..e0b25a7 --- /dev/null +++ b/sources/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() diff --git a/sources/budget/merged_settings.py b/sources/budget/merged_settings.py new file mode 100644 index 0000000..f6b1f81 --- /dev/null +++ b/sources/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/sources/budget/migrations/README b/sources/budget/migrations/README new file mode 100755 index 0000000..98e4f9c --- /dev/null +++ b/sources/budget/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/sources/budget/migrations/alembic.ini b/sources/budget/migrations/alembic.ini new file mode 100644 index 0000000..f8ed480 --- /dev/null +++ b/sources/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/sources/budget/migrations/env.py b/sources/budget/migrations/env.py new file mode 100755 index 0000000..cef89d2 --- /dev/null +++ b/sources/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/sources/budget/migrations/script.py.mako b/sources/budget/migrations/script.py.mako new file mode 100755 index 0000000..9570201 --- /dev/null +++ b/sources/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/sources/budget/migrations/versions/26d6a218c329_.py b/sources/budget/migrations/versions/26d6a218c329_.py new file mode 100644 index 0000000..859b9af --- /dev/null +++ b/sources/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/sources/budget/migrations/versions/b9a10d5d63ce_.py b/sources/budget/migrations/versions/b9a10d5d63ce_.py new file mode 100644 index 0000000..92bb446 --- /dev/null +++ b/sources/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/sources/budget/migrations/versions/f629c8ef4ab0_initialize_all_members_weights_to_1.py b/sources/budget/migrations/versions/f629c8ef4ab0_initialize_all_members_weights_to_1.py new file mode 100644 index 0000000..5542146 --- /dev/null +++ b/sources/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 diff --git a/sources/budget/models.py b/sources/budget/models.py index 27bd80b..852b3e1 100644 --- a/sources/budget/models.py +++ b/sources/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] @@ -49,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 @@ -62,7 +67,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"]}) @@ -152,13 +157,14 @@ 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")) 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): @@ -217,9 +223,10 @@ 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: - 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/sources/budget/requirements.txt b/sources/budget/requirements.txt index 87d8966..d49767e 100644 --- a/sources/budget/requirements.txt +++ b/sources/budget/requirements.txt @@ -1,7 +1,8 @@ flask>=0.9 flask-wtf==0.8 flask-sqlalchemy -flask-mail +flask-mail>=0.8 +Flask-Migrate==1.8.0 flask-babel flask-rest jinja2==2.6 diff --git a/sources/budget/run.py b/sources/budget/run.py index dace736..a7ce25e 100644 --- a/sources/budget/run.py +++ b/sources/budget/run.py @@ -1,22 +1,56 @@ +import os +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 from api import api -from utils import ReverseProxied +from utils import PrefixedWSGI +from utils import minimal_round + app = Flask(__name__) -app.config.from_object("default_settings") -app.wsgi_app = ReverseProxied(app.wsgi_app, app.config['APPLICATION_ROOT']) + + +def configure(): + """ A way to (re)configure the app, specially reset the settings + """ + config_obj = os.environ.get('FLASK_SETTINGS_MODULE', 'merged_settings') + app.config.from_object(config_obj) + app.wsgi_app = PrefixedWSGI(app) + + # 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) app.register_blueprint(api) +# custom jinja2 filters +app.jinja_env.filters['minimal_round'] = minimal_round + # 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) diff --git a/sources/budget/static/css/main.css b/sources/budget/static/css/main.css index 3274241..f3fe8a0 100644 --- a/sources/budget/static/css/main.css +++ b/sources/budget/static/css/main.css @@ -176,11 +176,16 @@ 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; } - + #bill-form > fieldset { margin-top: 10px; } @@ -189,6 +194,18 @@ tr.payer_line .balance-name{ position: absolute; } +.light { + opacity: 0.3; +} + +.extra-info { + display: none; +} + +tr:hover .extra-info { + display: inline; +} + .modal-body { max-height:455px; } @@ -208,4 +225,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/sources/budget/templates/authenticate.html b/sources/budget/templates/authenticate.html index dc62a70..98914d0 100644 --- a/sources/budget/templates/authenticate.html +++ b/sources/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/sources/budget/templates/edit_member.html b/sources/budget/templates/edit_member.html new file mode 100644 index 0000000..5f097f9 --- /dev/null +++ b/sources/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/sources/budget/templates/forms.html b/sources/budget/templates/forms.html index 2904e0d..07e5b3d 100644 --- a/sources/budget/templates/forms.html +++ b/sources/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") }} @@ -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/sources/budget/templates/invitation_mail.en b/sources/budget/templates/invitation_mail.en index 4f5bbf0..03f5141 100644 --- a/sources/budget/templates/invitation_mail.en +++ b/sources/budget/templates/invitation_mail.en @@ -1,10 +1,10 @@ -Hi, +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. -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) diff --git a/sources/budget/templates/invitation_mail.fr b/sources/budget/templates/invitation_mail.fr index a46f0a0..53698dd 100644 --- a/sources/budget/templates/invitation_mail.fr +++ b/sources/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, diff --git a/sources/budget/templates/list_bills.html b/sources/budget/templates/list_bills.html index f9d372a..f081334 100644 --- a/sources/budget/templates/list_bills.html +++ b/sources/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")){ @@ -42,13 +46,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 %} @@ -63,11 +67,16 @@ {% 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|minimal_round(1) }}) + {% if member.activated %}
-
+ +
+
+ {% else %}
diff --git a/sources/budget/templates/password_reminder.en b/sources/budget/templates/password_reminder.en index fc24a6f..31210aa 100644 --- a/sources/budget/templates/password_reminder.en +++ b/sources/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/sources/budget/templates/reminder_mail.en b/sources/budget/templates/reminder_mail.en index b2e3a65..fe57be2 100644 --- a/sources/budget/templates/reminder_mail.en +++ b/sources/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/sources/budget/templates/send_invites.html b/sources/budget/templates/send_invites.html index 9ddded1..7b3bdc5 100644 --- a/sources/budget/templates/send_invites.html +++ b/sources/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/sources/budget/tests.py b/sources/budget/tests.py index 0f7c2a2..82465f9 100644 --- a/sources/budget/tests.py +++ b/sources/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 @@ -413,6 +416,52 @@ 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_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") @@ -532,7 +581,7 @@ class BudgetTestCase(TestCase): for m, a in members.items(): self.assertEqual(a, balance[m.id]) return - + class APITestCase(TestCase): @@ -550,9 +599,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 @@ -759,8 +809,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} @@ -802,8 +852,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} @@ -820,6 +870,80 @@ 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): + 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/sources/budget/translations/fr/LC_MESSAGES/messages.mo b/sources/budget/translations/fr/LC_MESSAGES/messages.mo index 59e6262..558d835 100644 Binary files a/sources/budget/translations/fr/LC_MESSAGES/messages.mo and b/sources/budget/translations/fr/LC_MESSAGES/messages.mo differ diff --git a/sources/budget/translations/fr/LC_MESSAGES/messages.po b/sources/budget/translations/fr/LC_MESSAGES/messages.po index 776e6e2..d915cb3 100644 --- a/sources/budget/translations/fr/LC_MESSAGES/messages.po +++ b/sources/budget/translations/fr/LC_MESSAGES/messages.po @@ -105,6 +105,10 @@ msgstr "Le montant d'une facture ne peut pas être nul." msgid "Name" msgstr "Nom" +#: forms.py:155 +msgid "Weight" +msgstr "Poids" + #: forms.py:155 templates/forms.html:95 msgid "Add" msgstr "Ajouter" @@ -497,4 +501,3 @@ msgstr "Qui doit payer ?" #: templates/settle_bills.html:31 msgid "To whom?" msgstr "Pour qui ?" - diff --git a/sources/budget/utils.py b/sources/budget/utils.py index 9d63fc2..c849af0 100644 --- a/sources/budget/utils.py +++ b/sources/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 @@ -34,23 +35,25 @@ class Redirect303(HTTPException, RoutingException): return redirect(self.new_url, 303) -class ReverseProxied(object): +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, prefix): + def __init__(self, app): self.app = app - self.prefix = prefix + self.wsgi_app = app.wsgi_app def __call__(self, environ, start_response): - script_name = self.prefix + script_name = self.app.config['APPLICATION_ROOT'] if script_name: environ['SCRIPT_NAME'] = script_name path_info = environ['PATH_INFO'] @@ -60,4 +63,17 @@ class ReverseProxied(object): scheme = environ.get('HTTP_X_SCHEME', '') if scheme: environ['wsgi.url_scheme'] = scheme - return self.app(environ, start_response) + 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) diff --git a/sources/budget/web.py b/sources/budget/web.py index 77de026..63fbe4d 100644 --- a/sources/budget/web.py +++ b/sources/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) diff --git a/sources/docs/_themes/pelican/static/pelican.css_t b/sources/docs/_themes/pelican/static/pelican.css_t index 3cb2a3c..8d8a9b2 100644 --- a/sources/docs/_themes/pelican/static/pelican.css_t +++ b/sources/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/sources/docs/api.rst b/sources/docs/api.rst index 34edbdb..ce5df71 100644 --- a/sources/docs/api.rst +++ b/sources/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