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.
|
@ -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
|
||||||
|
|
4
Makefile
|
@ -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)
|
||||||
|
|
|
@ -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 = ""
|
|
121
budget/run.py
|
@ -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()
|
|
|
@ -1 +0,0 @@
|
||||||
from run import app as application
|
|
|
@ -1,3 +1,5 @@
|
||||||
zest.releaser
|
zest.releaser
|
||||||
tox
|
tox
|
||||||
pytest
|
pytest
|
||||||
|
Flask-Testing
|
||||||
|
Flake8
|
||||||
|
|
|
@ -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``.
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
31
ihatemoney/default_settings.py
Normal 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 = ""
|
|
@ -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")])
|
||||||
)
|
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
@ -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
|
@ -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()
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
Before Width: | Height: | Size: 274 B After Width: | Height: | Size: 274 B |
Before Width: | Height: | Size: 226 B After Width: | Height: | Size: 226 B |
Before Width: | Height: | Size: 258 B After Width: | Height: | Size: 258 B |
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 259 B After Width: | Height: | Size: 259 B |
|
@ -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
|
|
@ -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
|
@ -0,0 +1,3 @@
|
||||||
|
from ihatemoney.run import create_app
|
||||||
|
|
||||||
|
application = create_app()
|
5
setup.py
|
@ -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
|
@ -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
|
||||||
|
|