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

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

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

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

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

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

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