diff --git a/.gitignore b/.gitignore index d8d18940..9e3c42ce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -ihatemoney.cfg *.pyc *.egg-info dist @@ -13,3 +12,8 @@ build .pytest_cache ihatemoney/budget.db .idea/ +.envrc +.DS_Store +.idea +.python-version + diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e4ecb923..67f5fab4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -3,13 +3,72 @@ Changelog This document describes changes between each past release. -4.2 (unreleased) -================ +5.0.0 (unreleased) +================== + +Breaking changes +---------------- + +- Drop support for Python 2 (#483) +- Drop support for Python 3.5 (#571) + +The minimum supported version is now Python 3.6 + +Added +----- + +- Allow to import previously exported json data (#518) +- Add new optional field "external link" in bill form (#429) +- Add currencies to project and bills (#541) +- Add new statistics showing monthly expenses (#526) +- Add pagination to the list of bills (#480) +- Add sorting, pagination, and searching to the admin dashboard (#538) +- Add Project History page that records all changes (#553) +- Add token-based authentication to the API (#504) +- Add translations for Hindi, Portuguese (Brazil), Tamil + +Changed +------- -- Add support for espanol latino america (es_419) - Use the external debts lib to solve settlements (#476) - Remove balance column in statistics view (#323) -- Remove requirements files in favor of setup.cfg pinning (#558) +- Make language choice persistent (#547) + +Fixed +----- + +- Improve input of email addresses when inviting people to join a project (#133) + +4.1.4 (2020-06-07) +================== + +This is a bugfix-only release. It is almost certainly the last release to support Python 2: +you should upgrade to Python 3! + +Fixed +----- + +- Fix failed installation because dependencies were not being pinned (#540, #545, #558) +- backend: Trim usernames to remove leading or trailing spaces. This avoids a situation where different names can be visually identical (#367) +- backend: Fix API to forbid project creation when the `ALLOW_PUBLIC_PROJECT_CREATION` setting is set to false (#496) +- backend: Fix crash when a localized email template is missing (#592) +- backend: Fix language code parsing (#589) +- backend: Improve error handling when sending emails (#595) +- UI: Fix datepicker that was being displayed twice on some browsers (#221) +- UI: Fix "Submit and add a new one" button that had no effect when adding a bill (#498) +- UI: Prevent bill cancellation when cancelling autocomplete (#506) +- UI: Fix responsive width of homepage on small screns (#549) +- UI: Fix color of the "Add a member" button (#499) +- UI: Fix missing HTML tag (#583) +- UI: Fix a small typo in the french project-reminder email (#486) +- UI: Fix typo on message displayed when adding a member (#575) +- UI: Fix incorrect tool-tip message about the private code (#623) + +Added +----- + +- Add translations for German, Spanish (latin-america), Norwegian (bokmål), Indonesian, Polish, Russian, Chinese, Turkish, Ukrainian +- Update translations for all languages 4.1.3 (2019-09-18) ================== diff --git a/CONTRIBUTORS b/CONTRIBUTORS index 340471df..bd4d363e 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -45,3 +45,5 @@ THANOS SIOURDAKIS Toover Xavier Mehrenberger zorun + +The manual drawings are from Coline Billon, they are under CC BY 4.0. diff --git a/Makefile b/Makefile index a6817094..63002664 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -VIRTUALENV = virtualenv --python=python3 +VIRTUALENV = python3 -m venv SPHINX_BUILDDIR = docs/_build VENV := $(shell realpath $${VIRTUAL_ENV-.venv}) PYTHON = $(VENV)/bin/python3 @@ -50,7 +50,7 @@ black: install-dev ## Run the tests .PHONY: isort isort: install-dev ## Run the tests - $(VENV)/bin/isort -rc . + $(VENV)/bin/isort . .PHONY: release release: install-dev ## Release a new version (see https://ihatemoney.readthedocs.io/en/latest/contributing.html#how-to-release) diff --git a/docs/contributing.rst b/docs/contributing.rst index 8e0d69aa..7b4f8a55 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -67,7 +67,7 @@ If System :ref:`installation-requirements` are fulfilled, you can just issue:: make serve -It will setup a `virtualenv `_, +It will setup a `Virtual environment `_, install dependencies, and run the test server. The hard way @@ -193,7 +193,7 @@ The documentation is using `sphinx `_ and its source is located inside the `docs folder `_. -Install doc dependencies (within the virtualenv, if any):: +Install doc dependencies (within the virtual environment, if any):: pip install -r docs/requirements.txt diff --git a/docs/installation.rst b/docs/installation.rst index ca2b9bdf..b33fb55d 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -1,3 +1,5 @@ +.. _installation: + Installation ############ @@ -19,9 +21,9 @@ Requirements * **Python**: either 3.6, 3.7 or 3.8 will work. * **A Backend**: to choose among MySQL, PostgreSQL, SQLite or Memory. -* **Virtualenv** (recommended): `virtualenv` package under Debian/Ubuntu. +* **Virtual environment** (recommended): `python3-venv` package under Debian/Ubuntu. -We recommend to use `virtualenv `_ but +We recommend to use `virtual environment `_ but it will work without if you prefer. If wondering about the backend, SQLite is the simplest and will work fine for @@ -31,16 +33,16 @@ most small to medium setups. .. _virtualenv-preparation: -Prepare virtualenv (recommended) -================================ +Prepare virtual environment (recommended) +========================================= Choose an installation path, here `/home/john/ihatemoney`. -Create a virtualenv:: +Create a virtual environment:: - virtualenv -p /usr/bin/python3 /home/john/ihatemoney + python3 -m venv /home/john/ihatemoney -Activate the virtualenv:: +Activate the virtual environment:: source /home/john/ihatemoney/bin/activate @@ -72,7 +74,7 @@ Configure database with MySQL/MariaDB (optional) apt install python3-dev libssl-dev -2. Install PyMySQL (within your virtualenv):: +2. Install PyMySQL (within your virtual environment):: pip install 'PyMySQL>=0.9,<0.10' @@ -85,7 +87,7 @@ Configure database with PostgreSQL (optional) .. note:: Only required if you use Postgresql. -1. Install python driver for PostgreSQL (from within your virtualenv):: +1. Install python driver for PostgreSQL (from within your virtual environment):: pip install psycopg2 diff --git a/docs/requirements.txt b/docs/requirements.txt index 3cfd193c..faa22bff 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,2 @@ -Sphinx==3.0.2 +Sphinx==3.3.0 docutils==0.16 diff --git a/docs/upgrade.rst b/docs/upgrade.rst index 39519b5d..0db0428b 100644 --- a/docs/upgrade.rst +++ b/docs/upgrade.rst @@ -15,7 +15,7 @@ General procedure *(sufficient for minor/patch upgrades)* -1. From the virtualenv (if any):: +1. From the virtual environment (if any):: pip install -U ihatemoney @@ -35,6 +35,37 @@ Version-specific instructions When upgrading from a major version to another, you **must** follow special instructions: +4.x → 5.x +--------- + +Switch to a supported version of Python ++++++++++++++++++++++++++++++++++++++++ + +.. note:: If you are already using Python ≥ 3.6, you can skip this section, no + special action is required. + +If you were running IHateMoney using Python < 3.6, you must, **before** upgrading: + +1. Ensure to have a Python ≥ 3.6 available on your system +2. Rebuild your virtual environment (if any). It will *not* alter your database nor configuration. For example, if your virtual environment is in `/home/john/ihatemoney/`:: + + rm -rf /home/john/ihatemoney + pyhton3 -m venv /home/john/ihatemoney + source /home/john/ihatemoney/bin/activate + + You might need to ``pip install`` additional dependencies if you are using one + or several of the following deployment options : + + - Gunicorn (Nginx) + - MySQL + - PostgreSQL + +If so, pick the ``pip`` commands to use in the relevant section(s) of +:ref:`installation`. + +Then follow :ref:`general-procedure` from step 1. in order to complete the update. + + 2.x → 3.x --------- @@ -58,7 +89,7 @@ development only. 1. Delete the cloned folder -.. note:: If you are using a virtualenv, then the following commands should be run inside it (see +.. note:: If you are using a virtual environment, then the following commands should be run inside it (see :ref:`virtualenv-preparation`). diff --git a/ihatemoney/api/common.py b/ihatemoney/api/common.py index 9aefa2c8..cd247cdf 100644 --- a/ihatemoney/api/common.py +++ b/ihatemoney/api/common.py @@ -85,7 +85,7 @@ class ProjectStatsHandler(Resource): class APIMemberForm(MemberForm): - """ Member is not disablable via a Form. + """Member is not disablable via a Form. But we want Member.enabled to be togglable via the API. """ diff --git a/ihatemoney/conf-templates/apache-vhost.conf.j2 b/ihatemoney/conf-templates/apache-vhost.conf.j2 index e169589a..795a05f3 100644 --- a/ihatemoney/conf-templates/apache-vhost.conf.j2 +++ b/ihatemoney/conf-templates/apache-vhost.conf.j2 @@ -11,8 +11,7 @@ WSGIProcessGroup ihatemoney WSGIApplicationGroup %{GLOBAL} - Order deny,allow - Allow from all + Require all granted Alias /static/ {{ pkg_path }}/static/ diff --git a/ihatemoney/conf-templates/ihatemoney.cfg.j2 b/ihatemoney/conf-templates/ihatemoney.cfg.j2 index 5dfb9d47..c0545912 100644 --- a/ihatemoney/conf-templates/ihatemoney.cfg.j2 +++ b/ihatemoney/conf-templates/ihatemoney.cfg.j2 @@ -8,7 +8,7 @@ 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:///var/lib/ihatemoney/ihatemoney.sqlite' +SQLALCHEMY_DATABASE_URI = 'sqlite:////var/lib/ihatemoney/ihatemoney.sqlite' SQLACHEMY_ECHO = DEBUG # Will likely become the default value in flask-sqlalchemy >=3 ; could be removed diff --git a/ihatemoney/currency_convertor.py b/ihatemoney/currency_convertor.py new file mode 100644 index 00000000..10026eea --- /dev/null +++ b/ihatemoney/currency_convertor.py @@ -0,0 +1,50 @@ +from cachetools import TTLCache, cached +import requests + + +class Singleton(type): + _instances = {} + + def __call__(cls, *args, **kwargs): + if cls not in cls._instances: + cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) + return cls._instances[cls] + + +class CurrencyConverter(object, metaclass=Singleton): + # Get exchange rates + no_currency = "XXX" + api_url = "https://api.exchangeratesapi.io/latest?base=USD" + + def __init__(self): + pass + + @cached(cache=TTLCache(maxsize=1, ttl=86400)) + def get_rates(self): + rates = requests.get(self.api_url).json()["rates"] + rates[self.no_currency] = 1.0 + return rates + + def get_currencies(self, with_no_currency=True): + rates = [ + rate + for rate in self.get_rates() + if with_no_currency or rate != self.no_currency + ] + rates.sort(key=lambda rate: "" if rate == self.no_currency else rate) + return rates + + def exchange_currency(self, amount, source_currency, dest_currency): + if ( + source_currency == dest_currency + or source_currency == self.no_currency + or dest_currency == self.no_currency + ): + return amount + + rates = self.get_rates() + source_rate = rates[source_currency] + dest_rate = rates[dest_currency] + new_amount = (float(amount) / source_rate) * dest_rate + # round to two digits because we are dealing with money + return round(new_amount, 2) diff --git a/ihatemoney/default_settings.py b/ihatemoney/default_settings.py index 4895ab98..a3cf4077 100644 --- a/ihatemoney/default_settings.py +++ b/ihatemoney/default_settings.py @@ -8,4 +8,20 @@ ACTIVATE_DEMO_PROJECT = True ADMIN_PASSWORD = "" ALLOW_PUBLIC_PROJECT_CREATION = True ACTIVATE_ADMIN_DASHBOARD = False -SUPPORTED_LANGUAGES = ["en", "fr", "de", "nl", "es_419", "nb_NO", "id"] +SUPPORTED_LANGUAGES = [ + "de", + "en", + "es_419", + "fr", + "hi", + "id", + "nb_NO", + "nl", + "pl", + "pt_BR", + "ru", + "ta", + "tr", + "uk", + "zh_Hans", +] diff --git a/ihatemoney/forms.py b/ihatemoney/forms.py index 989b3022..e4a32d09 100644 --- a/ihatemoney/forms.py +++ b/ihatemoney/forms.py @@ -8,7 +8,7 @@ from flask_wtf.file import FileAllowed, FileField, FileRequired from flask_wtf.form import FlaskForm from jinja2 import Markup from werkzeug.security import check_password_hash, generate_password_hash -from wtforms.fields.core import SelectField, SelectMultipleField +from wtforms.fields.core import Label, SelectField, SelectMultipleField from wtforms.fields.html5 import DateField, DecimalField, URLField from wtforms.fields.simple import BooleanField, PasswordField, StringField, SubmitField from wtforms.validators import ( @@ -20,8 +20,13 @@ from wtforms.validators import ( ValidationError, ) +from ihatemoney.currency_convertor import CurrencyConverter from ihatemoney.models import LoggingMode, Person, Project -from ihatemoney.utils import eval_arithmetic_expression, slugify +from ihatemoney.utils import ( + eval_arithmetic_expression, + render_localized_currency, + slugify, +) def strip_filter(string): @@ -39,6 +44,18 @@ def get_billform_for(project, set_default=True, **kwargs): """ form = BillForm(**kwargs) + if form.original_currency.data == "None": + form.original_currency.data = project.default_currency + + show_no_currency = form.original_currency.data == CurrencyConverter.no_currency + + form.original_currency.choices = [ + (currency_name, render_localized_currency(currency_name, detailed=False)) + for currency_name in form.currency_helper.get_currencies( + with_no_currency=show_no_currency + ) + ] + active_members = [(m.id, m.name) for m in project.active_members] form.payed_for.choices = form.payer.choices = active_members @@ -89,6 +106,15 @@ class EditProjectForm(FlaskForm): contact_email = StringField(_("Email"), validators=[DataRequired(), Email()]) project_history = BooleanField(_("Enable project history")) ip_recording = BooleanField(_("Use IP tracking for project history")) + currency_helper = CurrencyConverter() + default_currency = SelectField(_("Default Currency"), validators=[DataRequired()]) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.default_currency.choices = [ + (currency_name, render_localized_currency(currency_name, detailed=True)) + for currency_name in self.currency_helper.get_currencies() + ] @property def logging_preference(self): @@ -112,6 +138,7 @@ class EditProjectForm(FlaskForm): password=generate_password_hash(self.password.data), contact_email=self.contact_email.data, logging_preference=self.logging_preference, + default_currency=self.default_currency.data, ) return project @@ -125,6 +152,7 @@ class EditProjectForm(FlaskForm): project.contact_email = self.contact_email.data project.logging_preference = self.logging_preference + project.default_currency = self.default_currency.data return project @@ -199,6 +227,8 @@ class BillForm(FlaskForm): what = StringField(_("What?"), validators=[DataRequired()]) payer = SelectField(_("Payer"), validators=[DataRequired()], coerce=int) amount = CalculatorStringField(_("Amount paid"), validators=[DataRequired()]) + currency_helper = CurrencyConverter() + original_currency = SelectField(_("Currency"), validators=[DataRequired()]) external_link = URLField( _("External link"), validators=[Optional()], @@ -217,6 +247,10 @@ class BillForm(FlaskForm): bill.external_link = self.external_link.data bill.date = self.date.data bill.owers = [Person.query.get(ower, project) for ower in self.payed_for.data] + bill.original_currency = self.original_currency.data + bill.converted_amount = self.currency_helper.exchange_currency( + bill.amount, bill.original_currency, project.default_currency + ) return bill def fake_form(self, bill, project): @@ -226,17 +260,30 @@ class BillForm(FlaskForm): bill.external_link = "" bill.date = self.date bill.owers = [Person.query.get(ower, project) for ower in self.payed_for] + bill.original_currency = CurrencyConverter.no_currency + bill.converted_amount = self.currency_helper.exchange_currency( + bill.amount, bill.original_currency, project.default_currency + ) return bill - def fill(self, bill): + def fill(self, bill, project): self.payer.data = bill.payer_id self.amount.data = bill.amount self.what.data = bill.what self.external_link.data = bill.external_link + self.original_currency.data = bill.original_currency self.date.data = bill.date self.payed_for.data = [int(ower.id) for ower in bill.owers] + self.original_currency.label = Label("original_currency", _("Currency")) + self.original_currency.description = _( + "Project default: %(currency)s", + currency=render_localized_currency( + project.default_currency, detailed=False + ), + ) + def set_default(self): self.payed_for.data = self.payed_for.default @@ -265,7 +312,7 @@ class MemberForm(FlaskForm): and Person.query.filter( Person.name == field.data, Person.project == form.project, - Person.activated == True, + Person.activated, ).all() ): # NOQA raise ValidationError(_("This project already have this member")) diff --git a/ihatemoney/history.py b/ihatemoney/history.py index 26fd0335..994f00bc 100644 --- a/ihatemoney/history.py +++ b/ihatemoney/history.py @@ -103,7 +103,15 @@ def get_history(project, human_readable_names=True): if removed: changeset["owers_removed"] = (None, removed) - for (prop, (val_before, val_after),) in changeset.items(): + # Remove converted_amount if amount changed in the same way. + if ( + "amount" in changeset + and "converted_amount" in changeset + and changeset["amount"] == changeset["converted_amount"] + ): + del changeset["converted_amount"] + + for (prop, (val_before, val_after)) in changeset.items(): if human_readable_names: if prop == "payer_id": prop = "payer" diff --git a/ihatemoney/messages.pot b/ihatemoney/messages.pot index 63f16cd0..27206f83 100644 --- a/ihatemoney/messages.pot +++ b/ihatemoney/messages.pot @@ -18,6 +18,9 @@ msgstr "" msgid "Use IP tracking for project history" msgstr "" +msgid "Default Currency" +msgstr "" + msgid "Import previously exported JSON file" msgstr "" @@ -72,6 +75,9 @@ msgstr "" msgid "Amount paid" msgstr "" +msgid "Currency" +msgstr "" + msgid "External link" msgstr "" @@ -87,6 +93,10 @@ msgstr "" msgid "Submit and add a new one" msgstr "" +#, python-format +msgid "Project default: %(currency)s" +msgstr "" + msgid "Bills can't be null" msgstr "" @@ -127,6 +137,9 @@ msgstr "" msgid "Project" msgstr "" +msgid "No Currency" +msgstr "" + msgid "Too many failed login attempts, please retry later." msgstr "" @@ -144,8 +157,22 @@ msgstr "" msgid "You have just created '%(project)s' to share your expenses" msgstr "" +msgid "A reminder email has just been sent to you" +msgstr "" + +msgid "" +"We tried to send you an reminder email, but there was an error. You can " +"still use the project normally." +msgstr "" + #, python-format -msgid "%(msg_compl)sThe project identifier is %(project)s" +msgid "The project identifier is %(project)s" +msgstr "" + +msgid "" +"Sorry, there was an error while sending you an email with password reset " +"instructions. Please check the email configuration of the server or " +"contact the administrator." msgstr "" msgid "No token provided" @@ -176,6 +203,12 @@ msgstr "" msgid "Your invitations have been sent" msgstr "" +msgid "" +"Sorry, there was an error while trying to send the invitation emails. " +"Please check the email configuration of the server or contact the " +"administrator." +msgstr "" + #, python-format msgid "%(member)s has been added" msgstr "" @@ -252,7 +285,7 @@ msgstr "" msgid "delete" msgstr "" -msgid "see" +msgid "show" msgstr "" msgid "The Dashboard is currently deactivated." @@ -390,12 +423,6 @@ msgstr "" msgid "owers list" msgstr "" -msgid "Who?" -msgstr "" - -msgid "Balance" -msgstr "" - #, python-format msgid "" "\n" @@ -480,6 +507,10 @@ msgstr "" msgid "Amount" msgstr "" +#, python-format +msgid "Amount in %(currency)s" +msgstr "" + msgid "modified" msgstr "" @@ -526,8 +557,8 @@ msgid "Create" msgstr "" msgid "" -"This access code will be sent to your friends. It is stored as-is by the " -"server, so don\\'t reuse a personal password!" +"Don\\'t reuse a personal password. Choose a private code and send it to " +"your friends" msgstr "" msgid "Account manager" @@ -587,10 +618,8 @@ msgstr "" msgid "you can contribute and improve it!" msgstr "" -msgid "deactivate" -msgstr "" - -msgid "reactivate" +#, python-format +msgid "%(amount)s each" msgstr "" msgid "Invite people" @@ -631,9 +660,6 @@ msgstr "" msgid "Everyone but %(excluded)s" msgstr "" -msgid "each" -msgstr "" - msgid "No bills" msgstr "" @@ -702,6 +728,18 @@ msgstr "" msgid "To whom?" msgstr "" +msgid "Who?" +msgstr "" + +msgid "Balance" +msgstr "" + +msgid "deactivate" +msgstr "" + +msgid "reactivate" +msgstr "" + msgid "Paid" msgstr "" diff --git a/ihatemoney/migrations/versions/927ed575acbd_add_currencies.py b/ihatemoney/migrations/versions/927ed575acbd_add_currencies.py new file mode 100644 index 00000000..88b8a5b0 --- /dev/null +++ b/ihatemoney/migrations/versions/927ed575acbd_add_currencies.py @@ -0,0 +1,73 @@ +"""Add currencies + +Revision ID: 927ed575acbd +Revises: cb038f79982e +Create Date: 2020-04-25 14:49:41.136602 + +""" + +# revision identifiers, used by Alembic. +revision = "927ed575acbd" +down_revision = "cb038f79982e" + +from alembic import op +import sqlalchemy as sa +from ihatemoney.currency_convertor import CurrencyConverter + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("bill", sa.Column("converted_amount", sa.Float(), nullable=True)) + op.add_column( + "bill", + sa.Column( + "original_currency", + sa.String(length=3), + server_default=CurrencyConverter.no_currency, + nullable=True, + ), + ) + op.add_column( + "bill_version", + sa.Column("converted_amount", sa.Float(), autoincrement=False, nullable=True), + ) + op.add_column( + "bill_version", + sa.Column( + "original_currency", sa.String(length=3), autoincrement=False, nullable=True + ), + ) + op.add_column( + "project", + sa.Column( + "default_currency", + sa.String(length=3), + server_default=CurrencyConverter.no_currency, + nullable=True, + ), + ) + op.add_column( + "project_version", + sa.Column( + "default_currency", sa.String(length=3), autoincrement=False, nullable=True + ), + ) + # ### end Alembic commands ### + op.execute( + """ + UPDATE bill + SET converted_amount = amount + WHERE converted_amount IS NULL + """ + ) + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("project_version", "default_currency") + op.drop_column("project", "default_currency") + op.drop_column("bill_version", "original_currency") + op.drop_column("bill_version", "converted_amount") + op.drop_column("bill", "original_currency") + op.drop_column("bill", "converted_amount") + # ### end Alembic commands ### diff --git a/ihatemoney/models.py b/ihatemoney/models.py index 742bc8ca..7984ab76 100644 --- a/ihatemoney/models.py +++ b/ihatemoney/models.py @@ -15,6 +15,7 @@ from sqlalchemy import orm from sqlalchemy.sql import func from sqlalchemy_continuum import make_versioned, version_class from sqlalchemy_continuum.plugins import FlaskPlugin +from werkzeug.security import generate_password_hash from ihatemoney.patch_sqlalchemy_continuum import PatchedBuilder from ihatemoney.versioning import ( @@ -71,6 +72,7 @@ class Project(db.Model): members = db.relationship("Person", backref="project") query_class = ProjectQuery + default_currency = db.Column(db.String(3)) @property def _to_serialize(self): @@ -80,6 +82,7 @@ class Project(db.Model): "contact_email": self.contact_email, "logging_preference": self.logging_preference.value, "members": [], + "default_currency": self.default_currency, } balance = self.balance @@ -128,7 +131,10 @@ class Project(db.Model): { "member": member, "paid": sum( - [bill.amount for bill in self.get_member_bills(member.id).all()] + [ + bill.converted_amount + for bill in self.get_member_bills(member.id).all() + ] ), "spent": sum( [ @@ -151,7 +157,7 @@ class Project(db.Model): """ monthly = defaultdict(lambda: defaultdict(float)) for bill in self.get_bills().all(): - monthly[bill.date.year][bill.date.month] += bill.amount + monthly[bill.date.year][bill.date.month] += bill.converted_amount return monthly @property @@ -162,8 +168,7 @@ class Project(db.Model): """Return a list of transactions that could be made to settle the bill""" def prettify(transactions, pretty_output): - """ Return pretty transactions - """ + """Return pretty transactions""" if not pretty_output: return transactions pretty_transactions = [] @@ -267,9 +272,8 @@ class Project(db.Model): This method returns the status DELETED or DEACTIVATED regarding the changes made. """ - try: - person = Person.query.get(member_id, self) - except orm.exc.NoResultFound: + person = Person.query.get(member_id, self) + if person is None: return None if not person.has_bills(): db.session.delete(person) @@ -325,14 +329,57 @@ class Project(db.Model): def __repr__(self): return f"" + @staticmethod + def create_demo_project(): + project = Project( + id="demo", + name="demonstration", + password=generate_password_hash("demo"), + contact_email="demo@notmyidea.org", + default_currency="EUR", + ) + db.session.add(project) + db.session.commit() + + members = {} + for name in ("Amina", "Georg", "Alice"): + person = Person() + person.name = name + person.project = project + person.weight = 1 + db.session.add(person) + + members[name] = person + + db.session.commit() + + operations = ( + ("Georg", 200, ("Amina", "Georg", "Alice"), "Food shopping"), + ("Alice", 20, ("Amina", "Alice"), "Beer !"), + ("Amina", 50, ("Amina", "Alice", "Georg"), "AMAP"), + ) + for (payer, amount, owers, subject) in operations: + bill = Bill() + bill.payer_id = members[payer].id + bill.what = subject + bill.owers = [members[name] for name in owers] + bill.amount = amount + bill.original_currency = "EUR" + bill.converted_amount = amount + + db.session.add(bill) + + db.session.commit() + return project + 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() + .filter(Person.project_id == project.id) + .one_or_none() ) def get(self, id, project=None): @@ -340,8 +387,8 @@ class Person(db.Model): project = g.project return ( Person.query.filter(Person.id == id) - .filter(Project.id == project.id) - .one() + .filter(Person.project_id == project.id) + .one_or_none() ) query_class = PersonQuery @@ -432,6 +479,9 @@ class Bill(db.Model): what = db.Column(db.UnicodeText) external_link = db.Column(db.UnicodeText) + original_currency = db.Column(db.String(3)) + converted_amount = db.Column(db.Float) + archive = db.Column(db.Integer, db.ForeignKey("archive.id")) @property @@ -445,9 +495,11 @@ class Bill(db.Model): "creation_date": self.creation_date, "what": self.what, "external_link": self.external_link, + "original_currency": self.original_currency, + "converted_amount": self.converted_amount, } - def pay_each(self): + def pay_each_default(self, amount): """Compute what each share has to pay""" if self.owers: weights = ( @@ -455,13 +507,16 @@ class Bill(db.Model): .join(billowers, Bill) .filter(Bill.id == self.id) ).scalar() - return self.amount / weights + return amount / weights else: return 0 def __str__(self): return self.what + def pay_each(self): + return self.pay_each_default(self.converted_amount) + def __repr__(self): return ( f" .delete, .bill-actions > .edit, -.bill-actions > .see { +.bill-actions > .show { font-size: 0px; display: block; width: 16px; @@ -321,8 +333,8 @@ footer .footer-left { background: url("../images/edit.png") no-repeat right; } -.bill-actions > .see { - background: url("../images/see.png") no-repeat right; +.bill-actions > .show { + background: url("../images/show.png") no-repeat right; } #bill_table, @@ -342,7 +354,7 @@ footer .footer-left { .project-actions > .delete, .project-actions > .edit, -.project-actions > .see { +.project-actions > .show { font-size: 0px; display: block; width: 16px; @@ -360,8 +372,8 @@ footer .footer-left { background: url("../images/edit.png") no-repeat right; } -.project-actions > .see { - background: url("../images/see.png") no-repeat right; +.project-actions > .show { + background: url("../images/show.png") no-repeat right; } .history_icon > .delete, @@ -535,11 +547,17 @@ tr:hover .extra-info { width: 1.2em; /* protection for IE11 */ } +.icon.high svg { + height: 2em; + margin-top: -0.2em; + margin-bottom: -0.2em; +} + .download-project .icon svg { fill: white; } -.icon.plus svg { +.icon.before-text svg { margin-right: 3px; } footer .icon svg { @@ -562,6 +580,10 @@ footer .icon svg { width: 200px; } +.hiddenpswp { + display: none; +} + #history_warnings { margin-top: 30px; } diff --git a/ihatemoney/static/images/add.png b/ihatemoney/static/images/add.png index 262891bf..67fbf44a 100644 Binary files a/ihatemoney/static/images/add.png and b/ihatemoney/static/images/add.png differ diff --git a/ihatemoney/static/images/bill.svg b/ihatemoney/static/images/bill.svg new file mode 100644 index 00000000..7272fa22 --- /dev/null +++ b/ihatemoney/static/images/bill.svg @@ -0,0 +1,88 @@ + + + + +Created by potrace 1.14, written by Peter Selinger 2001-2017 + + + + + + + + + + + + + + + + + + + diff --git a/ihatemoney/static/images/hand-holding-heart.svg b/ihatemoney/static/images/hand-holding-heart.svg deleted file mode 100644 index 65ee45e7..00000000 --- a/ihatemoney/static/images/hand-holding-heart.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/ihatemoney/static/images/indicate.svg b/ihatemoney/static/images/indicate.svg new file mode 100644 index 00000000..1d6caae6 --- /dev/null +++ b/ihatemoney/static/images/indicate.svg @@ -0,0 +1,88 @@ + + + + +Created by potrace 1.14, written by Peter Selinger 2001-2017 + + + + + + + + + + + + + diff --git a/ihatemoney/static/images/read.svg b/ihatemoney/static/images/read.svg new file mode 100644 index 00000000..e35bf59e --- /dev/null +++ b/ihatemoney/static/images/read.svg @@ -0,0 +1,55 @@ + + + + +Created by potrace 1.14, written by Peter Selinger 2001-2017 + + + + + + + diff --git a/ihatemoney/static/images/see.png b/ihatemoney/static/images/see.png deleted file mode 100644 index 741e8292..00000000 Binary files a/ihatemoney/static/images/see.png and /dev/null differ diff --git a/ihatemoney/static/images/share.svg b/ihatemoney/static/images/share.svg new file mode 100644 index 00000000..cfdc9fe5 --- /dev/null +++ b/ihatemoney/static/images/share.svg @@ -0,0 +1,108 @@ + + + + +Created by potrace 1.14, written by Peter Selinger 2001-2017 + + + + + + + + + + + + + + + diff --git a/ihatemoney/static/images/show.png b/ihatemoney/static/images/show.png new file mode 100644 index 00000000..5b5725a4 Binary files /dev/null and b/ihatemoney/static/images/show.png differ diff --git a/ihatemoney/static/images/sort_asc.png b/ihatemoney/static/images/sort_asc.png index e1ba61a8..47586a22 100644 Binary files a/ihatemoney/static/images/sort_asc.png and b/ihatemoney/static/images/sort_asc.png differ diff --git a/ihatemoney/static/images/sort_asc_disabled.png b/ihatemoney/static/images/sort_asc_disabled.png index fb11dfe2..4fb7b295 100644 Binary files a/ihatemoney/static/images/sort_asc_disabled.png and b/ihatemoney/static/images/sort_asc_disabled.png differ diff --git a/ihatemoney/static/images/sort_both.png b/ihatemoney/static/images/sort_both.png index af5bc7c5..4b70f96e 100644 Binary files a/ihatemoney/static/images/sort_both.png and b/ihatemoney/static/images/sort_both.png differ diff --git a/ihatemoney/static/images/sort_desc.png b/ihatemoney/static/images/sort_desc.png index 0e156deb..7fac2b63 100644 Binary files a/ihatemoney/static/images/sort_desc.png and b/ihatemoney/static/images/sort_desc.png differ diff --git a/ihatemoney/static/images/sort_desc_disabled.png b/ihatemoney/static/images/sort_desc_disabled.png index c9fdd8a1..c0b69cfa 100644 Binary files a/ihatemoney/static/images/sort_desc_disabled.png and b/ihatemoney/static/images/sort_desc_disabled.png differ diff --git a/ihatemoney/static/photoswipe/default-skin/default-skin.css b/ihatemoney/static/photoswipe/default-skin/default-skin.css new file mode 100644 index 00000000..c9616326 --- /dev/null +++ b/ihatemoney/static/photoswipe/default-skin/default-skin.css @@ -0,0 +1,482 @@ +/*! PhotoSwipe Default UI CSS by Dmitry Semenov | photoswipe.com | MIT license */ +/* + + Contents: + + 1. Buttons + 2. Share modal and links + 3. Index indicator ("1 of X" counter) + 4. Caption + 5. Loading indicator + 6. Additional styles (root element, top bar, idle state, hidden state, etc.) + +*/ +/* + + 1. Buttons + + */ +/* {{ _("delete") }} @@ -122,6 +124,9 @@ {{ input(form.what, inline=True) }} {{ input(form.payer, inline=True, class="form-control custom-select") }} {{ input(form.amount, inline=True) }} + {% if g.project.default_currency != "XXX" %} + {{ input(form.original_currency, inline=True) }} + {% endif %} {{ input(form.external_link, inline=True) }}
diff --git a/ihatemoney/templates/history.html b/ihatemoney/templates/history.html index 58f73a3e..1b25b736 100644 --- a/ihatemoney/templates/history.html +++ b/ihatemoney/templates/history.html @@ -99,23 +99,7 @@ {% block sidebar %}
- - - - - - - - {% set balance = g.project.balance %} - {% for member in g.project.members | sort(attribute='name') if member.activated or balance[member.id]|round(2) != 0 %} - - - - - {% endfor %} -
{{ _("Who?") }}{{ _("Balance") }}
{{ member.name }} - {% if balance[member.id]|round(2) > 0 %}+{% endif %}{{ "%.2f" | format(balance[member.id]) }} -
+ {{ balance_table(show_weight=False, show_header=True) }}
{% endblock %} @@ -251,7 +235,11 @@ {% elif event.prop_changed == "amount" %} {{ bill_property_change(event, _("amount")) }} {% elif event.prop_changed == "date" %} - {{ bill_property_change(event, _("date")) }} + {{ simple_property_change(event, _("Date")) }} + {% elif event.prop_changed == "original_currency" %} + {{ simple_property_change(event, _("Currency")) }} + {% elif event.prop_changed == "converted_amount" %} + {{ simple_property_change(event, _("Amount in %(currency)s", currency=g.project.default_currency)) }} {% else %} {% if event.object_type == "Bill" %} {% set bill_description=em_surround(event.object_desc) %} @@ -293,7 +281,7 @@
- {{ static_include("images/hand-holding-heart.svg") | safe }} + {{ static_include("images/bill.svg") | safe }}

{{ _('Nothing to list')}}

{{ _("Someone probably cleared the project history.") }} diff --git a/ihatemoney/templates/home.html b/ihatemoney/templates/home.html index bac8abe1..cad87390 100644 --- a/ihatemoney/templates/home.html +++ b/ihatemoney/templates/home.html @@ -9,14 +9,23 @@ {{ _("Try out the demo") }} {% endif %} + {% if g.lang == 'fr' %} + ou Voir la BD explicative + + {% endif %}

-

- {{ _("You're sharing a house?") }}
- {{ _("Going on holidays with friends?") }}
- {{ _("Simply sharing money with others?") }}
- {{ _("We can help!") }} -

+ + + +
+ {{ _("You're sharing a house?") }}
+ {{ _("Going on holidays with friends?") }}
+ {{ _("Simply sharing money with others?") }}
+ {{ _("We can help!") }} +
+ +
@@ -82,7 +91,7 @@ {% endblock %} {% block js %} $('#creation-form #password').tooltip({ - title: '{{ _("This access code will be sent to your friends. It is stored as-is by the server, so don\\'t reuse a personal password!")}}', + title: '{{ _("Don\\'t reuse a personal password. Choose a private code and send it to your friends")}}', trigger: 'focus', placement: 'right' }); diff --git a/ihatemoney/templates/invitation_mail.fr.j2 b/ihatemoney/templates/invitation_mail.fr.j2 index 197edcca..a79553df 100644 --- a/ihatemoney/templates/invitation_mail.fr.j2 +++ b/ihatemoney/templates/invitation_mail.fr.j2 @@ -1,11 +1,11 @@ Salut, -Quelqu'un avec l'adresse "{{ g.project.contact_email }}" vous à invité à partager vos dépenses pour "{{ g.project.name }}". +Quelqu'un dont l'adresse email est {{ g.project.contact_email }} vous a invité à partager vos dépenses pour « {{ g.project.name }} ». -C'est aussi simple que de dire qui à payé pour quoi, pour qui, et combien celà à coûté, on s’occupe du reste. +Il suffit de renseigner qui a payé pour quoi, pour qui, combien ça a coûté, et on s’occupe du reste. -Vous pouvez vous authentifier avec le lien suivant: {{ url_for(".authenticate", _external=True, token=g.project.generate_token()) }}. -Une fois authentifié, vous pouvez utiliser le lien suivant qui est plus facile à mémoriser: {{ url_for(".list_bills", _external=True) }} -Si votre cookie est supprimé ou si vous vous déconnectez, vous devrez vous authentifier à nouveau en utilisant le premier lien. +Vous pouvez vous connecter grâce à ce lien : {{ url_for(".authenticate", _external=True, token=g.project.generate_token()) }}. +Une fois connecté, vous pourrez utiliser le lien suivant qui est plus facile à mémoriser : {{ url_for(".list_bills", _external=True) }} +Si vous êtes déconnecté volontairement ou non, vous devrez utiliser à nouveau le premier lien. -Have fun, +Have fun ! diff --git a/ihatemoney/templates/layout.html b/ihatemoney/templates/layout.html index eaf13a6e..1e16c7e2 100644 --- a/ihatemoney/templates/layout.html +++ b/ihatemoney/templates/layout.html @@ -16,6 +16,7 @@ {%- endif %} + {% block head %}{% endblock %} + {% if g.lang == 'fr' %}{% include "showcase.html" %}{% endif %}
- {% for message in get_flashed_messages() %} -
{{ message }}
- {% endfor %} + {% for category, message in get_flashed_messages(with_categories=true) %} + {% if category == "message" %}{# Default category for flash(msg) #} +
{{ message }}
+ {% else %} +
{{ message }}
+ {% endif %} + {% endfor %}
{% block footer %} @@ -138,7 +144,7 @@ {{ static_include("images/git.svg") | safe }} - + {{ static_include("images/mobile-alt.svg") | safe }} diff --git a/ihatemoney/templates/list_bills.html b/ihatemoney/templates/list_bills.html index 0f2a68a5..12c50727 100644 --- a/ihatemoney/templates/list_bills.html +++ b/ihatemoney/templates/list_bills.html @@ -1,5 +1,9 @@ {% extends "sidebar_table_layout.html" %} +{%- macro bill_amount(bill, currency=bill.original_currency, amount=bill.amount) %} + {{ amount|currency(currency) }} ({{ _("%(amount)s each", amount=bill.pay_each_default(amount)|currency(currency)) }}) +{% endmacro -%} + {% block title %} - {{ g.project.name }}{% endblock %} {% block js %} {% if add_bill %} $('#new-bill > a').click(); {% endif %} @@ -41,36 +45,18 @@
- - {% set balance = g.project.balance %} - {% for member in g.project.members | sort(attribute='name') if member.activated or balance[member.id]|round(2) != 0 %} - - - {% if member.activated %} - - {% else %} - - {% endif %} - - - {% endfor %} -
{{ member.name }} - (x{{ member.weight|minimal_round(1) }}) - -
-
-
-
-
-
-
- {% if balance[member.id]|round(2) > 0 %}+{% endif %}{{ "%.2f" | format(balance[member.id]) }} -
+ {{ balance_table(member_edit=True) }}
@@ -79,7 +65,7 @@ {% block content %} - {{ static_include("images/plus.svg") | safe }} + {{ static_include("images/plus.svg") | safe }} {{ _("Add a new bill") }} @@ -111,7 +97,14 @@
- + + + {% for bill in bills.items %} @@ -130,12 +123,17 @@ {%- else -%} {{ bill.owers|join(', ', 'name') }} {%- endif %} - + @@ -161,7 +159,7 @@
- {{ static_include("images/hand-holding-heart.svg") | safe }} + {{ static_include("images/bill.svg") | safe }}

{{ _('No bills')}}

{{ _("Nothing to list yet.")}}
diff --git a/ihatemoney/templates/password_reminder.fr.j2 b/ihatemoney/templates/password_reminder.fr.j2 index d4fbc2d5..17c52c4d 100644 --- a/ihatemoney/templates/password_reminder.fr.j2 +++ b/ihatemoney/templates/password_reminder.fr.j2 @@ -4,4 +4,4 @@ Vous avez demandé à réinitialiser le mot de passe du projet suivant : "{{ pro Vous pouvez le réinitialiser ici : {{ url_for(".reset_password", _external=True, token=project.generate_token(expiration=3600)) }}. Ce lien est seulement valide pendant 1 heure. -Faites en bon usage ! +Faites-en bon usage ! diff --git a/ihatemoney/templates/reminder_mail.fr.j2 b/ihatemoney/templates/reminder_mail.fr.j2 index 4c15de0b..1a7a0970 100644 --- a/ihatemoney/templates/reminder_mail.fr.j2 +++ b/ihatemoney/templates/reminder_mail.fr.j2 @@ -1,9 +1,9 @@ -Hey, +Salut, Vous venez de créer le projet "{{ g.project.name }}" pour partager vos dépenses. -Vous pouvez y accéder ici: {{ url_for(".list_bills", _external=True) }} (l'identifiant est {{ g.project.id }}). -Si vous voulez partager ce projet avec vos amis, vous pouvez partager son identifiant et son code d'accès avec eux ou leur envoyer une invitation avec le lien suivant : +Vous pouvez y accéder ici : {{ url_for(".list_bills", _external=True) }} (l'identifiant est {{ g.project.id }}). +Si vous voulez partager ce projet avec vos amis, vous pouvez soit leur transmettre l'identifiant et le code d'accès, soit leur envoyer une invitation personnelle grâce au lien suivant : {{ url_for(".invite", _external=True) }} -Faites en bon usage ! +Faites-en bon usage ! diff --git a/ihatemoney/templates/settle_bills.html b/ihatemoney/templates/settle_bills.html index 7ec5e290..601156c6 100644 --- a/ihatemoney/templates/settle_bills.html +++ b/ihatemoney/templates/settle_bills.html @@ -2,17 +2,7 @@ {% block sidebar %}

-
{{ _("When?") }}{{ _("Who paid?") }}{{ _("For what?") }}{{ _("For whom?") }}{{ _("How much?") }}{{ _("Actions") }}
{{ _("When?") }} + {{ _("Who paid?") }} + {{ _("For what?") }} + {{ _("For whom?") }} + {{ _("How much?") }} + {{ _("Actions") }}
{{ "%0.2f"|format(bill.amount) }} ({{ "%0.2f"|format(bill.pay_each()) }} {{ _("each") }}) + + {{ bill_amount(bill) }} + + {{ _('edit') }} {{ _('delete') }} {% if bill.external_link %} - {{ _('see') }} + {{ _('show') }} {% endif %}
- {% set balance = g.project.balance %} - {% for member in g.project.members | sort(attribute='name') if member.activated or balance[member.id]|round(2) != 0 %} - - - - - {% endfor %} -
{{ member.name }} - {% if balance[member.id]|round(2) > 0 %}+{% endif %}{{ "%.2f" | format(balance[member.id]) }} -
+ {{ balance_table(show_weight=False) }}
{% endblock %} @@ -25,7 +15,7 @@ {{ bill.ower }} {{ bill.receiver }} - {{ "%0.2f"|format(bill.amount) }} + {{ bill.amount|currency }} {% endfor %} diff --git a/ihatemoney/templates/showcase.html b/ihatemoney/templates/showcase.html new file mode 100644 index 00000000..cd9a371e --- /dev/null +++ b/ihatemoney/templates/showcase.html @@ -0,0 +1,67 @@ + + diff --git a/ihatemoney/templates/sidebar_table_layout.html b/ihatemoney/templates/sidebar_table_layout.html index 9d588a31..b25a3d68 100644 --- a/ihatemoney/templates/sidebar_table_layout.html +++ b/ihatemoney/templates/sidebar_table_layout.html @@ -1,5 +1,49 @@ {% extends "layout.html" %} +{% macro balance_table(show_weight = True, show_header = False, member_edit = False) %} + + {%- set balance = g.project.balance %} + {%- if show_header %} + + + + + + + {%- endif %} + {%- for member in g.project.members | sort(attribute='name') if member.activated or balance[member.id]|round(2) != 0 %} + + + {%- if member_edit %} + {%- if member.activated %} + + {%- else %} + + {%- endif %} + {%- endif %} + + + {%- endfor %} +
{{ _("Who?") }}{{ _("Balance") }}
{{ member.name }} + {%- if show_weight -%} + (x{{ member.weight|minimal_round(1) }}) + {%- endif -%} + +
+ +
+
+ +
+
+
+ +
+
+ {% if balance[member.id] | round(2) > 0 %}+{% endif %}{{ balance[member.id]|currency }} +
+{% endmacro %} + {% block body %}