1
0
Fork 0
mirror of https://github.com/YunoHost-Apps/ihatemoney_ynh.git synced 2024-09-03 19:26:15 +02:00

Merge remote-tracking branch 'upstream/master'

Conflicts:
	conf/nginx.conf
	sources/budget/run.py
	sources/budget/utils.py
This commit is contained in:
Jocelyn Delande 2016-06-17 22:27:08 +02:00
commit 98ea1c8873
33 changed files with 684 additions and 110 deletions

View file

@ -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.

View file

@ -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).

View file

@ -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")

View file

@ -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"))

16
sources/budget/manage.py Executable file
View file

@ -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()

View file

@ -0,0 +1,10 @@
"""
Merges default settings with user-defined settings
"""
from default_settings import *
try:
from settings import *
except ImportError:
pass

View file

@ -0,0 +1 @@
Generic single-database configuration.

View file

@ -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

View file

@ -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()

View file

@ -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"}

View file

@ -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 ###

View file

@ -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 ###

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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%;}

View file

@ -3,7 +3,7 @@
<h2>Authentication</h2>
{% if create_project %}
<p class="info">{{ _("The project you are trying to access do not exist, do you want
<p class="info">{{ _("The project you are trying to access do not exist, do you want
to") }} <a href="{{ url_for(".create_project", project_id=create_project) }}">{{ _("create it") }}</a>{{ _("?") }}
</p>
{% endif %}

View file

@ -0,0 +1,17 @@
{% extends "layout.html" %}
{% block js %}
$('#cancel-form').click(function(){location.href={{ url_for(".list_bills") }};});
{% endblock %}
{% block top_menu %}
<a href="{{ url_for(".list_bills") }}">{{ _("Back to the list") }}</a>
{% endblock %}
{% block content %}
<form class="form-horizontal" method="post">
{{ forms.edit_member(form, edit) }}
</form>
{% endblock %}

View file

@ -76,12 +76,12 @@
<fieldset>
{% if title %}<legend>{% if edit %}{{ _("Edit this bill") }} {% else %}{{ _("Add a bill") }} {% endif %}</legend>{% 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) }}
</fieldset>
<div class="actions">
{{ form.submit(class="btn btn-primary") }}
@ -95,6 +95,20 @@
{{ form.name(placeholder=_("Type user name here")) }}<button class="btn">{{ _("Add") }}</button>
{% endmacro %}
{% macro edit_member(form, title=True) %}
<fieldset>
{% if title %}<legend>{{ _("Edit this member") }}</legend>{% endif %}
{% include "display_errors.html" %}
{{ form.hidden_tag() }}
{{ input(form.name) }}
{{ input(form.weight) }}
</fieldset>
<div class="actions">
{{ form.submit(class="btn btn-primary") }}
</div>
{% endmacro %}
{% macro invites(form) %}
{{ form.hidden_tag() }}
{{ input(form.emails) }}

View file

@ -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)

View file

@ -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,

View file

@ -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 %}
<tr id="bal-member-{{ member.id }}" action={% if member.activated %}delete{% else %}reactivate{% endif %}>
<td class="balance-name">{{ member.name }}</td>
<td class="balance-name">{{ member.name }}
<span class="light{% if not g.project.uses_weights %} extra-info{% endif %}">(x{{ member.weight|minimal_round(1) }})</span>
</td>
{% if member.activated %}
<td>
<form class="action delete" action="{{ url_for(".remove_member", member_id=member.id) }}" method="POST">
<button type="submit">{{ _("delete") }}</button></form></td>
<button type="submit">{{ _("delete") }}</button></form>
<form class="action edit" action="{{ url_for(".edit_member", member_id=member.id) }}" method="GET">
<button type="submit">{{ _("edit") }}</button></form>
</td>
{% else %}
<td>
<form class="action reactivate" action="{{ url_for(".reactivate", member_id=member.id) }}" method="POST">

View file

@ -1,4 +1,4 @@
Hi,
Hi,
You requested to be reminded about your password for "{{ project.name }}".

View file

@ -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.

View file

@ -9,7 +9,7 @@
{% endblock %}
{% block content %}
<h2>{{ _("Invite people to join this project") }}</h2>
<p>{{ _("Specify a (comma separated) list of email adresses you want to notify about the
<p>{{ _("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.") }}</p>
<p>{{ _("If you prefer, you can") }} <a href="{{ url_for(".list_bills") }}">{{ _("skip this step") }}</a> {{ _("and notify them yourself") }}</p>

View file

@ -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()

View file

@ -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 ?"

View file

@ -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)

View file

@ -322,6 +322,24 @@ def remove_member(member_id):
return redirect(url_for(".list_bills"))
@main.route("/<project_id>/members/<member_id>/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("/<project_id>/add", methods=["GET", "POST"])
def add_bill():
form = get_billform_for(g.project)

View file

@ -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;

View file

@ -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/<id>/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/<id>/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/<id>/members/<member-id>`::
$ 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