Absolute imports & some other improvements (#243)

* Use absolute imports and rename package to ihatemoney
* Add a ihatemoney command
* Factorize application creation logic
* Refactor the tests
* Update the wsgi.py module with the new create_app() function
* Fix some styling thanks to Flake8.
* Automate Flake8 check in the CI.
This commit is contained in:
Alexis Metaireau 2017-07-07 00:06:56 +02:00 committed by GitHub
parent 0e374cd5e0
commit 3a4282fd75
82 changed files with 613 additions and 524 deletions

View file

@ -1,3 +1,3 @@
include *.rst include *.rst
recursive-include budget *.rst *.py *.yaml *.po *.mo *.html *.css *.js *.eot *.svg *.woff *.txt *.png *.ini *.cfg recursive-include ihatemoney *.rst *.py *.yaml *.po *.mo *.html *.css *.js *.eot *.svg *.woff *.txt *.png *.ini *.cfg
include LICENSE CONTRIBUTORS CHANGELOG.rst include LICENSE CONTRIBUTORS CHANGELOG.rst

View file

@ -29,7 +29,7 @@ remove-install-stamp:
update: remove-install-stamp install update: remove-install-stamp install
serve: install serve: install
$(PYTHON) -m budget.manage runserver $(PYTHON) -m ihatemoney.manage runserver
test: $(DEV_STAMP) test: $(DEV_STAMP)
$(VENV)/bin/tox $(VENV)/bin/tox
@ -38,7 +38,7 @@ release: $(DEV_STAMP)
$(VENV)/bin/fullrelease $(VENV)/bin/fullrelease
build-translations: build-translations:
$(VENV)/bin/pybabel compile -d budget/translations $(VENV)/bin/pybabel compile -d ihatemoney/translations
build-requirements: build-requirements:
$(VIRTUALENV) $(TEMPDIR) $(VIRTUALENV) $(TEMPDIR)

View file

@ -1,14 +0,0 @@
DEBUG = False
SQLALCHEMY_DATABASE_URI = 'sqlite:///budget.db'
SQLACHEMY_ECHO = DEBUG
# Will likely become the default value in flask-sqlalchemy >=3 ; could be removed
# then:
SQLALCHEMY_TRACK_MODIFICATIONS = False
SECRET_KEY = "tralala"
MAIL_DEFAULT_SENDER = ("Budget manager", "budget@notmyidea.org")
ACTIVATE_DEMO_PROJECT = True
ADMIN_PASSWORD = ""

View file

@ -1,121 +0,0 @@
import os
import os.path
import warnings
from flask import Flask, g, request, session
from flask_babel import Babel
from flask_migrate import Migrate, upgrade, stamp
from raven.contrib.flask import Sentry
from .web import main, db, mail
from .api import api
from .utils import PrefixedWSGI
from .utils import minimal_round
from . import default_settings
app = Flask(__name__, instance_path='/etc/ihatemoney', instance_relative_config=True)
def pre_alembic_db():
""" Checks if we are migrating from a pre-alembic ihatemoney
"""
con = db.engine.connect()
tables_exist = db.engine.dialect.has_table(con, 'project')
alembic_setup = db.engine.dialect.has_table(con, 'alembic_version')
return tables_exist and not alembic_setup
def configure():
""" A way to (re)configure the app, specially reset the settings
"""
default_config_file = os.path.join(app.root_path, 'default_settings.py')
config_file = os.environ.get('IHATEMONEY_SETTINGS_FILE_PATH')
# Load default settings first
# Then load the settings from the path set in IHATEMONEY_SETTINGS_FILE_PATH var
# If not set, default to /etc/ihatemoney/ihatemoney.cfg
# If the latter doesn't exist no error is raised and the default settings are used
app.config.from_pyfile(default_config_file)
if config_file:
app.config.from_pyfile(config_file)
else:
app.config.from_pyfile('ihatemoney.cfg', silent=True)
app.wsgi_app = PrefixedWSGI(app)
if app.config['SECRET_KEY'] == default_settings.SECRET_KEY:
warnings.warn(
"Running a server without changing the SECRET_KEY can lead to"
+ " user impersonation. Please update your configuration file.",
UserWarning
)
# 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
if "pbkdf2:sha256:" not in app.config['ADMIN_PASSWORD'] and app.config['ADMIN_PASSWORD']:
# Since 2.0
warnings.warn(
"The way Ihatemoney stores your ADMIN_PASSWORD has changed. You are using an unhashed"
+" ADMIN_PASSWORD, which is not supported anymore and won't let you access your admin"
+" endpoints. Please use the command './budget/manage.py generate_password_hash'"
+" to generate a proper password HASH and copy the output to the value of"
+" ADMIN_PASSWORD in your settings file.",
UserWarning
)
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 migrations
migrate = Migrate(app, db)
migrations_path = os.path.join(app.root_path, 'migrations')
if pre_alembic_db():
with app.app_context():
# fake the first migration
stamp(migrations_path, revision='b9a10d5d63ce')
# auto-execute migrations on runtime
with app.app_context():
upgrade(migrations_path)
# mail
mail.init_app(app)
# translations
babel = Babel(app)
# sentry
sentry = Sentry(app)
@babel.localeselector
def get_locale():
# get the lang from the session if defined, fallback on the browser "accept
# languages" header.
lang = session.get('lang', request.accept_languages.best_match(['fr', 'en']))
setattr(g, 'lang', lang)
return lang
def main():
app.run(host="0.0.0.0", debug=True)
if __name__ == '__main__':
main()

View file

@ -1 +0,0 @@
from run import app as application

View file

@ -1,3 +1,5 @@
zest.releaser zest.releaser
tox tox
pytest pytest
Flask-Testing
Flake8

View file

@ -29,7 +29,7 @@ dependencies yourself (that's what the `make serve` does). That would be::
And then run the application:: And then run the application::
cd budget cd ihatemoney
python run.py python run.py
In any case, you can point your browser at `http://localhost:5000 <http://localhost:5000>`_. In any case, you can point your browser at `http://localhost:5000 <http://localhost:5000>`_.
@ -76,7 +76,8 @@ properly.
| Setting name | Default | What does it do? | | Setting name | Default | What does it do? |
+============================+===========================+========================================================================================+ +============================+===========================+========================================================================================+
| SQLALCHEMY_DATABASE_URI | ``sqlite:///budget.db`` | Specifies the type of backend to use and its location. More information | | SQLALCHEMY_DATABASE_URI | ``sqlite:///budget.db`` | Specifies the type of backend to use and its location. More information |
| | | on the format used can be found on `the SQLAlchemy documentation`. | | | | on the format used can be found on `the SQLAlchemy documentation |
| | | <http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls>`_. |
+----------------------------+---------------------------+----------------------------------------------------------------------------------------+ +----------------------------+---------------------------+----------------------------------------------------------------------------------------+
| SECRET_KEY | ``tralala`` | The secret key used to encrypt the cookies. **This needs to be changed**. | | SECRET_KEY | ``tralala`` | The secret key used to encrypt the cookies. **This needs to be changed**. |
+----------------------------+---------------------------+----------------------------------------------------------------------------------------+ +----------------------------+---------------------------+----------------------------------------------------------------------------------------+
@ -86,16 +87,14 @@ properly.
| ACTIVATE_DEMO_PROJECT | ``True`` | If set to `True`, a demo project will be available on the frontpage. | | ACTIVATE_DEMO_PROJECT | ``True`` | If set to `True`, a demo project will be available on the frontpage. |
+----------------------------+---------------------------+----------------------------------------------------------------------------------------+ +----------------------------+---------------------------+----------------------------------------------------------------------------------------+
| | ``""`` | If not empty, the specified password must be entered to create new projects. | | | ``""`` | If not empty, the specified password must be entered to create new projects. |
| ADMIN_PASSWORD | | To generate the proper password HASH, use ``./budget/manage.py generate_password_hash``| | ADMIN_PASSWORD | | To generate the proper password HASH, use ``ihatemoney generate_password_hash`` |
| | | and copy its output into the value of *ADMIN_PASSWORD*. | | | | and copy its output into the value of *ADMIN_PASSWORD*. |
+----------------------------+---------------------------+----------------------------------------------------------------------------------------+ +----------------------------+---------------------------+----------------------------------------------------------------------------------------+
.. _`the SQLAlechemy documentation`: http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls
In a production environment In a production environment
--------------------------- ---------------------------
Make a copy of ``budget/default_settings.py`` and name it ``ihatemoney.cfg``. Make a copy of ``ihatemoney/default_settings.py`` and name it ``ihatemoney.cfg``.
Then adjust the settings to your needs and move this file to Then adjust the settings to your needs and move this file to
``/etc/ihatemoney/ihatemoney.cfg``. ``/etc/ihatemoney/ihatemoney.cfg``.

View file

@ -2,8 +2,8 @@
from flask import Blueprint, request from flask import Blueprint, request
from flask_rest import RESTResource, need_auth from flask_rest import RESTResource, need_auth
from .models import db, Project, Person, Bill from ihatemoney.models import db, Project, Person, Bill
from .forms import (ProjectForm, EditProjectForm, MemberForm, from ihatemoney.forms import (ProjectForm, EditProjectForm, MemberForm,
get_billform_for) get_billform_for)

View file

@ -0,0 +1,31 @@
# You can find more information about what these settings mean in the
# documentation, available online at
# http://ihatemoney.readthedocs.io/en/latest/installation.html#configuration
# Turn this on if you want to have more output on what's happening under the
# hood.
DEBUG = False
# The database URI, reprensenting the type of database and how to connect to it.
# Enter an absolute path here.
SQLALCHEMY_DATABASE_URI = 'sqlite://'
SQLACHEMY_ECHO = DEBUG
# Will likely become the default value in flask-sqlalchemy >=3 ; could be removed
# then:
SQLALCHEMY_TRACK_MODIFICATIONS = False
# You need to change this secret key, otherwise bad things might happen to your
# users.
SECRET_KEY = "tralala"
# A python tuple describing the name and email adress of the sender of the mails.
MAIL_DEFAULT_SENDER = ("Budget manager", "budget@notmyidea.org")
# If set to True, a demonstration project will be activated.
ACTIVATE_DEMO_PROJECT = True
# If not empty, the specified password must be entered to create new projects.
# DO NOT enter the password in cleartext. Generate a password hash with
# "ihatemoney generate_password_hash" instead.
ADMIN_PASSWORD = ""

View file

@ -6,12 +6,12 @@ from wtforms.validators import Email, Required, ValidationError
from flask_babel import lazy_gettext as _ from flask_babel import lazy_gettext as _
from flask import request from flask import request
from wtforms.widgets import html_params
from datetime import datetime from datetime import datetime
from jinja2 import Markup from jinja2 import Markup
from .models import Project, Person from ihatemoney.models import Project, Person
from .utils import slugify from ihatemoney.utils import slugify
def get_billform_for(project, set_default=True, **kwargs): def get_billform_for(project, set_default=True, **kwargs):
"""Return an instance of BillForm configured for a particular project. """Return an instance of BillForm configured for a particular project.
@ -21,8 +21,9 @@ def get_billform_for(project, set_default=True, **kwargs):
""" """
form = BillForm(**kwargs) form = BillForm(**kwargs)
form.payed_for.choices = form.payer.choices = [(m.id, m.name) active_members = [(m.id, m.name) for m in project.active_members]
for m in project.active_members]
form.payed_for.choices = form.payer.choices = active_members
form.payed_for.default = [m.id for m in project.active_members] form.payed_for.default = [m.id for m in project.active_members]
if set_default and request.method == "GET": if set_default and request.method == "GET":
@ -31,7 +32,9 @@ def get_billform_for(project, set_default=True, **kwargs):
class CommaDecimalField(DecimalField): class CommaDecimalField(DecimalField):
"""A class to deal with comma in Decimal Field""" """A class to deal with comma in Decimal Field"""
def process_formdata(self, value): def process_formdata(self, value):
if value: if value:
value[0] = str(value[0]).replace(',', '.') value[0] = str(value[0]).replace(',', '.')
@ -70,12 +73,13 @@ class ProjectForm(EditProjectForm):
def validate_id(form, field): def validate_id(form, field):
form.id.data = slugify(field.data) form.id.data = slugify(field.data)
if (form.id.data == "dashboard") or Project.query.get(form.id.data): if (form.id.data == "dashboard") or Project.query.get(form.id.data):
raise ValidationError(Markup(_("The project identifier is used " message = _("The project identifier is used to log in and for the "
"to log in and for the URL of the project. " "URL of the project. "
"We tried to generate an identifier for you but a project " "We tried to generate an identifier for you but a "
"with this identifier already exists. " "project with this identifier already exists. "
"Please create a new identifier " "Please create a new identifier that you will be able "
"that you will be able to remember."))) "to remember")
raise ValidationError(Markup(message))
class AuthenticationForm(FlaskForm): class AuthenticationForm(FlaskForm):
@ -150,7 +154,7 @@ class MemberForm(FlaskForm):
if (not form.edit and Person.query.filter( if (not form.edit and Person.query.filter(
Person.name == field.data, Person.name == field.data,
Person.project == form.project, Person.project == form.project,
Person.activated == True).all()): Person.activated == True).all()): # NOQA
raise ValidationError(_("This project already have this member")) raise ValidationError(_("This project already have this member"))
def save(self, project, person): def save(self, project, person):
@ -179,13 +183,13 @@ class InviteForm(FlaskForm):
class ExportForm(FlaskForm): class ExportForm(FlaskForm):
export_type = SelectField(_("What do you want to download ?"), export_type = SelectField(
_("What do you want to download ?"),
validators=[Required()], validators=[Required()],
coerce=str, coerce=str,
choices=[("bills", _("bills")), ("transactions", _("transactions"))] choices=[("bills", _("bills")), ("transactions", _("transactions"))])
) export_format = SelectField(
export_format = SelectField(_("Export file format"), _("Export file format"),
validators=[Required()], validators=[Required()],
coerce=str, coerce=str,
choices=[("csv", "csv"), ("json", "json")] choices=[("csv", "csv"), ("json", "json")])
)

View file

@ -5,25 +5,26 @@ from flask_script import Manager, Command
from flask_migrate import Migrate, MigrateCommand from flask_migrate import Migrate, MigrateCommand
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
from .run import app from ihatemoney.run import create_app
from .models import db from ihatemoney.models import db
class GeneratePasswordHash(Command): class GeneratePasswordHash(Command):
"Get password from user and hash it without printing it in clear text"
"""Get password from user and hash it without printing it in clear text."""
def run(self): def run(self):
password = getpass(prompt='Password: ') password = getpass(prompt='Password: ')
print(generate_password_hash(password)) print(generate_password_hash(password))
migrate = Migrate(app, db)
def main():
app = create_app()
Migrate(app, db)
manager = Manager(app) manager = Manager(app)
manager.add_command('db', MigrateCommand) manager.add_command('db', MigrateCommand)
manager.add_command('generate_password_hash', GeneratePasswordHash) manager.add_command('generate_password_hash', GeneratePasswordHash)
def main():
manager.run() manager.run()

View file

@ -9,13 +9,12 @@ from sqlalchemy import orm
db = SQLAlchemy() db = SQLAlchemy()
# define models
class Project(db.Model): class Project(db.Model):
_to_serialize = ("id", "name", "password", "contact_email", _to_serialize = (
"members", "active_members", "balance") "id", "name", "password", "contact_email", "members", "active_members",
"balance"
)
id = db.Column(db.String(64), primary_key=True) id = db.Column(db.String(64), primary_key=True)
@ -37,7 +36,8 @@ class Project(db.Model):
# for each person # for each person
for person in self.members: for person in self.members:
# get the list of bills he has to pay # get the list of bills he has to pay
bills = Bill.query.options(orm.subqueryload(Bill.owers)).filter(Bill.owers.contains(person)) bills = Bill.query.options(orm.subqueryload(Bill.owers)).filter(
Bill.owers.contains(person))
for bill in bills.all(): for bill in bills.all():
if person != bill.payer: if person != bill.payer:
share = bill.pay_each() * person.weight share = bill.pay_each() * person.weight
@ -56,6 +56,7 @@ class Project(db.Model):
def get_transactions_to_settle_bill(self, pretty_output=False): def get_transactions_to_settle_bill(self, pretty_output=False):
"""Return a list of transactions that could be made to settle the bill""" """Return a list of transactions that could be made to settle the bill"""
def prettify(transactions, pretty_output): def prettify(transactions, pretty_output):
""" Return pretty transactions """ Return pretty transactions
""" """
@ -63,9 +64,11 @@ class Project(db.Model):
return transactions return transactions
pretty_transactions = [] pretty_transactions = []
for transaction in transactions: for transaction in transactions:
pretty_transactions.append({'ower': transaction['ower'].name, pretty_transactions.append({
'ower': transaction['ower'].name,
'receiver': transaction['receiver'].name, 'receiver': transaction['receiver'].name,
'amount': round(transaction['amount'], 2)}) 'amount': round(transaction['amount'], 2)
})
return pretty_transactions return pretty_transactions
# cache value for better performance # cache value for better performance
@ -77,22 +80,36 @@ class Project(db.Model):
credits.append({"person": person, "balance": balance[person.id]}) credits.append({"person": person, "balance": balance[person.id]})
elif round(balance[person.id], 2) < 0: elif round(balance[person.id], 2) < 0:
debts.append({"person": person, "balance": -balance[person.id]}) debts.append({"person": person, "balance": -balance[person.id]})
# Try and find exact matches # Try and find exact matches
for credit in credits: for credit in credits:
match = self.exactmatch(round(credit["balance"], 2), debts) match = self.exactmatch(round(credit["balance"], 2), debts)
if match: if match:
for m in match: for m in match:
transactions.append({"ower": m["person"], "receiver": credit["person"], "amount": m["balance"]}) transactions.append({
"ower": m["person"],
"receiver": credit["person"],
"amount": m["balance"]
})
debts.remove(m) debts.remove(m)
credits.remove(credit) credits.remove(credit)
# Split any remaining debts & credits # Split any remaining debts & credits
while credits and debts: while credits and debts:
if credits[0]["balance"] > debts[0]["balance"]: if credits[0]["balance"] > debts[0]["balance"]:
transactions.append({"ower": debts[0]["person"], "receiver": credits[0]["person"], "amount": debts[0]["balance"]}) transactions.append({
"ower": debts[0]["person"],
"receiver": credits[0]["person"],
"amount": debts[0]["balance"]
})
credits[0]["balance"] = credits[0]["balance"] - debts[0]["balance"] credits[0]["balance"] = credits[0]["balance"] - debts[0]["balance"]
del debts[0] del debts[0]
else: else:
transactions.append({"ower": debts[0]["person"], "receiver": credits[0]["person"], "amount": credits[0]["balance"]}) transactions.append({
"ower": debts[0]["person"],
"receiver": credits[0]["person"],
"amount": credits[0]["balance"]
})
debts[0]["balance"] = debts[0]["balance"] - credits[0]["balance"] debts[0]["balance"] = debts[0]["balance"] - credits[0]["balance"]
del credits[0] del credits[0]
@ -136,12 +153,15 @@ class Project(db.Model):
owers = [ower.name for ower in bill.owers] owers = [ower.name for ower in bill.owers]
else: else:
owers = ', '.join([ower.name for ower in bill.owers]) owers = ', '.join([ower.name for ower in bill.owers])
pretty_bills.append({"what": bill.what,
pretty_bills.append({
"what": bill.what,
"amount": round(bill.amount, 2), "amount": round(bill.amount, 2),
"date": str(bill.date), "date": str(bill.date),
"payer_name": Person.query.get(bill.payer_id).name, "payer_name": Person.query.get(bill.payer_id).name,
"payer_weight": Person.query.get(bill.payer_id).weight, "payer_weight": Person.query.get(bill.payer_id).weight,
"owers": owers}) "owers": owers
})
return pretty_bills return pretty_bills
def remove_member(self, member_id): def remove_member(self, member_id):
@ -176,6 +196,7 @@ class Project(db.Model):
class Person(db.Model): class Person(db.Model):
class PersonQuery(BaseQuery): class PersonQuery(BaseQuery):
def get_by_name(self, name, project): def get_by_name(self, name, project):
return Person.query.filter(Person.name == name)\ return Person.query.filter(Person.name == name)\
.filter(Project.id == project.id).one() .filter(Project.id == project.id).one()
@ -211,8 +232,10 @@ class Person(db.Model):
def __repr__(self): def __repr__(self):
return "<Person %s for project %s>" % (self.name, self.project.name) return "<Person %s for project %s>" % (self.name, self.project.name)
# We need to manually define a join table for m2m relations # We need to manually define a join table for m2m relations
billowers = db.Table('billowers', billowers = db.Table(
'billowers',
db.Column('bill_id', db.Integer, db.ForeignKey('bill.id')), db.Column('bill_id', db.Integer, db.ForeignKey('bill.id')),
db.Column('person_id', db.Integer, db.ForeignKey('person.id')), db.Column('person_id', db.Integer, db.ForeignKey('person.id')),
) )
@ -224,11 +247,11 @@ class Bill(db.Model):
def get(self, project, id): def get(self, project, id):
try: try:
return self.join(Person, Project)\ return (self.join(Person, Project)
.filter(Bill.payer_id == Person.id)\ .filter(Bill.payer_id == Person.id)
.filter(Person.project_id == Project.id)\ .filter(Person.project_id == Project.id)
.filter(Project.id == project.id)\ .filter(Project.id == project.id)
.filter(Bill.id == id).one() .filter(Bill.id == id).one())
except orm.exc.NoResultFound: except orm.exc.NoResultFound:
return None return None
@ -262,8 +285,10 @@ class Bill(db.Model):
return 0 return 0
def __repr__(self): def __repr__(self):
return "<Bill of %s from %s for %s>" % (self.amount, return "<Bill of %s from %s for %s>" % (
self.payer, ", ".join([o.name for o in self.owers])) self.amount,
self.payer, ", ".join([o.name for o in self.owers])
)
class Archive(db.Model): class Archive(db.Model):

144
ihatemoney/run.py Normal file
View file

@ -0,0 +1,144 @@
import os
import os.path
import warnings
from flask import Flask, g, request, session
from flask_babel import Babel
from flask_mail import Mail
from flask_migrate import Migrate, upgrade, stamp
from raven.contrib.flask import Sentry
from ihatemoney.api import api
from ihatemoney.models import db
from ihatemoney.utils import PrefixedWSGI, minimal_round
from ihatemoney.web import main as web_interface
from ihatemoney import default_settings
def setup_database(app):
"""Prepare the database. Create tables, run migrations etc."""
def _pre_alembic_db():
""" Checks if we are migrating from a pre-alembic ihatemoney
"""
con = db.engine.connect()
tables_exist = db.engine.dialect.has_table(con, 'project')
alembic_setup = db.engine.dialect.has_table(con, 'alembic_version')
return tables_exist and not alembic_setup
db.init_app(app)
db.app = app
Migrate(app, db)
migrations_path = os.path.join(app.root_path, 'migrations')
if _pre_alembic_db():
with app.app_context():
# fake the first migration
stamp(migrations_path, revision='b9a10d5d63ce')
# auto-execute migrations on runtime
with app.app_context():
upgrade(migrations_path)
def load_configuration(app, configuration=None):
""" Find the right configuration file for the application and load it.
By order of preference:
- Use the IHATEMONEY_SETTINGS_FILE_PATH env var if defined ;
- If not, use /etc/ihatemoney/ihatemoney.cfg ;
- Otherwise, load the default settings.
"""
env_var_config = os.environ.get('IHATEMONEY_SETTINGS_FILE_PATH')
app.config.from_object('ihatemoney.default_settings')
if configuration:
app.config.from_object(configuration)
elif env_var_config:
app.config.from_pyfile(env_var_config)
else:
app.config.from_pyfile('ihatemoney.cfg', silent=True)
def validate_configuration(app):
if app.config['SECRET_KEY'] == default_settings.SECRET_KEY:
warnings.warn(
"Running a server without changing the SECRET_KEY can lead to"
+ " user impersonation. Please update your configuration file.",
UserWarning
)
# 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 'MAIL_DEFAULT_SENDER' not in app.config:
app.config['MAIL_DEFAULT_SENDER'] = default_settings.DEFAULT_MAIL_SENDER
if "pbkdf2:sha256:" not in app.config['ADMIN_PASSWORD'] and app.config['ADMIN_PASSWORD']:
# Since 2.0
warnings.warn(
"The way Ihatemoney stores your ADMIN_PASSWORD has changed. You are using an unhashed"
+ " ADMIN_PASSWORD, which is not supported anymore and won't let you access your admin"
+ " endpoints. Please use the command 'ihatemoney generate_password_hash'"
+ " to generate a proper password HASH and copy the output to the value of"
+ " ADMIN_PASSWORD in your settings file.",
UserWarning
)
def create_app(configuration=None, instance_path='/etc/ihatemoney',
instance_relative_config=True):
app = Flask(
__name__,
instance_path=instance_path,
instance_relative_config=instance_relative_config)
# If a configuration object is passed, use it. Otherwise try to find one.
load_configuration(app, configuration)
app.wsgi_app = PrefixedWSGI(app)
validate_configuration(app)
app.register_blueprint(web_interface)
app.register_blueprint(api)
# Configure the application
setup_database(app)
mail = Mail()
mail.init_app(app)
app.mail = mail
# Error reporting
Sentry(app)
# Jinja filters
app.jinja_env.filters['minimal_round'] = minimal_round
# Translations
babel = Babel(app)
@babel.localeselector
def get_locale():
# get the lang from the session if defined, fallback on the browser "accept
# languages" header.
lang = session.get('lang', request.accept_languages.best_match(['fr', 'en']))
setattr(g, 'lang', lang)
return lang
return app
def main():
app = create_app()
app.run(host="0.0.0.0", debug=True)
if __name__ == '__main__':
main()

View file

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

View file

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

View file

Before

Width:  |  Height:  |  Size: 274 B

After

Width:  |  Height:  |  Size: 274 B

View file

Before

Width:  |  Height:  |  Size: 226 B

After

Width:  |  Height:  |  Size: 226 B

View file

Before

Width:  |  Height:  |  Size: 258 B

After

Width:  |  Height:  |  Size: 258 B

View file

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View file

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View file

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View file

Before

Width:  |  Height:  |  Size: 259 B

After

Width:  |  Height:  |  Size: 259 B

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,5 @@
import base64 import base64
import re import re
import inspect
from io import BytesIO, StringIO from io import BytesIO, StringIO
from jinja2 import filters from jinja2 import filters
@ -26,7 +25,9 @@ def slugify(value):
value = six.text_type(re.sub('[^\w\s-]', '', value).strip().lower()) value = six.text_type(re.sub('[^\w\s-]', '', value).strip().lower())
return re.sub('[-\s]+', '-', value) return re.sub('[-\s]+', '-', value)
class Redirect303(HTTPException, RoutingException): class Redirect303(HTTPException, RoutingException):
"""Raise if the map requests a redirect. This is for example the case if """Raise if the map requests a redirect. This is for example the case if
`strict_slashes` are activated and an url that requires a trailing slash. `strict_slashes` are activated and an url that requires a trailing slash.
@ -43,6 +44,7 @@ class Redirect303(HTTPException, RoutingException):
class PrefixedWSGI(object): class PrefixedWSGI(object):
''' '''
Wrap the application in this middleware and configure the Wrap the application in this middleware and configure the
front-end server to add these headers, to let you quietly bind front-end server to add these headers, to let you quietly bind
@ -55,6 +57,7 @@ class PrefixedWSGI(object):
:param app: the WSGI application :param app: the WSGI application
''' '''
def __init__(self, app): def __init__(self, app):
self.app = app self.app = app
self.wsgi_app = app.wsgi_app self.wsgi_app = app.wsgi_app
@ -85,12 +88,14 @@ def minimal_round(*args, **kw):
ires = int(res) ires = int(res)
return (res if res != ires else ires) return (res if res != ires else ires)
def list_of_dicts2json(dict_to_convert): def list_of_dicts2json(dict_to_convert):
"""Take a list of dictionnaries and turns it into """Take a list of dictionnaries and turns it into
a json in-memory file a json in-memory file
""" """
return BytesIO(dumps(dict_to_convert).encode('utf-8')) return BytesIO(dumps(dict_to_convert).encode('utf-8'))
def list_of_dicts2csv(dict_to_convert): def list_of_dicts2csv(dict_to_convert):
"""Take a list of dictionnaries and turns it into """Take a list of dictionnaries and turns it into
a csv in-memory file, assume all dict have the same keys a csv in-memory file, assume all dict have the same keys
@ -110,8 +115,9 @@ def list_of_dicts2csv(dict_to_convert):
csv_data = [] csv_data = []
csv_data.append([key.encode('utf-8') for key in dict_to_convert[0].keys()]) csv_data.append([key.encode('utf-8') for key in dict_to_convert[0].keys()])
for dic in dict_to_convert: for dic in dict_to_convert:
csv_data.append([dic[h].encode('utf8') csv_data.append(
if isinstance(dic[h], unicode) else str(dic[h]).encode('utf8') [dic[h].encode('utf8')
if isinstance(dic[h], unicode) else str(dic[h]).encode('utf8') # NOQA
for h in dict_to_convert[0].keys()]) for h in dict_to_convert[0].keys()])
except (KeyError, IndexError): except (KeyError, IndexError):
csv_data = [] csv_data = []
@ -122,5 +128,6 @@ def list_of_dicts2csv(dict_to_convert):
csv_file = BytesIO(csv_file.getvalue().encode('utf-8')) csv_file = BytesIO(csv_file.getvalue().encode('utf-8'))
return csv_file return csv_file
# base64 encoding that works with both py2 and py3 and yield no warning # base64 encoding that works with both py2 and py3 and yield no warning
base64_encode = base64.encodestring if six.PY2 else base64.encodebytes base64_encode = base64.encodestring if six.PY2 else base64.encodebytes

View file

@ -13,25 +13,23 @@ from flask import (
Blueprint, current_app, flash, g, redirect, render_template, request, Blueprint, current_app, flash, g, redirect, render_template, request,
session, url_for, send_file session, url_for, send_file
) )
from flask_mail import Mail, Message from flask_mail import Message
from flask_babel import get_locale, gettext as _ from flask_babel import get_locale, gettext as _
from werkzeug.security import generate_password_hash, \ from werkzeug.security import check_password_hash
check_password_hash
from smtplib import SMTPRecipientsRefused from smtplib import SMTPRecipientsRefused
import werkzeug import werkzeug
from sqlalchemy import orm from sqlalchemy import orm
from functools import wraps from functools import wraps
from .models import db, Project, Person, Bill from ihatemoney.models import db, Project, Person, Bill
from .forms import ( from ihatemoney.forms import (
AdminAuthenticationForm, AuthenticationForm, EditProjectForm, AdminAuthenticationForm, AuthenticationForm, EditProjectForm,
InviteForm, MemberForm, PasswordReminder, ProjectForm, get_billform_for, InviteForm, MemberForm, PasswordReminder, ProjectForm, get_billform_for,
ExportForm ExportForm
) )
from .utils import Redirect303, list_of_dicts2json, list_of_dicts2csv from ihatemoney.utils import Redirect303, list_of_dicts2json, list_of_dicts2csv
main = Blueprint("main", __name__) main = Blueprint("main", __name__)
mail = Mail()
def requires_admin(f): def requires_admin(f):
@ -205,7 +203,7 @@ def create_project():
body=message_body, body=message_body,
recipients=[project.contact_email]) recipients=[project.contact_email])
try: try:
mail.send(msg) current_app.mail.send(msg)
except SMTPRecipientsRefused: except SMTPRecipientsRefused:
msg_compl = 'Problem sending mail. ' msg_compl = 'Problem sending mail. '
# TODO: destroy the project and cancel instead? # TODO: destroy the project and cancel instead?
@ -230,7 +228,8 @@ def remind_password():
# send the password reminder # send the password reminder
password_reminder = "password_reminder.%s" % get_locale().language password_reminder = "password_reminder.%s" % get_locale().language
mail.send(Message("password recovery", current_app.mail.send(Message(
"password recovery",
body=render_template(password_reminder, project=project), body=render_template(password_reminder, project=project),
recipients=[project.contact_email])) recipients=[project.contact_email]))
flash(_("a mail has been sent to you with the password")) flash(_("a mail has been sent to you with the password"))
@ -337,7 +336,7 @@ def invite():
body=message_body, body=message_body,
recipients=[email.strip() recipients=[email.strip()
for email in form.emails.data.split(",")]) for email in form.emails.data.split(",")])
mail.send(msg) current_app.mail.send(msg)
flash(_("Your invitations have been sent")) flash(_("Your invitations have been sent"))
return redirect(url_for(".list_bills")) return redirect(url_for(".list_bills"))
@ -390,7 +389,7 @@ def reactivate(member_id):
def remove_member(member_id): def remove_member(member_id):
member = g.project.remove_member(member_id) member = g.project.remove_member(member_id)
if member: if member:
if member.activated == False: if not member.activated:
flash(_("User '%(name)s' has been deactivated. It will still " flash(_("User '%(name)s' has been deactivated. It will still "
"appear in the users list until its balance " "appear in the users list until its balance "
"becomes zero.", name=member.name)) "becomes zero.", name=member.name))

3
ihatemoney/wsgi.py Normal file
View file

@ -0,0 +1,3 @@
from ihatemoney.run import create_app
application = create_app()

View file

@ -35,7 +35,10 @@ DEPENDENCY_LINKS = [
ENTRY_POINTS = { ENTRY_POINTS = {
'paste.app_factory': [ 'paste.app_factory': [
'main = budget.run:main', 'main = ihatemoney.run:main',
],
'console_scripts': [
'ihatemoney = ihatemoney.manage:main'
], ],
} }

13
tox.ini
View file

@ -1,12 +1,12 @@
[tox] [tox]
envlist = py35,py34,py27,docs envlist = py35,py34,py27,docs,lint
skip_missing_interpreters = True skip_missing_interpreters = True
[testenv] [testenv]
commands = commands =
python --version python --version
py.test budget/tests/tests.py py.test ihatemoney/tests/tests.py
deps = deps =
-rdev-requirements.txt -rdev-requirements.txt
-rrequirements.txt -rrequirements.txt
@ -17,3 +17,12 @@ install_command = pip install --pre {opts} {packages}
commands = sphinx-build -a -n -b html -d docs/_build/doctrees docs docs/_build/html commands = sphinx-build -a -n -b html -d docs/_build/doctrees docs docs/_build/html
deps = deps =
-rdocs/requirements.txt -rdocs/requirements.txt
[testenv:lint]
commands = flake8 ihatemoney
deps =
-rdev-requirements.txt
[flake8]
exclude = migrations
max_line_length = 100