diff options
| author | Alexis Metaireau <alexis@notmyidea.org> | 2017-07-07 00:06:56 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2017-07-07 00:06:56 +0200 |
| commit | 3a4282fd75e3b3317b2b08b4aa2e6ac154310e73 (patch) | |
| tree | 9470c907ba1f884246af87d26d55c3aaac6d6dc5 | |
| parent | 0e374cd5e0ef5a9be67084365f91de2ab84f636c (diff) | |
| download | ihatemoney-mirror-3a4282fd75e3b3317b2b08b4aa2e6ac154310e73.zip ihatemoney-mirror-3a4282fd75e3b3317b2b08b4aa2e6ac154310e73.tar.gz ihatemoney-mirror-3a4282fd75e3b3317b2b08b4aa2e6ac154310e73.tar.bz2 | |
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.
| -rw-r--r-- | MANIFEST.in | 2 | ||||
| -rw-r--r-- | Makefile | 4 | ||||
| -rw-r--r-- | budget/default_settings.py | 14 | ||||
| -rw-r--r-- | budget/run.py | 121 | ||||
| -rw-r--r-- | budget/wsgi.py | 1 | ||||
| -rw-r--r-- | dev-requirements.txt | 2 | ||||
| -rw-r--r-- | docs/installation.rst | 11 | ||||
| -rw-r--r-- | ihatemoney/__init__.py (renamed from budget/__init__.py) | 0 | ||||
| -rw-r--r-- | ihatemoney/api.py (renamed from budget/api.py) | 6 | ||||
| -rw-r--r-- | ihatemoney/babel.cfg (renamed from budget/babel.cfg) | 0 | ||||
| -rw-r--r-- | ihatemoney/default_settings.py | 31 | ||||
| -rw-r--r-- | ihatemoney/forms.py (renamed from budget/forms.py) | 58 | ||||
| -rwxr-xr-x | ihatemoney/manage.py (renamed from budget/manage.py) | 19 | ||||
| -rw-r--r-- | ihatemoney/messages.pot (renamed from budget/messages.pot) | 0 | ||||
| -rwxr-xr-x | ihatemoney/migrations/README (renamed from budget/migrations/README) | 0 | ||||
| -rw-r--r-- | ihatemoney/migrations/alembic.ini (renamed from budget/migrations/alembic.ini) | 0 | ||||
| -rwxr-xr-x | ihatemoney/migrations/env.py (renamed from budget/migrations/env.py) | 0 | ||||
| -rwxr-xr-x | ihatemoney/migrations/script.py.mako (renamed from budget/migrations/script.py.mako) | 0 | ||||
| -rw-r--r-- | ihatemoney/migrations/versions/26d6a218c329_.py (renamed from budget/migrations/versions/26d6a218c329_.py) | 0 | ||||
| -rw-r--r-- | ihatemoney/migrations/versions/b9a10d5d63ce_.py (renamed from budget/migrations/versions/b9a10d5d63ce_.py) | 0 | ||||
| -rw-r--r-- | ihatemoney/migrations/versions/f629c8ef4ab0_initialize_all_members_weights_to_1.py (renamed from budget/migrations/versions/f629c8ef4ab0_initialize_all_members_weights_to_1.py) | 0 | ||||
| -rw-r--r-- | ihatemoney/models.py (renamed from budget/models.py) | 85 | ||||
| -rw-r--r-- | ihatemoney/run.py | 144 | ||||
| -rw-r--r-- | ihatemoney/static/css/bootstrap-datepicker3.standalone.css (renamed from budget/static/css/bootstrap-datepicker3.standalone.css) | 0 | ||||
| -rw-r--r-- | ihatemoney/static/css/bootstrap.min.css (renamed from budget/static/css/bootstrap.min.css) | 0 | ||||
| -rw-r--r-- | ihatemoney/static/css/main.css (renamed from budget/static/css/main.css) | 0 | ||||
| -rw-r--r-- | ihatemoney/static/fonts/OFL.txt (renamed from budget/static/fonts/OFL.txt) | 0 | ||||
| -rw-r--r-- | ihatemoney/static/fonts/comfortaa-regular-webfont.eot (renamed from budget/static/fonts/comfortaa-regular-webfont.eot) | bin | 40370 -> 40370 bytes | |||
| -rw-r--r-- | ihatemoney/static/fonts/comfortaa-regular-webfont.svg (renamed from budget/static/fonts/comfortaa-regular-webfont.svg) | 0 | ||||
| -rw-r--r-- | ihatemoney/static/fonts/comfortaa-regular-webfont.woff (renamed from budget/static/fonts/comfortaa-regular-webfont.woff) | bin | 20920 -> 20920 bytes | |||
| -rw-r--r-- | ihatemoney/static/fonts/fontfaces.css (renamed from budget/static/fonts/fontfaces.css) | 0 | ||||
| -rw-r--r-- | ihatemoney/static/fonts/lobster-webfont.eot (renamed from budget/static/fonts/lobster-webfont.eot) | bin | 63744 -> 63744 bytes | |||
| -rw-r--r-- | ihatemoney/static/fonts/lobster-webfont.svg (renamed from budget/static/fonts/lobster-webfont.svg) | 0 | ||||
| -rw-r--r-- | ihatemoney/static/fonts/lobster-webfont.woff (renamed from budget/static/fonts/lobster-webfont.woff) | bin | 33380 -> 33380 bytes | |||
| -rw-r--r-- | ihatemoney/static/images/delete.png (renamed from budget/static/images/delete.png) | bin | 274 -> 274 bytes | |||
| -rw-r--r-- | ihatemoney/static/images/deleter.png (renamed from budget/static/images/deleter.png) | bin | 226 -> 226 bytes | |||
| -rw-r--r-- | ihatemoney/static/images/edit.png (renamed from budget/static/images/edit.png) | bin | 258 -> 258 bytes | |||
| -rw-r--r-- | ihatemoney/static/images/glyphicons-halflings-white.png (renamed from budget/static/images/glyphicons-halflings-white.png) | bin | 4352 -> 4352 bytes | |||
| -rw-r--r-- | ihatemoney/static/images/glyphicons-halflings.png (renamed from budget/static/images/glyphicons-halflings.png) | bin | 4352 -> 4352 bytes | |||
| -rw-r--r-- | ihatemoney/static/images/gradient.png (renamed from budget/static/images/gradient.png) | bin | 24656 -> 24656 bytes | |||
| -rw-r--r-- | ihatemoney/static/images/reactivate.png (renamed from budget/static/images/reactivate.png) | bin | 259 -> 259 bytes | |||
| -rw-r--r-- | ihatemoney/static/js/bootstrap-datepicker.js (renamed from budget/static/js/bootstrap-datepicker.js) | 0 | ||||
| -rw-r--r-- | ihatemoney/static/js/bootstrap.min.js (renamed from budget/static/js/bootstrap.min.js) | 0 | ||||
| -rw-r--r-- | ihatemoney/static/js/ihatemoney.js (renamed from budget/static/js/ihatemoney.js) | 0 | ||||
| -rw-r--r-- | ihatemoney/static/js/jquery-3.1.1.min.js (renamed from budget/static/js/jquery-3.1.1.min.js) | 0 | ||||
| -rw-r--r-- | ihatemoney/static/js/locales/bootstrap-datepicker.fr.min.js (renamed from budget/static/js/locales/bootstrap-datepicker.fr.min.js) | 0 | ||||
| -rw-r--r-- | ihatemoney/static/js/tether.min.js (renamed from budget/static/js/tether.min.js) | 0 | ||||
| -rw-r--r-- | ihatemoney/templates/add_bill.html (renamed from budget/templates/add_bill.html) | 0 | ||||
| -rw-r--r-- | ihatemoney/templates/add_member.html (renamed from budget/templates/add_member.html) | 0 | ||||
| -rw-r--r-- | ihatemoney/templates/authenticate.html (renamed from budget/templates/authenticate.html) | 0 | ||||
| -rw-r--r-- | ihatemoney/templates/create_project.html (renamed from budget/templates/create_project.html) | 0 | ||||
| -rw-r--r-- | ihatemoney/templates/dashboard.html (renamed from budget/templates/dashboard.html) | 0 | ||||
| -rw-r--r-- | ihatemoney/templates/debug.html (renamed from budget/templates/debug.html) | 0 | ||||
| -rw-r--r-- | ihatemoney/templates/display_errors.html (renamed from budget/templates/display_errors.html) | 0 | ||||
| -rw-r--r-- | ihatemoney/templates/edit_member.html (renamed from budget/templates/edit_member.html) | 0 | ||||
| -rw-r--r-- | ihatemoney/templates/edit_project.html (renamed from budget/templates/edit_project.html) | 0 | ||||
| -rw-r--r-- | ihatemoney/templates/forms.html (renamed from budget/templates/forms.html) | 0 | ||||
| -rw-r--r-- | ihatemoney/templates/home.html (renamed from budget/templates/home.html) | 0 | ||||
| -rw-r--r-- | ihatemoney/templates/invitation_mail.en (renamed from budget/templates/invitation_mail.en) | 0 | ||||
| -rw-r--r-- | ihatemoney/templates/invitation_mail.fr (renamed from budget/templates/invitation_mail.fr) | 0 | ||||
| -rw-r--r-- | ihatemoney/templates/layout.html (renamed from budget/templates/layout.html) | 0 | ||||
| -rw-r--r-- | ihatemoney/templates/list_bills.html (renamed from budget/templates/list_bills.html) | 0 | ||||
| -rw-r--r-- | ihatemoney/templates/password_reminder.en (renamed from budget/templates/password_reminder.en) | 0 | ||||
| -rw-r--r-- | ihatemoney/templates/password_reminder.fr (renamed from budget/templates/password_reminder.fr) | 0 | ||||
| -rw-r--r-- | ihatemoney/templates/password_reminder.html (renamed from budget/templates/password_reminder.html) | 0 | ||||
| -rw-r--r-- | ihatemoney/templates/recent_projects.html (renamed from budget/templates/recent_projects.html) | 0 | ||||
| -rw-r--r-- | ihatemoney/templates/reminder_mail.en (renamed from budget/templates/reminder_mail.en) | 0 | ||||
| -rw-r--r-- | ihatemoney/templates/reminder_mail.fr (renamed from budget/templates/reminder_mail.fr) | 0 | ||||
| -rw-r--r-- | ihatemoney/templates/send_invites.html (renamed from budget/templates/send_invites.html) | 0 | ||||
| -rw-r--r-- | ihatemoney/templates/settle_bills.html (renamed from budget/templates/settle_bills.html) | 0 | ||||
| -rw-r--r-- | ihatemoney/templates/sidebar_table_layout.html (renamed from budget/templates/sidebar_table_layout.html) | 0 | ||||
| -rw-r--r-- | ihatemoney/tests/__init__.py (renamed from budget/tests/__init__.py) | 0 | ||||
| -rw-r--r-- | ihatemoney/tests/ihatemoney.cfg (renamed from budget/tests/ihatemoney.cfg) | 0 | ||||
| -rw-r--r-- | ihatemoney/tests/ihatemoney_envvar.cfg (renamed from budget/tests/ihatemoney_envvar.cfg) | 0 | ||||
| -rw-r--r-- | ihatemoney/tests/tests.py (renamed from budget/tests/tests.py) | 536 | ||||
| -rw-r--r-- | ihatemoney/translations/fr/LC_MESSAGES/messages.mo (renamed from budget/translations/fr/LC_MESSAGES/messages.mo) | bin | 8425 -> 8425 bytes | |||
| -rw-r--r-- | ihatemoney/translations/fr/LC_MESSAGES/messages.po (renamed from budget/translations/fr/LC_MESSAGES/messages.po) | 0 | ||||
| -rw-r--r-- | ihatemoney/utils.py (renamed from budget/utils.py) | 17 | ||||
| -rw-r--r-- | ihatemoney/web.py (renamed from budget/web.py) | 65 | ||||
| -rw-r--r-- | ihatemoney/wsgi.py | 3 | ||||
| -rw-r--r-- | setup.py | 5 | ||||
| -rw-r--r-- | tox.ini | 13 |
82 files changed, 613 insertions, 524 deletions
diff --git a/MANIFEST.in b/MANIFEST.in index ba99e2a..9ba34ba 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,3 @@ 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 @@ -29,7 +29,7 @@ remove-install-stamp: update: remove-install-stamp install serve: install - $(PYTHON) -m budget.manage runserver + $(PYTHON) -m ihatemoney.manage runserver test: $(DEV_STAMP) $(VENV)/bin/tox @@ -38,7 +38,7 @@ release: $(DEV_STAMP) $(VENV)/bin/fullrelease build-translations: - $(VENV)/bin/pybabel compile -d budget/translations + $(VENV)/bin/pybabel compile -d ihatemoney/translations build-requirements: $(VIRTUALENV) $(TEMPDIR) diff --git a/budget/default_settings.py b/budget/default_settings.py deleted file mode 100644 index 15fe9cd..0000000 --- a/budget/default_settings.py +++ /dev/null @@ -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 = "" diff --git a/budget/run.py b/budget/run.py deleted file mode 100644 index 5e65c90..0000000 --- a/budget/run.py +++ /dev/null @@ -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() diff --git a/budget/wsgi.py b/budget/wsgi.py deleted file mode 100644 index 66f7a73..0000000 --- a/budget/wsgi.py +++ /dev/null @@ -1 +0,0 @@ -from run import app as application diff --git a/dev-requirements.txt b/dev-requirements.txt index 8795457..04358ae 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,3 +1,5 @@ zest.releaser tox pytest +Flask-Testing +Flake8 diff --git a/docs/installation.rst b/docs/installation.rst index 3cd143d..d1fd9bc 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -29,7 +29,7 @@ dependencies yourself (that's what the `make serve` does). That would be:: And then run the application:: - cd budget + cd ihatemoney python run.py 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? | +============================+===========================+========================================================================================+ | 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**. | +----------------------------+---------------------------+----------------------------------------------------------------------------------------+ @@ -86,16 +87,14 @@ properly. | 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. | -| 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*. | +----------------------------+---------------------------+----------------------------------------------------------------------------------------+ -.. _`the SQLAlechemy documentation`: http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls - 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 ``/etc/ihatemoney/ihatemoney.cfg``. diff --git a/budget/__init__.py b/ihatemoney/__init__.py index e69de29..e69de29 100644 --- a/budget/__init__.py +++ b/ihatemoney/__init__.py diff --git a/budget/api.py b/ihatemoney/api.py index 7ce6a34..a34fa12 100644 --- a/budget/api.py +++ b/ihatemoney/api.py @@ -2,9 +2,9 @@ from flask import Blueprint, request from flask_rest import RESTResource, need_auth -from .models import db, Project, Person, Bill -from .forms import (ProjectForm, EditProjectForm, MemberForm, - get_billform_for) +from ihatemoney.models import db, Project, Person, Bill +from ihatemoney.forms import (ProjectForm, EditProjectForm, MemberForm, + get_billform_for) api = Blueprint("api", __name__, url_prefix="/api") diff --git a/budget/babel.cfg b/ihatemoney/babel.cfg index f0234b3..f0234b3 100644 --- a/budget/babel.cfg +++ b/ihatemoney/babel.cfg diff --git a/ihatemoney/default_settings.py b/ihatemoney/default_settings.py new file mode 100644 index 0000000..4ca11ca --- /dev/null +++ b/ihatemoney/default_settings.py @@ -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 = "" diff --git a/budget/forms.py b/ihatemoney/forms.py index bd7fc5b..ead5586 100644 --- a/budget/forms.py +++ b/ihatemoney/forms.py @@ -6,12 +6,12 @@ from wtforms.validators import Email, Required, ValidationError from flask_babel import lazy_gettext as _ from flask import request -from wtforms.widgets import html_params from datetime import datetime from jinja2 import Markup -from .models import Project, Person -from .utils import slugify +from ihatemoney.models import Project, Person +from ihatemoney.utils import slugify + def get_billform_for(project, set_default=True, **kwargs): """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.payed_for.choices = form.payer.choices = [(m.id, m.name) - for m in project.active_members] + active_members = [(m.id, m.name) 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] if set_default and request.method == "GET": @@ -31,7 +32,9 @@ def get_billform_for(project, set_default=True, **kwargs): class CommaDecimalField(DecimalField): + """A class to deal with comma in Decimal Field""" + def process_formdata(self, value): if value: value[0] = str(value[0]).replace(',', '.') @@ -49,8 +52,8 @@ class EditProjectForm(FlaskForm): Returns the created instance """ project = Project(name=self.name.data, id=self.id.data, - password=self.password.data, - contact_email=self.contact_email.data) + password=self.password.data, + contact_email=self.contact_email.data) return project def update(self, project): @@ -70,12 +73,13 @@ class ProjectForm(EditProjectForm): def validate_id(form, field): form.id.data = slugify(field.data) if (form.id.data == "dashboard") or Project.query.get(form.id.data): - raise ValidationError(Markup(_("The project identifier is used " - "to log in and for the URL of the project. " - "We tried to generate an identifier for you but a project " - "with this identifier already exists. " - "Please create a new identifier " - "that you will be able to remember."))) + message = _("The project identifier is used to log in and for the " + "URL of the project. " + "We tried to generate an identifier for you but a " + "project with this identifier already exists. " + "Please create a new identifier that you will be able " + "to remember") + raise ValidationError(Markup(message)) class AuthenticationForm(FlaskForm): @@ -104,7 +108,7 @@ class BillForm(FlaskForm): payer = SelectField(_("Payer"), validators=[Required()], coerce=int) amount = CommaDecimalField(_("Amount paid"), validators=[Required()]) payed_for = SelectMultipleField(_("For whom?"), - validators=[Required()], coerce=int) + validators=[Required()], coerce=int) submit = SubmitField(_("Submit")) submit2 = SubmitField(_("Submit and add a new one")) @@ -114,7 +118,7 @@ class BillForm(FlaskForm): bill.what = self.what.data bill.date = self.date.data bill.owers = [Person.query.get(ower, project) - for ower in self.payed_for.data] + for ower in self.payed_for.data] return bill @@ -150,7 +154,7 @@ class MemberForm(FlaskForm): if (not form.edit and Person.query.filter( Person.name == field.data, Person.project == form.project, - Person.activated == True).all()): + Person.activated == True).all()): # NOQA raise ValidationError(_("This project already have this member")) def save(self, project, person): @@ -175,17 +179,17 @@ class InviteForm(FlaskForm): for email in [email.strip() for email in form.emails.data.split(",")]: if not validator.regex.match(email): raise ValidationError(_("The email %(email)s is not valid", - email=email)) + email=email)) class ExportForm(FlaskForm): - export_type = SelectField(_("What do you want to download ?"), - validators=[Required()], - coerce=str, - choices=[("bills", _("bills")), ("transactions", _("transactions"))] - ) - export_format = SelectField(_("Export file format"), - validators=[Required()], - coerce=str, - choices=[("csv", "csv"), ("json", "json")] - ) + export_type = SelectField( + _("What do you want to download ?"), + validators=[Required()], + coerce=str, + choices=[("bills", _("bills")), ("transactions", _("transactions"))]) + export_format = SelectField( + _("Export file format"), + validators=[Required()], + coerce=str, + choices=[("csv", "csv"), ("json", "json")]) diff --git a/budget/manage.py b/ihatemoney/manage.py index 0a66284..6f63a98 100755 --- a/budget/manage.py +++ b/ihatemoney/manage.py @@ -5,25 +5,26 @@ from flask_script import Manager, Command from flask_migrate import Migrate, MigrateCommand from werkzeug.security import generate_password_hash -from .run import app -from .models import db +from ihatemoney.run import create_app +from ihatemoney.models import db 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): password = getpass(prompt='Password: ') print(generate_password_hash(password)) -migrate = Migrate(app, db) - -manager = Manager(app) -manager.add_command('db', MigrateCommand) -manager.add_command('generate_password_hash', GeneratePasswordHash) - def main(): + app = create_app() + Migrate(app, db) + + manager = Manager(app) + manager.add_command('db', MigrateCommand) + manager.add_command('generate_password_hash', GeneratePasswordHash) manager.run() diff --git a/budget/messages.pot b/ihatemoney/messages.pot index 0b1759b..0b1759b 100644 --- a/budget/messages.pot +++ b/ihatemoney/messages.pot diff --git a/budget/migrations/README b/ihatemoney/migrations/README index 98e4f9c..98e4f9c 100755 --- a/budget/migrations/README +++ b/ihatemoney/migrations/README diff --git a/budget/migrations/alembic.ini b/ihatemoney/migrations/alembic.ini index f8ed480..f8ed480 100644 --- a/budget/migrations/alembic.ini +++ b/ihatemoney/migrations/alembic.ini diff --git a/budget/migrations/env.py b/ihatemoney/migrations/env.py index e2f9a28..e2f9a28 100755 --- a/budget/migrations/env.py +++ b/ihatemoney/migrations/env.py diff --git a/budget/migrations/script.py.mako b/ihatemoney/migrations/script.py.mako index 9570201..9570201 100755 --- a/budget/migrations/script.py.mako +++ b/ihatemoney/migrations/script.py.mako diff --git a/budget/migrations/versions/26d6a218c329_.py b/ihatemoney/migrations/versions/26d6a218c329_.py index 859b9af..859b9af 100644 --- a/budget/migrations/versions/26d6a218c329_.py +++ b/ihatemoney/migrations/versions/26d6a218c329_.py diff --git a/budget/migrations/versions/b9a10d5d63ce_.py b/ihatemoney/migrations/versions/b9a10d5d63ce_.py index 92bb446..92bb446 100644 --- a/budget/migrations/versions/b9a10d5d63ce_.py +++ b/ihatemoney/migrations/versions/b9a10d5d63ce_.py diff --git a/budget/migrations/versions/f629c8ef4ab0_initialize_all_members_weights_to_1.py b/ihatemoney/migrations/versions/f629c8ef4ab0_initialize_all_members_weights_to_1.py index 5542146..5542146 100644 --- a/budget/migrations/versions/f629c8ef4ab0_initialize_all_members_weights_to_1.py +++ b/ihatemoney/migrations/versions/f629c8ef4ab0_initialize_all_members_weights_to_1.py diff --git a/budget/models.py b/ihatemoney/models.py index 1da4489..6c71a57 100644 --- a/budget/models.py +++ b/ihatemoney/models.py @@ -9,13 +9,12 @@ from sqlalchemy import orm db = SQLAlchemy() -# define models - - class Project(db.Model): - _to_serialize = ("id", "name", "password", "contact_email", - "members", "active_members", "balance") + _to_serialize = ( + "id", "name", "password", "contact_email", "members", "active_members", + "balance" + ) id = db.Column(db.String(64), primary_key=True) @@ -32,12 +31,13 @@ class Project(db.Model): def balance(self): balances, should_pay, should_receive = (defaultdict(int) - for time in (1, 2, 3)) + for time in (1, 2, 3)) # for each person for person in self.members: # 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(): if person != bill.payer: share = bill.pay_each() * person.weight @@ -56,6 +56,7 @@ class Project(db.Model): def get_transactions_to_settle_bill(self, pretty_output=False): """Return a list of transactions that could be made to settle the bill""" + def prettify(transactions, pretty_output): """ Return pretty transactions """ @@ -63,36 +64,52 @@ class Project(db.Model): return transactions pretty_transactions = [] for transaction in transactions: - pretty_transactions.append({'ower': transaction['ower'].name, - 'receiver': transaction['receiver'].name, - 'amount': round(transaction['amount'], 2)}) + pretty_transactions.append({ + 'ower': transaction['ower'].name, + 'receiver': transaction['receiver'].name, + 'amount': round(transaction['amount'], 2) + }) return pretty_transactions - #cache value for better performance + # cache value for better performance balance = self.balance - credits, debts, transactions = [],[],[] + credits, debts, transactions = [], [], [] # Create lists of credits and debts for person in self.members: if round(balance[person.id], 2) > 0: credits.append({"person": person, "balance": balance[person.id]}) elif round(balance[person.id], 2) < 0: debts.append({"person": person, "balance": -balance[person.id]}) + # Try and find exact matches for credit in credits: 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"]}) + transactions.append({ + "ower": m["person"], + "receiver": credit["person"], + "amount": m["balance"] + }) debts.remove(m) credits.remove(credit) # Split any remaining debts & credits while credits and debts: + 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"] del debts[0] 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"] del credits[0] @@ -107,7 +124,7 @@ class Project(db.Model): elif debts[0]["balance"] == credit: return [debts[0]] else: - match = self.exactmatch(credit-debts[0]["balance"], debts[1:]) + match = self.exactmatch(credit - debts[0]["balance"], debts[1:]) if match: match.append(debts[0]) else: @@ -136,12 +153,15 @@ class Project(db.Model): owers = [ower.name for ower in bill.owers] else: owers = ', '.join([ower.name for ower in bill.owers]) - pretty_bills.append({"what": bill.what, - "amount": round(bill.amount, 2), - "date": str(bill.date), - "payer_name": Person.query.get(bill.payer_id).name, - "payer_weight": Person.query.get(bill.payer_id).weight, - "owers": owers}) + + pretty_bills.append({ + "what": bill.what, + "amount": round(bill.amount, 2), + "date": str(bill.date), + "payer_name": Person.query.get(bill.payer_id).name, + "payer_weight": Person.query.get(bill.payer_id).weight, + "owers": owers + }) return pretty_bills def remove_member(self, member_id): @@ -176,6 +196,7 @@ class Project(db.Model): class Person(db.Model): class PersonQuery(BaseQuery): + def get_by_name(self, name, project): return Person.query.filter(Person.name == name)\ .filter(Project.id == project.id).one() @@ -211,8 +232,10 @@ class Person(db.Model): def __repr__(self): return "<Person %s for project %s>" % (self.name, self.project.name) + # 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('person_id', db.Integer, db.ForeignKey('person.id')), ) @@ -224,11 +247,11 @@ class Bill(db.Model): def get(self, project, id): try: - return self.join(Person, Project)\ - .filter(Bill.payer_id == Person.id)\ - .filter(Person.project_id == Project.id)\ - .filter(Project.id == project.id)\ - .filter(Bill.id == id).one() + return (self.join(Person, Project) + .filter(Bill.payer_id == Person.id) + .filter(Person.project_id == Project.id) + .filter(Project.id == project.id) + .filter(Bill.id == id).one()) except orm.exc.NoResultFound: return None @@ -262,8 +285,10 @@ class Bill(db.Model): return 0 def __repr__(self): - return "<Bill of %s from %s for %s>" % (self.amount, - self.payer, ", ".join([o.name for o in self.owers])) + return "<Bill of %s from %s for %s>" % ( + self.amount, + self.payer, ", ".join([o.name for o in self.owers]) + ) class Archive(db.Model): diff --git a/ihatemoney/run.py b/ihatemoney/run.py new file mode 100644 index 0000000..22cf235 --- /dev/null +++ b/ihatemoney/run.py @@ -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() diff --git a/budget/static/css/bootstrap-datepicker3.standalone.css b/ihatemoney/static/css/bootstrap-datepicker3.standalone.css index b61742e..b61742e 100644 --- a/budget/static/css/bootstrap-datepicker3.standalone.css +++ b/ihatemoney/static/css/bootstrap-datepicker3.standalone.css diff --git a/budget/static/css/bootstrap.min.css b/ihatemoney/static/css/bootstrap.min.css index a8da074..a8da074 100644 --- a/budget/static/css/bootstrap.min.css +++ b/ihatemoney/static/css/bootstrap.min.css diff --git a/budget/static/css/main.css b/ihatemoney/static/css/main.css index 54a0008..54a0008 100644 --- a/budget/static/css/main.css +++ b/ihatemoney/static/css/main.css diff --git a/budget/static/fonts/OFL.txt b/ihatemoney/static/fonts/OFL.txt index 6e1e20d..6e1e20d 100644 --- a/budget/static/fonts/OFL.txt +++ b/ihatemoney/static/fonts/OFL.txt diff --git a/budget/static/fonts/comfortaa-regular-webfont.eot b/ihatemoney/static/fonts/comfortaa-regular-webfont.eot Binary files differindex 41f9d83..41f9d83 100644 --- a/budget/static/fonts/comfortaa-regular-webfont.eot +++ b/ihatemoney/static/fonts/comfortaa-regular-webfont.eot diff --git a/budget/static/fonts/comfortaa-regular-webfont.svg b/ihatemoney/static/fonts/comfortaa-regular-webfont.svg index 518873c..518873c 100644 --- a/budget/static/fonts/comfortaa-regular-webfont.svg +++ b/ihatemoney/static/fonts/comfortaa-regular-webfont.svg diff --git a/budget/static/fonts/comfortaa-regular-webfont.woff b/ihatemoney/static/fonts/comfortaa-regular-webfont.woff Binary files differindex 10f74d0..10f74d0 100644 --- a/budget/static/fonts/comfortaa-regular-webfont.woff +++ b/ihatemoney/static/fonts/comfortaa-regular-webfont.woff diff --git a/budget/static/fonts/fontfaces.css b/ihatemoney/static/fonts/fontfaces.css index c872f38..c872f38 100644 --- a/budget/static/fonts/fontfaces.css +++ b/ihatemoney/static/fonts/fontfaces.css diff --git a/budget/static/fonts/lobster-webfont.eot b/ihatemoney/static/fonts/lobster-webfont.eot Binary files differindex d2257df..d2257df 100644 --- a/budget/static/fonts/lobster-webfont.eot +++ b/ihatemoney/static/fonts/lobster-webfont.eot diff --git a/budget/static/fonts/lobster-webfont.svg b/ihatemoney/static/fonts/lobster-webfont.svg index a9f510f..a9f510f 100644 --- a/budget/static/fonts/lobster-webfont.svg +++ b/ihatemoney/static/fonts/lobster-webfont.svg diff --git a/budget/static/fonts/lobster-webfont.woff b/ihatemoney/static/fonts/lobster-webfont.woff Binary files differindex bf39d59..bf39d59 100644 --- a/budget/static/fonts/lobster-webfont.woff +++ b/ihatemoney/static/fonts/lobster-webfont.woff diff --git a/budget/static/images/delete.png b/ihatemoney/static/images/delete.png Binary files differindex aa786a3..aa786a3 100644 --- a/budget/static/images/delete.png +++ b/ihatemoney/static/images/delete.png diff --git a/budget/static/images/deleter.png b/ihatemoney/static/images/deleter.png Binary files differindex 04a23f3..04a23f3 100644 --- a/budget/static/images/deleter.png +++ b/ihatemoney/static/images/deleter.png diff --git a/budget/static/images/edit.png b/ihatemoney/static/images/edit.png Binary files differindex 02662fc..02662fc 100644 --- a/budget/static/images/edit.png +++ b/ihatemoney/static/images/edit.png diff --git a/budget/static/images/glyphicons-halflings-white.png b/ihatemoney/static/images/glyphicons-halflings-white.png Binary files differindex a20760b..a20760b 100644 --- a/budget/static/images/glyphicons-halflings-white.png +++ b/ihatemoney/static/images/glyphicons-halflings-white.png diff --git a/budget/static/images/glyphicons-halflings.png b/ihatemoney/static/images/glyphicons-halflings.png Binary files differindex 92d4445..92d4445 100644 --- a/budget/static/images/glyphicons-halflings.png +++ b/ihatemoney/static/images/glyphicons-halflings.png diff --git a/budget/static/images/gradient.png b/ihatemoney/static/images/gradient.png Binary files differindex ad148bf..ad148bf 100644 --- a/budget/static/images/gradient.png +++ b/ihatemoney/static/images/gradient.png diff --git a/budget/static/images/reactivate.png b/ihatemoney/static/images/reactivate.png Binary files differindex 54c60c0..54c60c0 100644 --- a/budget/static/images/reactivate.png +++ b/ihatemoney/static/images/reactivate.png diff --git a/budget/static/js/bootstrap-datepicker.js b/ihatemoney/static/js/bootstrap-datepicker.js index 76a99fc..76a99fc 100644 --- a/budget/static/js/bootstrap-datepicker.js +++ b/ihatemoney/static/js/bootstrap-datepicker.js diff --git a/budget/static/js/bootstrap.min.js b/ihatemoney/static/js/bootstrap.min.js index d9c72df..d9c72df 100644 --- a/budget/static/js/bootstrap.min.js +++ b/ihatemoney/static/js/bootstrap.min.js diff --git a/budget/static/js/ihatemoney.js b/ihatemoney/static/js/ihatemoney.js index 24e82b7..24e82b7 100644 --- a/budget/static/js/ihatemoney.js +++ b/ihatemoney/static/js/ihatemoney.js diff --git a/budget/static/js/jquery-3.1.1.min.js b/ihatemoney/static/js/jquery-3.1.1.min.js index 4c5be4c..4c5be4c 100644 --- a/budget/static/js/jquery-3.1.1.min.js +++ b/ihatemoney/static/js/jquery-3.1.1.min.js diff --git a/budget/static/js/locales/bootstrap-datepicker.fr.min.js b/ihatemoney/static/js/locales/bootstrap-datepicker.fr.min.js index 88379c3..88379c3 100644 --- a/budget/static/js/locales/bootstrap-datepicker.fr.min.js +++ b/ihatemoney/static/js/locales/bootstrap-datepicker.fr.min.js diff --git a/budget/static/js/tether.min.js b/ihatemoney/static/js/tether.min.js index d16b9b1..d16b9b1 100644 --- a/budget/static/js/tether.min.js +++ b/ihatemoney/static/js/tether.min.js diff --git a/budget/templates/add_bill.html b/ihatemoney/templates/add_bill.html index 595f363..595f363 100644 --- a/budget/templates/add_bill.html +++ b/ihatemoney/templates/add_bill.html diff --git a/budget/templates/add_member.html b/ihatemoney/templates/add_member.html index 8ddfd52..8ddfd52 100644 --- a/budget/templates/add_member.html +++ b/ihatemoney/templates/add_member.html diff --git a/budget/templates/authenticate.html b/ihatemoney/templates/authenticate.html index f241c48..f241c48 100644 --- a/budget/templates/authenticate.html +++ b/ihatemoney/templates/authenticate.html diff --git a/budget/templates/create_project.html b/ihatemoney/templates/create_project.html index 9d4fde9..9d4fde9 100644 --- a/budget/templates/create_project.html +++ b/ihatemoney/templates/create_project.html diff --git a/budget/templates/dashboard.html b/ihatemoney/templates/dashboard.html index 3f50915..3f50915 100644 --- a/budget/templates/dashboard.html +++ b/ihatemoney/templates/dashboard.html diff --git a/budget/templates/debug.html b/ihatemoney/templates/debug.html index 6f97667..6f97667 100644 --- a/budget/templates/debug.html +++ b/ihatemoney/templates/debug.html diff --git a/budget/templates/display_errors.html b/ihatemoney/templates/display_errors.html index 9e19605..9e19605 100644 --- a/budget/templates/display_errors.html +++ b/ihatemoney/templates/display_errors.html diff --git a/budget/templates/edit_member.html b/ihatemoney/templates/edit_member.html index 5f097f9..5f097f9 100644 --- a/budget/templates/edit_member.html +++ b/ihatemoney/templates/edit_member.html diff --git a/budget/templates/edit_project.html b/ihatemoney/templates/edit_project.html index a5e85c3..a5e85c3 100644 --- a/budget/templates/edit_project.html +++ b/ihatemoney/templates/edit_project.html diff --git a/budget/templates/forms.html b/ihatemoney/templates/forms.html index ffdd165..ffdd165 100644 --- a/budget/templates/forms.html +++ b/ihatemoney/templates/forms.html diff --git a/budget/templates/home.html b/ihatemoney/templates/home.html index 9bfe467..9bfe467 100644 --- a/budget/templates/home.html +++ b/ihatemoney/templates/home.html diff --git a/budget/templates/invitation_mail.en b/ihatemoney/templates/invitation_mail.en index 03f5141..03f5141 100644 --- a/budget/templates/invitation_mail.en +++ b/ihatemoney/templates/invitation_mail.en diff --git a/budget/templates/invitation_mail.fr b/ihatemoney/templates/invitation_mail.fr index 53698dd..53698dd 100644 --- a/budget/templates/invitation_mail.fr +++ b/ihatemoney/templates/invitation_mail.fr diff --git a/budget/templates/layout.html b/ihatemoney/templates/layout.html index 6ecff41..6ecff41 100644 --- a/budget/templates/layout.html +++ b/ihatemoney/templates/layout.html diff --git a/budget/templates/list_bills.html b/ihatemoney/templates/list_bills.html index 4029bc9..4029bc9 100644 --- a/budget/templates/list_bills.html +++ b/ihatemoney/templates/list_bills.html diff --git a/budget/templates/password_reminder.en b/ihatemoney/templates/password_reminder.en index 31210aa..31210aa 100644 --- a/budget/templates/password_reminder.en +++ b/ihatemoney/templates/password_reminder.en diff --git a/budget/templates/password_reminder.fr b/ihatemoney/templates/password_reminder.fr index 58f04e3..58f04e3 100644 --- a/budget/templates/password_reminder.fr +++ b/ihatemoney/templates/password_reminder.fr diff --git a/budget/templates/password_reminder.html b/ihatemoney/templates/password_reminder.html index 8f46289..8f46289 100644 --- a/budget/templates/password_reminder.html +++ b/ihatemoney/templates/password_reminder.html diff --git a/budget/templates/recent_projects.html b/ihatemoney/templates/recent_projects.html index df4972d..df4972d 100644 --- a/budget/templates/recent_projects.html +++ b/ihatemoney/templates/recent_projects.html diff --git a/budget/templates/reminder_mail.en b/ihatemoney/templates/reminder_mail.en index fe57be2..fe57be2 100644 --- a/budget/templates/reminder_mail.en +++ b/ihatemoney/templates/reminder_mail.en diff --git a/budget/templates/reminder_mail.fr b/ihatemoney/templates/reminder_mail.fr index 8130218..8130218 100644 --- a/budget/templates/reminder_mail.fr +++ b/ihatemoney/templates/reminder_mail.fr diff --git a/budget/templates/send_invites.html b/ihatemoney/templates/send_invites.html index 7b3bdc5..7b3bdc5 100644 --- a/budget/templates/send_invites.html +++ b/ihatemoney/templates/send_invites.html diff --git a/budget/templates/settle_bills.html b/ihatemoney/templates/settle_bills.html index b67a9b8..b67a9b8 100644 --- a/budget/templates/settle_bills.html +++ b/ihatemoney/templates/settle_bills.html diff --git a/budget/templates/sidebar_table_layout.html b/ihatemoney/templates/sidebar_table_layout.html index 239acb3..239acb3 100644 --- a/budget/templates/sidebar_table_layout.html +++ b/ihatemoney/templates/sidebar_table_layout.html diff --git a/budget/tests/__init__.py b/ihatemoney/tests/__init__.py index e69de29..e69de29 100644 --- a/budget/tests/__init__.py +++ b/ihatemoney/tests/__init__.py diff --git a/budget/tests/ihatemoney.cfg b/ihatemoney/tests/ihatemoney.cfg index 6345fcf..6345fcf 100644 --- a/budget/tests/ihatemoney.cfg +++ b/ihatemoney/tests/ihatemoney.cfg diff --git a/budget/tests/ihatemoney_envvar.cfg b/ihatemoney/tests/ihatemoney_envvar.cfg index dbc078e..dbc078e 100644 --- a/budget/tests/ihatemoney_envvar.cfg +++ b/ihatemoney/tests/ihatemoney_envvar.cfg diff --git a/budget/tests/tests.py b/ihatemoney/tests/tests.py index 386920f..271477a 100644 --- a/budget/tests/tests.py +++ b/ihatemoney/tests/tests.py @@ -1,4 +1,4 @@ - # -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- from __future__ import unicode_literals try: import unittest2 as unittest @@ -12,139 +12,119 @@ import six from werkzeug.security import generate_password_hash from flask import session +from flask_testing import TestCase + +from ihatemoney.run import create_app, db +from ihatemoney import models +from ihatemoney import utils # Unset configuration file env var if previously set if 'IHATEMONEY_SETTINGS_FILE_PATH' in os.environ: del os.environ['IHATEMONEY_SETTINGS_FILE_PATH'] -from .. import run -from .. import models -from .. import utils - __HERE__ = os.path.dirname(os.path.abspath(__file__)) -class TestCase(unittest.TestCase): +class BaseTestCase(TestCase): - def setUp(self): - run.app.config['TESTING'] = True + SECRET_KEY = "TEST SESSION" - run.app.config['SQLALCHEMY_DATABASE_URI'] = "sqlite:///memory" - run.app.config['WTF_CSRF_ENABLED'] = False # simplify the tests - self.app = run.app.test_client() - try: - models.db.init_app(run.app) - run.mail.init_app(run.app) - except: - pass + def create_app(self): + # Pass the test object as a configuration. + return create_app(self) - models.db.app = run.app - models.db.create_all() + def setUp(self): + db.create_all() def tearDown(self): # clean after testing - models.db.session.remove() - models.db.drop_all() - # reconfigure app with default settings - run.configure() + db.session.remove() + db.drop_all() def login(self, project, password=None, test_client=None): password = password or project - return self.app.post('/authenticate', data=dict( + return self.client.post('/authenticate', data=dict( id=project, password=password), follow_redirects=True) def post_project(self, name): """Create a fake project""" # create the project - self.app.post("/create", data={ - 'name': name, - 'id': name, - 'password': name, - 'contact_email': '%s@notmyidea.org' % name + self.client.post("/create", data={ + 'name': name, + 'id': name, + 'password': name, + 'contact_email': '%s@notmyidea.org' % name }) def create_project(self, name): - models.db.session.add(models.Project(id=name, name=six.text_type(name), - password=name, contact_email="%s@notmyidea.org" % name)) + project = models.Project( + id=name, + name=six.text_type(name), + password=name, + contact_email="%s@notmyidea.org" % name) + models.db.session.add(project) models.db.session.commit() -class BudgetTestCase(TestCase): +class IhatemoneyTestCase(BaseTestCase): + SQLALCHEMY_DATABASE_URI = "sqlite://" + TESTING = True + WTF_CSRF_ENABLED = False # Simplifies the tests. + + +class DefaultConfigurationTestCase(BaseTestCase): def test_default_configuration(self): """Test that default settings are loaded when no other configuration file is specified""" - run.configure() - self.assertFalse(run.app.config['DEBUG']) - self.assertEqual(run.app.config['SQLALCHEMY_DATABASE_URI'], 'sqlite:///budget.db') - self.assertFalse(run.app.config['SQLALCHEMY_TRACK_MODIFICATIONS']) - self.assertEqual(run.app.config['SECRET_KEY'], 'tralala') - self.assertEqual(run.app.config['MAIL_DEFAULT_SENDER'], + self.assertFalse(self.app.config['DEBUG']) + self.assertEqual(self.app.config['SQLALCHEMY_DATABASE_URI'], 'sqlite://') + self.assertFalse(self.app.config['SQLALCHEMY_TRACK_MODIFICATIONS']) + self.assertEqual(self.app.config['MAIL_DEFAULT_SENDER'], ("Budget manager", "budget@notmyidea.org")) - def test_env_var_configuration_file(self): - """Test that settings are loaded from the specified configuration file""" - os.environ['IHATEMONEY_SETTINGS_FILE_PATH'] = os.path.join(__HERE__, - "ihatemoney_envvar.cfg") - run.configure() - self.assertEqual(run.app.config['SECRET_KEY'], 'lalatra') - - # Test that the specified configuration file is loaded - # even if the default configuration file ihatemoney.cfg exists - os.environ['IHATEMONEY_SETTINGS_FILE_PATH'] = os.path.join(__HERE__, - "ihatemoney_envvar.cfg") - run.app.config.root_path = __HERE__ - run.configure() - self.assertEqual(run.app.config['SECRET_KEY'], 'lalatra') - - if 'IHATEMONEY_SETTINGS_FILE_PATH' in os.environ: - del os.environ['IHATEMONEY_SETTINGS_FILE_PATH'] - - def test_default_configuration_file(self): - """Test that settings are loaded from the default configuration file""" - run.app.config.root_path = __HERE__ - run.configure() - self.assertEqual(run.app.config['SECRET_KEY'], 'supersecret') + +class BudgetTestCase(IhatemoneyTestCase): def test_notifications(self): """Test that the notifications are sent, and that email adresses are checked properly. """ # sending a message to one person - with run.mail.record_messages() as outbox: + with self.app.mail.record_messages() as outbox: # create a project self.login("raclette") self.post_project("raclette") - self.app.post("/raclette/invite", - data={"emails": 'alexis@notmyidea.org'}) + self.client.post("/raclette/invite", + data={"emails": 'alexis@notmyidea.org'}) self.assertEqual(len(outbox), 2) self.assertEqual(outbox[0].recipients, ["raclette@notmyidea.org"]) self.assertEqual(outbox[1].recipients, ["alexis@notmyidea.org"]) # sending a message to multiple persons - with run.mail.record_messages() as outbox: - self.app.post("/raclette/invite", - data={"emails": 'alexis@notmyidea.org, toto@notmyidea.org'}) + with self.app.mail.record_messages() as outbox: + self.client.post("/raclette/invite", + data={"emails": 'alexis@notmyidea.org, toto@notmyidea.org'}) # only one message is sent to multiple persons self.assertEqual(len(outbox), 1) self.assertEqual(outbox[0].recipients, - ["alexis@notmyidea.org", "toto@notmyidea.org"]) + ["alexis@notmyidea.org", "toto@notmyidea.org"]) # mail address checking - with run.mail.record_messages() as outbox: - response = self.app.post("/raclette/invite", - data={"emails": "toto"}) + with self.app.mail.record_messages() as outbox: + response = self.client.post("/raclette/invite", + data={"emails": "toto"}) self.assertEqual(len(outbox), 0) # no message sent self.assertIn("The email toto is not valid", response.data.decode('utf-8')) # mixing good and wrong adresses shouldn't send any messages - with run.mail.record_messages() as outbox: - self.app.post("/raclette/invite", - data={"emails": 'alexis@notmyidea.org, alexis'}) # not valid + with self.app.mail.record_messages() as outbox: + self.client.post("/raclette/invite", + data={"emails": 'alexis@notmyidea.org, alexis'}) # not valid # only one message is sent to multiple persons self.assertEqual(len(outbox), 0) @@ -155,19 +135,19 @@ class BudgetTestCase(TestCase): self.create_project("raclette") - with run.mail.record_messages() as outbox: + with self.app.mail.record_messages() as outbox: # a nonexisting project should not send an email - self.app.post("/password-reminder", data={"id": "unexisting"}) + self.client.post("/password-reminder", data={"id": "unexisting"}) self.assertEqual(len(outbox), 0) # a mail should be sent when a project exists - self.app.post("/password-reminder", data={"id": "raclette"}) + self.client.post("/password-reminder", data={"id": "raclette"}) self.assertEqual(len(outbox), 1) self.assertIn("raclette", outbox[0].body) self.assertIn("raclette@notmyidea.org", outbox[0].recipients) def test_project_creation(self): - with run.app.test_client() as c: + with self.app.test_client() as c: # add a valid project c.post("/create", data={ @@ -198,7 +178,7 @@ class BudgetTestCase(TestCase): def test_project_deletion(self): - with run.app.test_client() as c: + with self.app.test_client() as c: c.post("/create", data={ 'name': 'raclette party', 'id': 'raclette', @@ -219,37 +199,37 @@ class BudgetTestCase(TestCase): self.login("raclette") # adds a member to this project - self.app.post("/raclette/members/add", data={'name': 'alexis'}) + self.client.post("/raclette/members/add", data={'name': 'alexis'}) self.assertEqual(len(models.Project.query.get("raclette").members), 1) # adds him twice - result = self.app.post("/raclette/members/add", - data={'name': 'alexis'}) + result = self.client.post("/raclette/members/add", + data={'name': 'alexis'}) # should not accept him self.assertEqual(len(models.Project.query.get("raclette").members), 1) # add fred - self.app.post("/raclette/members/add", data={'name': 'fred'}) + self.client.post("/raclette/members/add", data={'name': 'fred'}) self.assertEqual(len(models.Project.query.get("raclette").members), 2) # check fred is present in the bills page - result = self.app.get("/raclette/") + result = self.client.get("/raclette/") self.assertIn("fred", result.data.decode('utf-8')) # remove fred - self.app.post("/raclette/members/%s/delete" % - models.Project.query.get("raclette").members[-1].id) + self.client.post("/raclette/members/%s/delete" % + models.Project.query.get("raclette").members[-1].id) # as fred is not bound to any bill, he is removed self.assertEqual(len(models.Project.query.get("raclette").members), 1) # add fred again - self.app.post("/raclette/members/add", data={'name': 'fred'}) + self.client.post("/raclette/members/add", data={'name': 'fred'}) fred_id = models.Project.query.get("raclette").members[-1].id # bound him to a bill - result = self.app.post("/raclette/add", data={ + result = self.client.post("/raclette/add", data={ 'date': '2011-08-10', 'what': 'fromage à raclette', 'payer': fred_id, @@ -258,47 +238,47 @@ class BudgetTestCase(TestCase): }) # remove fred - self.app.post("/raclette/members/%s/delete" % fred_id) + self.client.post("/raclette/members/%s/delete" % fred_id) # he is still in the database, but is deactivated self.assertEqual(len(models.Project.query.get("raclette").members), 2) self.assertEqual( - len(models.Project.query.get("raclette").active_members), 1) + len(models.Project.query.get("raclette").active_members), 1) # as fred is now deactivated, check that he is not listed when adding # a bill or displaying the balance - result = self.app.get("/raclette/") + result = self.client.get("/raclette/") self.assertNotIn(("/raclette/members/%s/delete" % fred_id), result.data.decode('utf-8')) - result = self.app.get("/raclette/add") + result = self.client.get("/raclette/add") self.assertNotIn("fred", result.data.decode('utf-8')) # adding him again should reactivate him - self.app.post("/raclette/members/add", data={'name': 'fred'}) + self.client.post("/raclette/members/add", data={'name': 'fred'}) self.assertEqual( - len(models.Project.query.get("raclette").active_members), 2) + len(models.Project.query.get("raclette").active_members), 2) # adding an user with the same name as another user from a different # project should not cause any troubles self.post_project("randomid") self.login("randomid") - self.app.post("/randomid/members/add", data={'name': 'fred'}) + self.client.post("/randomid/members/add", data={'name': 'fred'}) self.assertEqual( - len(models.Project.query.get("randomid").active_members), 1) + len(models.Project.query.get("randomid").active_members), 1) def test_person_model(self): self.post_project("raclette") self.login("raclette") # adds a member to this project - self.app.post("/raclette/members/add", data={'name': 'alexis'}) + self.client.post("/raclette/members/add", data={'name': 'alexis'}) alexis = models.Project.query.get("raclette").members[-1] # should not have any bills self.assertFalse(alexis.has_bills()) # bound him to a bill - self.app.post("/raclette/add", data={ + self.client.post("/raclette/add", data={ 'date': '2011-08-10', 'what': 'fromage à raclette', 'payer': alexis.id, @@ -315,36 +295,36 @@ class BudgetTestCase(TestCase): self.login("raclette") # adds a member to this project - self.app.post("/raclette/members/add", data={'name': 'alexis'}) + self.client.post("/raclette/members/add", data={'name': 'alexis'}) # try to remove the member using GET method - response = self.app.get("/raclette/members/1/delete") + response = self.client.get("/raclette/members/1/delete") self.assertEqual(response.status_code, 405) - #delete user using POST method - self.app.post("/raclette/members/1/delete") + # delete user using POST method + self.client.post("/raclette/members/1/delete") self.assertEqual( - len(models.Project.query.get("raclette").active_members), 0) - #try to delete an user already deleted - self.app.post("/raclette/members/1/delete") + len(models.Project.query.get("raclette").active_members), 0) + # try to delete an user already deleted + self.client.post("/raclette/members/1/delete") def test_demo(self): # test that a demo project is created if none is defined self.assertEqual([], models.Project.query.all()) - self.app.get("/demo") + self.client.get("/demo") self.assertTrue(models.Project.query.get("demo") is not None) def test_deactivated_demo(self): - run.app.config['ACTIVATE_DEMO_PROJECT'] = False + self.app.config['ACTIVATE_DEMO_PROJECT'] = False # test redirection to the create project form when demo is deactivated - resp = self.app.get("/demo") + resp = self.client.get("/demo") self.assertIn('<a href="/create?project_id=demo">', resp.data.decode('utf-8')) def test_authentication(self): # try to authenticate without credentials should redirect # to the authentication page - resp = self.app.post("/authenticate") + resp = self.client.post("/authenticate") self.assertIn("Authentication", resp.data.decode('utf-8')) # raclette that the login / logout process works @@ -352,21 +332,21 @@ class BudgetTestCase(TestCase): # try to see the project while not being authenticated should redirect # to the authentication page - resp = self.app.get("/raclette", follow_redirects=True) + resp = self.client.get("/raclette", follow_redirects=True) self.assertIn("Authentication", resp.data.decode('utf-8')) # try to connect with wrong credentials should not work - with run.app.test_client() as c: + with self.app.test_client() as c: resp = c.post("/authenticate", - data={'id': 'raclette', 'password': 'nope'}) + data={'id': 'raclette', 'password': 'nope'}) self.assertIn("Authentication", resp.data.decode('utf-8')) self.assertNotIn('raclette', session) # try to connect with the right credentials should work - with run.app.test_client() as c: + with self.app.test_client() as c: resp = c.post("/authenticate", - data={'id': 'raclette', 'password': 'raclette'}) + data={'id': 'raclette', 'password': 'raclette'}) self.assertNotIn("Authentication", resp.data.decode('utf-8')) self.assertIn('raclette', session) @@ -377,36 +357,36 @@ class BudgetTestCase(TestCase): self.assertNotIn('raclette', session) def test_admin_authentication(self): - run.app.config['ADMIN_PASSWORD'] = generate_password_hash("pass") + self.app.config['ADMIN_PASSWORD'] = generate_password_hash("pass") # test the redirection to the authentication page when trying to access admin endpoints - resp = self.app.get("/create") + resp = self.client.get("/create") self.assertIn('<a href="/admin?goto=%2Fcreate">', resp.data.decode('utf-8')) # test right password - resp = self.app.post("/admin?goto=%2Fcreate", data={'admin_password': 'pass'}) + resp = self.client.post("/admin?goto=%2Fcreate", data={'admin_password': 'pass'}) self.assertIn('<a href="/create">/create</a>', resp.data.decode('utf-8')) # test wrong password - resp = self.app.post("/admin?goto=%2Fcreate", data={'admin_password': 'wrong'}) + resp = self.client.post("/admin?goto=%2Fcreate", data={'admin_password': 'wrong'}) self.assertNotIn('<a href="/create">/create</a>', resp.data.decode('utf-8')) # test empty password - resp = self.app.post("/admin?goto=%2Fcreate", data={'admin_password': ''}) + resp = self.client.post("/admin?goto=%2Fcreate", data={'admin_password': ''}) self.assertNotIn('<a href="/create">/create</a>', resp.data.decode('utf-8')) def test_manage_bills(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': 'fred'}) + self.client.post("/raclette/members/add", data={'name': 'alexis'}) + self.client.post("/raclette/members/add", data={'name': 'fred'}) members_ids = [m.id for m in models.Project.query.get("raclette").members] # create a bill - self.app.post("/raclette/add", data={ + self.client.post("/raclette/add", data={ 'date': '2011-08-10', 'what': 'fromage à raclette', 'payer': members_ids[0], @@ -418,7 +398,7 @@ class BudgetTestCase(TestCase): self.assertEqual(bill.amount, 25) # edit the bill - self.app.post("/raclette/edit/%s" % bill.id, data={ + self.client.post("/raclette/edit/%s" % bill.id, data={ 'date': '2011-08-10', 'what': 'fromage à raclette', 'payer': members_ids[0], @@ -430,11 +410,11 @@ class BudgetTestCase(TestCase): self.assertEqual(bill.amount, 10, "bill edition") # delete the bill - self.app.get("/raclette/delete/%s" % bill.id) + self.client.get("/raclette/delete/%s" % bill.id) self.assertEqual(0, len(models.Bill.query.all()), "bill deletion") # test balance - self.app.post("/raclette/add", data={ + self.client.post("/raclette/add", data={ 'date': '2011-08-10', 'what': 'fromage à raclette', 'payer': members_ids[0], @@ -442,7 +422,7 @@ class BudgetTestCase(TestCase): 'amount': '19', }) - self.app.post("/raclette/add", data={ + self.client.post("/raclette/add", data={ 'date': '2011-08-10', 'what': 'fromage à raclette', 'payer': members_ids[1], @@ -450,7 +430,7 @@ class BudgetTestCase(TestCase): 'amount': '20', }) - self.app.post("/raclette/add", data={ + self.client.post("/raclette/add", data={ 'date': '2011-08-10', 'what': 'fromage à raclette', 'payer': members_ids[1], @@ -461,8 +441,8 @@ class BudgetTestCase(TestCase): balance = models.Project.query.get("raclette").balance self.assertEqual(set(balance.values()), set([19.0, -19.0])) - #Bill with negative amount - self.app.post("/raclette/add", data={ + # Bill with negative amount + self.client.post("/raclette/add", data={ 'date': '2011-08-12', 'what': 'fromage à raclette', 'payer': members_ids[0], @@ -472,8 +452,8 @@ class BudgetTestCase(TestCase): bill = models.Bill.query.filter(models.Bill.date == '2011-08-12')[0] self.assertEqual(bill.amount, -25) - #add a bill with a comma - self.app.post("/raclette/add", data={ + # add a bill with a comma + self.client.post("/raclette/add", data={ 'date': '2011-08-01', 'what': 'fromage à raclette', 'payer': members_ids[0], @@ -487,14 +467,14 @@ class BudgetTestCase(TestCase): 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}) + self.client.post("/raclette/members/add", data={'name': 'alexis'}) + self.client.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={ + self.client.post("/raclette/add", data={ 'date': '2011-08-10', 'what': 'fromage à raclette', 'payer': members_ids[0], @@ -502,7 +482,7 @@ class BudgetTestCase(TestCase): 'amount': '10', }) - self.app.post("/raclette/add", data={ + self.client.post("/raclette/add", data={ 'date': '2011-08-10', 'what': 'pommes de terre', 'payer': members_ids[1], @@ -517,28 +497,27 @@ class BudgetTestCase(TestCase): 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}) + self.client.post("/raclette/members/add", data={'name': 'alexis'}) + self.client.post("/raclette/members/add", data={'name': 'tata', 'weight': 1}) - resp = self.app.get("/raclette/") + resp = self.client.get("/raclette/") self.assertIn('extra-info', resp.data.decode('utf-8')) - self.app.post("/raclette/members/add", data={'name': 'freddy familly', 'weight': 4}) + self.client.post("/raclette/members/add", data={'name': 'freddy familly', 'weight': 4}) - resp = self.app.get("/raclette/") + resp = self.client.get("/raclette/") self.assertNotIn('extra-info', resp.data.decode('utf-8')) - def test_rounding(self): self.post_project("raclette") # add members - self.app.post("/raclette/members/add", data={'name': 'alexis'}) - self.app.post("/raclette/members/add", data={'name': 'fred'}) - self.app.post("/raclette/members/add", data={'name': 'tata'}) + self.client.post("/raclette/members/add", data={'name': 'alexis'}) + self.client.post("/raclette/members/add", data={'name': 'fred'}) + self.client.post("/raclette/members/add", data={'name': 'tata'}) # create bills - self.app.post("/raclette/add", data={ + self.client.post("/raclette/add", data={ 'date': '2011-08-10', 'what': 'fromage à raclette', 'payer': 1, @@ -546,7 +525,7 @@ class BudgetTestCase(TestCase): 'amount': '24.36', }) - self.app.post("/raclette/add", data={ + self.client.post("/raclette/add", data={ 'date': '2011-08-10', 'what': 'red wine', 'payer': 2, @@ -554,7 +533,7 @@ class BudgetTestCase(TestCase): 'amount': '19.12', }) - self.app.post("/raclette/add", data={ + self.client.post("/raclette/add", data={ 'date': '2011-08-10', 'what': 'delicatessen', 'payer': 1, @@ -567,8 +546,10 @@ class BudgetTestCase(TestCase): result[models.Project.query.get("raclette").members[0].id] = 8.12 result[models.Project.query.get("raclette").members[1].id] = 0.0 result[models.Project.query.get("raclette").members[2].id] = -8.12 - # Since we're using floating point to store currency, we can have some rounding issues that prevent test from working. - # However, we should obtain the same values as the theorical ones if we round to 2 decimals, like in the UI. + # Since we're using floating point to store currency, we can have some + # rounding issues that prevent test from working. + # However, we should obtain the same values as the theorical ones if we + # round to 2 decimals, like in the UI. for key, value in six.iteritems(balance): self.assertEqual(round(value, 2), result[key]) @@ -582,8 +563,8 @@ class BudgetTestCase(TestCase): 'password': 'didoudida' } - resp = self.app.post("/raclette/edit", data=new_data, - follow_redirects=True) + resp = self.client.post("/raclette/edit", data=new_data, + follow_redirects=True) self.assertEqual(resp.status_code, 200) project = models.Project.query.get("raclette") @@ -593,31 +574,31 @@ class BudgetTestCase(TestCase): # Editing a project with a wrong email address should fail new_data['contact_email'] = 'wrong_email' - resp = self.app.post("/raclette/edit", data=new_data, - follow_redirects=True) + resp = self.client.post("/raclette/edit", data=new_data, + follow_redirects=True) self.assertIn("Invalid email address", resp.data.decode('utf-8')) def test_dashboard(self): - response = self.app.get("/dashboard") + response = self.client.get("/dashboard") self.assertEqual(response.status_code, 200) def test_settle_page(self): self.post_project("raclette") - response = self.app.get("/raclette/settle_bills") + response = self.client.get("/raclette/settle_bills") self.assertEqual(response.status_code, 200) def test_settle(self): self.post_project("raclette") # add members - self.app.post("/raclette/members/add", data={'name': 'alexis'}) - self.app.post("/raclette/members/add", data={'name': 'fred'}) - self.app.post("/raclette/members/add", data={'name': 'tata'}) - #Add a member with a balance=0 : - self.app.post("/raclette/members/add", data={'name': 'toto'}) + self.client.post("/raclette/members/add", data={'name': 'alexis'}) + self.client.post("/raclette/members/add", data={'name': 'fred'}) + self.client.post("/raclette/members/add", data={'name': 'tata'}) + # Add a member with a balance=0 : + self.client.post("/raclette/members/add", data={'name': 'toto'}) # create bills - self.app.post("/raclette/add", data={ + self.client.post("/raclette/add", data={ 'date': '2011-08-10', 'what': 'fromage à raclette', 'payer': 1, @@ -625,7 +606,7 @@ class BudgetTestCase(TestCase): 'amount': '10.0', }) - self.app.post("/raclette/add", data={ + self.client.post("/raclette/add", data={ 'date': '2011-08-10', 'what': 'red wine', 'payer': 2, @@ -633,20 +614,20 @@ class BudgetTestCase(TestCase): 'amount': '20', }) - self.app.post("/raclette/add", data={ + self.client.post("/raclette/add", data={ 'date': '2011-08-10', 'what': 'delicatessen', 'payer': 1, 'payed_for': [1, 2], 'amount': '10', }) - project = models.Project.query.get('raclette') + project = models.Project.query.get('raclette') transactions = project.get_transactions_to_settle_bill() members = defaultdict(int) - #We should have the same values between transactions and project balances + # We should have the same values between transactions and project balances for t in transactions: - members[t['ower']]-=t['amount'] - members[t['receiver']]+=t['amount'] + members[t['ower']] -= t['amount'] + members[t['receiver']] += t['amount'] balance = models.Project.query.get("raclette").balance for m, a in members.items(): self.assertEqual(a, balance[m.id]) @@ -656,12 +637,12 @@ class BudgetTestCase(TestCase): self.post_project("raclette") # add members - self.app.post("/raclette/members/add", data={'name': 'alexis'}) - self.app.post("/raclette/members/add", data={'name': 'fred'}) - self.app.post("/raclette/members/add", data={'name': 'tata'}) + self.client.post("/raclette/members/add", data={'name': 'alexis'}) + self.client.post("/raclette/members/add", data={'name': 'fred'}) + self.client.post("/raclette/members/add", data={'name': 'tata'}) # create bills - self.app.post("/raclette/add", data={ + self.client.post("/raclette/add", data={ 'date': '2016-12-31', 'what': 'fromage à raclette', 'payer': 1, @@ -669,7 +650,7 @@ class BudgetTestCase(TestCase): 'amount': '10.0', }) - self.app.post("/raclette/add", data={ + self.client.post("/raclette/add", data={ 'date': '2016-12-31', 'what': 'red wine', 'payer': 2, @@ -677,16 +658,16 @@ class BudgetTestCase(TestCase): 'amount': '20', }) - self.app.post("/raclette/add", data={ + self.client.post("/raclette/add", data={ 'date': '2017-01-01', 'what': 'refund', 'payer': 3, 'payed_for': [2], 'amount': '13.33', }) - project = models.Project.query.get('raclette') + project = models.Project.query.get('raclette') transactions = project.get_transactions_to_settle_bill() - members = defaultdict(int) + # There should not be any zero-amount transfer after rounding for t in transactions: rounded_amount = round(t['amount'], 2) @@ -697,13 +678,13 @@ class BudgetTestCase(TestCase): self.post_project("raclette") # add members - self.app.post("/raclette/members/add", data={'name': 'alexis', 'weight': 2}) - self.app.post("/raclette/members/add", data={'name': 'fred'}) - self.app.post("/raclette/members/add", data={'name': 'tata'}) - self.app.post("/raclette/members/add", data={'name': 'pépé'}) + self.client.post("/raclette/members/add", data={'name': 'alexis', 'weight': 2}) + self.client.post("/raclette/members/add", data={'name': 'fred'}) + self.client.post("/raclette/members/add", data={'name': 'tata'}) + self.client.post("/raclette/members/add", data={'name': 'pépé'}) # create bills - self.app.post("/raclette/add", data={ + self.client.post("/raclette/add", data={ 'date': '2016-12-31', 'what': 'fromage à raclette', 'payer': 1, @@ -711,7 +692,7 @@ class BudgetTestCase(TestCase): 'amount': '10.0', }) - self.app.post("/raclette/add", data={ + self.client.post("/raclette/add", data={ 'date': '2016-12-31', 'what': 'red wine', 'payer': 2, @@ -719,7 +700,7 @@ class BudgetTestCase(TestCase): 'amount': '200', }) - self.app.post("/raclette/add", data={ + self.client.post("/raclette/add", data={ 'date': '2017-01-01', 'what': 'refund', 'payer': 3, @@ -728,27 +709,44 @@ class BudgetTestCase(TestCase): }) # generate json export of bills - resp = self.app.post("/raclette/edit", data={ + resp = self.client.post("/raclette/edit", data={ 'export_format': 'json', 'export_type': 'bills' }) - expected = [{'date': '2017-01-01', 'what': 'refund', - 'amount': 13.33, 'payer_name': 'tata', 'payer_weight': 1.0, 'owers': ['fred']}, - {'date': '2016-12-31', 'what': 'red wine', - 'amount': 200.0, 'payer_name': 'fred', 'payer_weight': 1.0, 'owers': ['alexis', 'tata']}, - {'date': '2016-12-31', 'what': 'fromage \xe0 raclette', - 'amount': 10.0, 'payer_name': 'alexis', 'payer_weight': 2.0, 'owers': ['alexis', 'fred', 'tata', 'p\xe9p\xe9']}] + expected = [{ + 'date': '2017-01-01', + 'what': 'refund', + 'amount': 13.33, + 'payer_name': 'tata', + 'payer_weight': 1.0, + 'owers': ['fred'] + }, { + 'date': '2016-12-31', + 'what': 'red wine', + 'amount': 200.0, + 'payer_name': 'fred', + 'payer_weight': 1.0, + 'owers': ['alexis', 'tata'] + }, { + 'date': '2016-12-31', + 'what': 'fromage \xe0 raclette', + 'amount': 10.0, + 'payer_name': 'alexis', + 'payer_weight': 2.0, + 'owers': ['alexis', 'fred', 'tata', 'p\xe9p\xe9'] + }] self.assertEqual(json.loads(resp.data.decode('utf-8')), expected) # generate csv export of bills - resp = self.app.post("/raclette/edit", data={ + resp = self.client.post("/raclette/edit", data={ 'export_format': 'csv', 'export_type': 'bills' }) - expected = ["date,what,amount,payer_name,payer_weight,owers", - "2017-01-01,refund,13.33,tata,1.0,fred", - "2016-12-31,red wine,200.0,fred,1.0,\"alexis, tata\"", - "2016-12-31,fromage à raclette,10.0,alexis,2.0,\"alexis, fred, tata, pépé\""] + expected = [ + "date,what,amount,payer_name,payer_weight,owers", + "2017-01-01,refund,13.33,tata,1.0,fred", + "2016-12-31,red wine,200.0,fred,1.0,\"alexis, tata\"", + "2016-12-31,fromage à raclette,10.0,alexis,2.0,\"alexis, fred, tata, pépé\""] received_lines = resp.data.decode('utf-8').split("\n") for i, line in enumerate(expected): @@ -758,7 +756,7 @@ class BudgetTestCase(TestCase): ) # generate json export of transactions - resp = self.app.post("/raclette/edit", data={ + resp = self.client.post("/raclette/edit", data={ 'export_format': 'json', 'export_type': 'transactions' }) @@ -768,7 +766,7 @@ class BudgetTestCase(TestCase): self.assertEqual(json.loads(resp.data.decode('utf-8')), expected) # generate csv export of transactions - resp = self.app.post("/raclette/edit", data={ + resp = self.client.post("/raclette/edit", data={ 'export_format': 'csv', 'export_type': 'transactions' }) @@ -786,7 +784,7 @@ class BudgetTestCase(TestCase): ) # wrong export_format should return a 200 and export form - resp = self.app.post("/raclette/edit", data={ + resp = self.client.post("/raclette/edit", data={ 'export_format': 'wrong_export_format', 'export_type': 'transactions' }) @@ -795,7 +793,7 @@ class BudgetTestCase(TestCase): self.assertIn('id="export_format" name="export_format"', resp.data.decode('utf-8')) # wrong export_type should return a 200 and export form - resp = self.app.post("/raclette/edit", data={ + resp = self.client.post("/raclette/edit", data={ 'export_format': 'json', 'export_type': 'wrong_export_type' }) @@ -804,7 +802,8 @@ class BudgetTestCase(TestCase): self.assertIn('id="export_format" name="export_format"', resp.data.decode('utf-8')) -class APITestCase(TestCase): +class APITestCase(IhatemoneyTestCase): + """Tests the API""" def api_create(self, name, id=None, password=None, contact=None): @@ -812,7 +811,7 @@ class APITestCase(TestCase): password = password or name contact = contact or "%s@notmyidea.org" % name - return self.app.post("/api/projects", data={ + return self.client.post("/api/projects", data={ 'name': name, 'id': id, 'password': password, @@ -820,9 +819,9 @@ class APITestCase(TestCase): }) def api_add_member(self, project, name, weight=1): - self.app.post("/api/projects/%s/members" % project, - data={"name": name, "weight": weight}, - headers=self.get_auth(project)) + self.client.post("/api/projects/%s/members" % project, + data={"name": name, "weight": weight}, + headers=self.get_auth(project)) def get_auth(self, username, password=None): password = password or username @@ -833,7 +832,7 @@ class APITestCase(TestCase): def assertStatus(self, expected, resp, url=""): return self.assertEqual(expected, resp.status_code, - "%s expected %s, got %s" % (url, expected, resp.status_code)) + "%s expected %s, got %s" % (url, expected, resp.status_code)) def test_basic_auth(self): # create a project @@ -841,7 +840,7 @@ class APITestCase(TestCase): self.assertStatus(201, resp) # try to do something on it being unauth should return a 401 - resp = self.app.get("/api/projects/raclette") + resp = self.client.get("/api/projects/raclette") self.assertStatus(401, resp) # PUT / POST / DELETE / GET on the different resources @@ -849,20 +848,20 @@ class APITestCase(TestCase): for verb in ('post',): for resource in ("/raclette/members", "/raclette/bills"): url = "/api/projects" + resource - self.assertStatus(401, getattr(self.app, verb)(url), - verb + resource) + self.assertStatus(401, getattr(self.client, verb)(url), + verb + resource) for verb in ('get', 'delete', 'put'): for resource in ("/raclette", "/raclette/members/1", - "/raclette/bills/1"): + "/raclette/bills/1"): url = "/api/projects" + resource - self.assertStatus(401, getattr(self.app, verb)(url), - verb + resource) + self.assertStatus(401, getattr(self.client, verb)(url), + verb + resource) def test_project(self): # wrong email should return an error - resp = self.app.post("/api/projects", data={ + resp = self.client.post("/api/projects", data={ 'name': "raclette", 'id': "raclette", 'password': "raclette", @@ -884,8 +883,8 @@ class APITestCase(TestCase): self.assertIn('id', json.loads(resp.data.decode('utf-8'))) # get information about it - resp = self.app.get("/api/projects/raclette", - headers=self.get_auth("raclette")) + resp = self.client.get("/api/projects/raclette", + headers=self.get_auth("raclette")) self.assertTrue(200, resp.status_code) expected = { @@ -900,16 +899,16 @@ class APITestCase(TestCase): self.assertDictEqual(json.loads(resp.data.decode('utf-8')), expected) # edit should work - resp = self.app.put("/api/projects/raclette", data={ + resp = self.client.put("/api/projects/raclette", data={ "contact_email": "yeah@notmyidea.org", "password": "raclette", "name": "The raclette party", - }, headers=self.get_auth("raclette")) + }, headers=self.get_auth("raclette")) self.assertEqual(200, resp.status_code) - resp = self.app.get("/api/projects/raclette", - headers=self.get_auth("raclette")) + resp = self.client.get("/api/projects/raclette", + headers=self.get_auth("raclette")) self.assertEqual(200, resp.status_code) expected = { @@ -924,14 +923,14 @@ class APITestCase(TestCase): self.assertDictEqual(json.loads(resp.data.decode('utf-8')), expected) # delete should work - resp = self.app.delete("/api/projects/raclette", - headers=self.get_auth("raclette")) + resp = self.client.delete("/api/projects/raclette", + headers=self.get_auth("raclette")) self.assertEqual(200, resp.status_code) # get should return a 401 on an unknown resource - resp = self.app.get("/api/projects/raclette", - headers=self.get_auth("raclette")) + resp = self.client.get("/api/projects/raclette", + headers=self.get_auth("raclette")) self.assertEqual(401, resp.status_code) def test_member(self): @@ -939,53 +938,53 @@ class APITestCase(TestCase): self.api_create("raclette") # get the list of members (should be empty) - req = self.app.get("/api/projects/raclette/members", - headers=self.get_auth("raclette")) + req = self.client.get("/api/projects/raclette/members", + headers=self.get_auth("raclette")) self.assertStatus(200, req) self.assertEqual('[]', req.data.decode('utf-8')) # add a member - req = self.app.post("/api/projects/raclette/members", data={ - "name": "Alexis" - }, headers=self.get_auth("raclette")) + req = self.client.post("/api/projects/raclette/members", data={ + "name": "Alexis" + }, headers=self.get_auth("raclette")) # the id of the new member should be returned self.assertStatus(201, req) self.assertEqual("1", req.data.decode('utf-8')) # the list of members should contain one member - req = self.app.get("/api/projects/raclette/members", - headers=self.get_auth("raclette")) + req = self.client.get("/api/projects/raclette/members", + headers=self.get_auth("raclette")) self.assertStatus(200, req) self.assertEqual(len(json.loads(req.data.decode('utf-8'))), 1) # edit this member - req = self.app.put("/api/projects/raclette/members/1", data={ - "name": "Fred" - }, headers=self.get_auth("raclette")) + req = self.client.put("/api/projects/raclette/members/1", data={ + "name": "Fred" + }, headers=self.get_auth("raclette")) self.assertStatus(200, req) # get should return the new name - req = self.app.get("/api/projects/raclette/members/1", - headers=self.get_auth("raclette")) + req = self.client.get("/api/projects/raclette/members/1", + headers=self.get_auth("raclette")) self.assertStatus(200, req) self.assertEqual("Fred", json.loads(req.data.decode('utf-8'))["name"]) # delete a member - req = self.app.delete("/api/projects/raclette/members/1", - headers=self.get_auth("raclette")) + req = self.client.delete("/api/projects/raclette/members/1", + headers=self.get_auth("raclette")) self.assertStatus(200, req) # the list of members should be empty # get the list of members (should be empty) - req = self.app.get("/api/projects/raclette/members", - headers=self.get_auth("raclette")) + req = self.client.get("/api/projects/raclette/members", + headers=self.get_auth("raclette")) self.assertStatus(200, req) self.assertEqual('[]', req.data.decode('utf-8')) @@ -1000,28 +999,28 @@ class APITestCase(TestCase): self.api_add_member("raclette", "arnaud") # get the list of bills (should be empty) - req = self.app.get("/api/projects/raclette/bills", - headers=self.get_auth("raclette")) + req = self.client.get("/api/projects/raclette/bills", + headers=self.get_auth("raclette")) self.assertStatus(200, req) self.assertEqual("[]", req.data.decode('utf-8')) # add a bill - req = self.app.post("/api/projects/raclette/bills", data={ + req = self.client.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")) + }, headers=self.get_auth("raclette")) # should return the id self.assertStatus(201, req) self.assertEqual(req.data.decode('utf-8'), "1") # get this bill details - req = self.app.get("/api/projects/raclette/bills/1", - headers=self.get_auth("raclette")) + req = self.client.get("/api/projects/raclette/bills/1", + headers=self.get_auth("raclette")) # compare with the added info self.assertStatus(200, req) @@ -1038,35 +1037,35 @@ class APITestCase(TestCase): self.assertDictEqual(expected, json.loads(req.data.decode('utf-8'))) # the list of bills should lenght 1 - req = self.app.get("/api/projects/raclette/bills", - headers=self.get_auth("raclette")) + req = self.client.get("/api/projects/raclette/bills", + headers=self.get_auth("raclette")) self.assertStatus(200, req) self.assertEqual(1, len(json.loads(req.data.decode('utf-8')))) # edit with errors should return an error - req = self.app.put("/api/projects/raclette/bills/1", data={ + req = self.client.put("/api/projects/raclette/bills/1", data={ 'date': '201111111-08-10', # not a date 'what': 'fromage', 'payer': "1", 'payed_for': ["1", "2"], 'amount': '25', - }, headers=self.get_auth("raclette")) + }, headers=self.get_auth("raclette")) self.assertStatus(400, req) self.assertEqual('{"date": ["This field is required."]}', req.data.decode('utf-8')) # edit a bill - req = self.app.put("/api/projects/raclette/bills/1", data={ + req = self.client.put("/api/projects/raclette/bills/1", data={ 'date': '2011-09-10', 'what': 'beer', 'payer': "2", 'payed_for': ["1", "2"], 'amount': '25', - }, headers=self.get_auth("raclette")) + }, headers=self.get_auth("raclette")) # check its fields - req = self.app.get("/api/projects/raclette/bills/1", - headers=self.get_auth("raclette")) + req = self.client.get("/api/projects/raclette/bills/1", + headers=self.get_auth("raclette")) expected = { "what": "beer", @@ -1081,25 +1080,25 @@ class APITestCase(TestCase): self.assertDictEqual(expected, json.loads(req.data.decode('utf-8'))) # delete a bill - req = self.app.delete("/api/projects/raclette/bills/1", - headers=self.get_auth("raclette")) + req = self.client.delete("/api/projects/raclette/bills/1", + headers=self.get_auth("raclette")) self.assertStatus(200, req) # getting it should return a 404 - req = self.app.get("/api/projects/raclette/bills/1", - headers=self.get_auth("raclette")) + req = self.client.get("/api/projects/raclette/bills/1", + headers=self.get_auth("raclette")) self.assertStatus(404, req) def test_username_xss(self): # create a project - #self.api_create("raclette") + # self.api_create("raclette") self.post_project("raclette") self.login("raclette") # add members self.api_add_member("raclette", "<script>") - result = self.app.get('/raclette/') + result = self.client.get('/raclette/') self.assertNotIn("<script>", result.data.decode('utf-8')) def test_weighted_bills(self): @@ -1112,17 +1111,17 @@ class APITestCase(TestCase): self.api_add_member("raclette", "arnaud") # add a bill - req = self.app.post("/api/projects/raclette/bills", data={ + req = self.client.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")) + }, headers=self.get_auth("raclette")) # get this bill details - req = self.app.get("/api/projects/raclette/bills/1", - headers=self.get_auth("raclette")) + req = self.client.get("/api/projects/raclette/bills/1", + headers=self.get_auth("raclette")) # compare with the added info self.assertStatus(200, req) @@ -1138,8 +1137,8 @@ class APITestCase(TestCase): self.assertDictEqual(expected, json.loads(req.data.decode('utf-8'))) # getting it should return a 404 - req = self.app.get("/api/projects/raclette", - headers=self.get_auth("raclette")) + req = self.client.get("/api/projects/raclette", + headers=self.get_auth("raclette")) expected = { "active_members": [ @@ -1162,20 +1161,19 @@ class APITestCase(TestCase): self.assertStatus(200, req) self.assertEqual(expected, json.loads(req.data.decode('utf-8'))) + class ServerTestCase(APITestCase): - def setUp(self): - run.configure() - super(ServerTestCase, self).setUp() def test_unprefixed(self): - run.app.config['APPLICATION_ROOT'] = '/' - req = self.app.get("/foo/") + self.app.config['APPLICATION_ROOT'] = '/' + req = self.client.get("/foo/") self.assertStatus(303, req) def test_prefixed(self): - run.app.config['APPLICATION_ROOT'] = '/foo' - req = self.app.get("/foo/") + self.app.config['APPLICATION_ROOT'] = '/foo' + req = self.client.get("/foo/") self.assertStatus(200, req) + if __name__ == "__main__": unittest.main() diff --git a/budget/translations/fr/LC_MESSAGES/messages.mo b/ihatemoney/translations/fr/LC_MESSAGES/messages.mo Binary files differindex 210852b..210852b 100644 --- a/budget/translations/fr/LC_MESSAGES/messages.mo +++ b/ihatemoney/translations/fr/LC_MESSAGES/messages.mo diff --git a/budget/translations/fr/LC_MESSAGES/messages.po b/ihatemoney/translations/fr/LC_MESSAGES/messages.po index 0f3339e..0f3339e 100644 --- a/budget/translations/fr/LC_MESSAGES/messages.po +++ b/ihatemoney/translations/fr/LC_MESSAGES/messages.po diff --git a/budget/utils.py b/ihatemoney/utils.py index 0e6251b..4e79a37 100644 --- a/budget/utils.py +++ b/ihatemoney/utils.py @@ -1,6 +1,5 @@ import base64 import re -import inspect from io import BytesIO, StringIO from jinja2 import filters @@ -26,7 +25,9 @@ def slugify(value): value = six.text_type(re.sub('[^\w\s-]', '', value).strip().lower()) return re.sub('[-\s]+', '-', value) + class Redirect303(HTTPException, RoutingException): + """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. @@ -43,6 +44,7 @@ class Redirect303(HTTPException, RoutingException): class PrefixedWSGI(object): + ''' Wrap the application in this middleware and configure the front-end server to add these headers, to let you quietly bind @@ -55,6 +57,7 @@ class PrefixedWSGI(object): :param app: the WSGI application ''' + def __init__(self, app): self.app = app self.wsgi_app = app.wsgi_app @@ -85,12 +88,14 @@ def minimal_round(*args, **kw): ires = int(res) return (res if res != ires else ires) + def list_of_dicts2json(dict_to_convert): """Take a list of dictionnaries and turns it into a json in-memory file """ return BytesIO(dumps(dict_to_convert).encode('utf-8')) + def list_of_dicts2csv(dict_to_convert): """Take a list of dictionnaries and turns it into a csv in-memory file, assume all dict have the same keys @@ -110,9 +115,10 @@ def list_of_dicts2csv(dict_to_convert): csv_data = [] csv_data.append([key.encode('utf-8') for key in dict_to_convert[0].keys()]) for dic in dict_to_convert: - csv_data.append([dic[h].encode('utf8') - if isinstance(dic[h], unicode) else str(dic[h]).encode('utf8') - for h in dict_to_convert[0].keys()]) + csv_data.append( + [dic[h].encode('utf8') + if isinstance(dic[h], unicode) else str(dic[h]).encode('utf8') # NOQA + for h in dict_to_convert[0].keys()]) except (KeyError, IndexError): csv_data = [] writer = csv.writer(csv_file) @@ -122,5 +128,6 @@ def list_of_dicts2csv(dict_to_convert): csv_file = BytesIO(csv_file.getvalue().encode('utf-8')) return csv_file + # 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 diff --git a/budget/web.py b/ihatemoney/web.py index ba77124..65c0ed6 100644 --- a/budget/web.py +++ b/ihatemoney/web.py @@ -13,25 +13,23 @@ from flask import ( Blueprint, current_app, flash, g, redirect, render_template, request, 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 werkzeug.security import generate_password_hash, \ - check_password_hash +from werkzeug.security import check_password_hash from smtplib import SMTPRecipientsRefused import werkzeug from sqlalchemy import orm from functools import wraps -from .models import db, Project, Person, Bill -from .forms import ( +from ihatemoney.models import db, Project, Person, Bill +from ihatemoney.forms import ( AdminAuthenticationForm, AuthenticationForm, EditProjectForm, InviteForm, MemberForm, PasswordReminder, ProjectForm, get_billform_for, 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__) -mail = Mail() def requires_admin(f): @@ -75,14 +73,14 @@ def pull_project(endpoint, values): project = Project.query.get(project_id) if not project: raise Redirect303(url_for(".create_project", - project_id=project_id)) + project_id=project_id)) if project.id in session and session[project.id] == project.password: # add project into kwargs and call the original function g.project = project else: # redirect to authentication page raise Redirect303( - url_for(".authenticate", project_id=project_id)) + url_for(".authenticate", project_id=project_id)) @main.route("/admin", methods=["GET", "POST"]) @@ -110,7 +108,7 @@ def authenticate(project_id=None): form.id.data = request.args['project_id'] project_id = form.id.data if project_id is None: - #User doesn't provide project identifier, return to authenticate form + # User doesn't provide project identifier, return to authenticate form msg = _("You need to enter a project identifier") form.errors["id"] = [msg] return render_template("authenticate.html", form=form) @@ -150,7 +148,7 @@ def authenticate(project_id=None): return redirect(url_for(".list_bills")) return render_template("authenticate.html", form=form, - create_project=create_project) + create_project=create_project) @main.route("/") @@ -196,16 +194,16 @@ def create_project(): g.project = project message_title = _("You have just created '%(project)s' " - "to share your expenses", project=g.project.name) + "to share your expenses", project=g.project.name) message_body = render_template("reminder_mail.%s" % - get_locale().language) + get_locale().language) msg = Message(message_title, - body=message_body, - recipients=[project.contact_email]) + body=message_body, + recipients=[project.contact_email]) try: - mail.send(msg) + current_app.mail.send(msg) except SMTPRecipientsRefused: msg_compl = 'Problem sending mail. ' # TODO: destroy the project and cancel instead? @@ -214,7 +212,7 @@ def create_project(): # redirect the user to the next step (invite) flash(_("%(msg_compl)sThe project identifier is %(project)s", - msg_compl=msg_compl, project=project.id)) + msg_compl=msg_compl, project=project.id)) return redirect(url_for(".invite", project_id=project.id)) return render_template("create_project.html", form=form) @@ -230,7 +228,8 @@ def remind_password(): # send the password reminder 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), recipients=[project.contact_email])) flash(_("a mail has been sent to you with the password")) @@ -270,7 +269,7 @@ def edit_project(): attachment_filename="%s-%s.%s" % (g.project.id, export_type, export_format), as_attachment=True - ) + ) else: edit_form.name.data = g.project.name edit_form.password.data = g.project.password @@ -311,7 +310,7 @@ def demo(): project_id='demo')) if not project and is_demo_project_activated: project = Project(id="demo", name=u"demonstration", password="demo", - contact_email="demo@notmyidea.org") + contact_email="demo@notmyidea.org") db.session.add(project) db.session.commit() session[project.id] = project.password @@ -329,15 +328,15 @@ def invite(): # send the email message_body = render_template("invitation_mail.%s" % - get_locale().language) + get_locale().language) message_title = _("You have been invited to share your " - "expenses for %(project)s", project=g.project.name) + "expenses for %(project)s", project=g.project.name) msg = Message(message_title, - body=message_body, - recipients=[email.strip() - for email in form.emails.data.split(",")]) - mail.send(msg) + body=message_body, + recipients=[email.strip() + for email in form.emails.data.split(",")]) + current_app.mail.send(msg) flash(_("Your invitations have been sent")) return redirect(url_for(".list_bills")) @@ -354,11 +353,11 @@ def list_bills(): bills = g.project.get_bills().options(orm.subqueryload(Bill.owers)) return render_template("list_bills.html", - bills=bills, member_form=MemberForm(g.project), - bill_form=bill_form, - add_bill=request.values.get('add_bill', False), - current_view="list_bills", - ) + bills=bills, member_form=MemberForm(g.project), + bill_form=bill_form, + add_bill=request.values.get('add_bill', False), + current_view="list_bills", + ) @main.route("/<project_id>/members/add", methods=["GET", "POST"]) @@ -378,7 +377,7 @@ def add_member(): @main.route("/<project_id>/members/<member_id>/reactivate", methods=["POST"]) def reactivate(member_id): person = Person.query.filter(Person.id == member_id)\ - .filter(Project.id == g.project.id).all() + .filter(Project.id == g.project.id).all() if person: person[0].activated = True db.session.commit() @@ -390,7 +389,7 @@ def reactivate(member_id): def remove_member(member_id): member = g.project.remove_member(member_id) if member: - if member.activated == False: + if not member.activated: flash(_("User '%(name)s' has been deactivated. It will still " "appear in the users list until its balance " "becomes zero.", name=member.name)) diff --git a/ihatemoney/wsgi.py b/ihatemoney/wsgi.py new file mode 100644 index 0000000..d537568 --- /dev/null +++ b/ihatemoney/wsgi.py @@ -0,0 +1,3 @@ +from ihatemoney.run import create_app + +application = create_app() @@ -35,7 +35,10 @@ DEPENDENCY_LINKS = [ ENTRY_POINTS = { 'paste.app_factory': [ - 'main = budget.run:main', + 'main = ihatemoney.run:main', + ], + 'console_scripts': [ + 'ihatemoney = ihatemoney.manage:main' ], } @@ -1,12 +1,12 @@ [tox] -envlist = py35,py34,py27,docs +envlist = py35,py34,py27,docs,lint skip_missing_interpreters = True [testenv] commands = python --version - py.test budget/tests/tests.py + py.test ihatemoney/tests/tests.py deps = -rdev-requirements.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 deps = -rdocs/requirements.txt + +[testenv:lint] +commands = flake8 ihatemoney +deps = + -rdev-requirements.txt + +[flake8] +exclude = migrations +max_line_length = 100 |
