diff --git a/.gitignore b/.gitignore index 1b9de4d9..9e3c42ce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -ihatemoney.cfg *.pyc *.egg-info dist @@ -10,5 +9,11 @@ dist build .vscode .env -.pytest_cache - +.pytest_cache +ihatemoney/budget.db +.idea/ +.envrc +.DS_Store +.idea +.python-version + diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 00000000..ba73fb7a --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,13 @@ +[settings] +# Needed for black compatibility +multi_line_output=3 +include_trailing_comma=True +force_grid_wrap=0 +line_length=88 +combine_as_imports=True + +# If set, imports will be sorted within their section independent to the import_type. +force_sort_within_sections=True + +# skip +skip_glob=.local,**/migrations/**,**/node_modules/**,**/node-forge/** diff --git a/.travis.yml b/.travis.yml index c18d65f8..ca72fb95 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,9 @@ sudo: false language: python python: - - "3.5" - "3.6" - "3.7" + - "3.8" script: tox install: - pip install tox-travis diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 76831ccb..e4ecb923 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,7 +9,7 @@ This document describes changes between each past release. - 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) 4.1.3 (2019-09-18) ================== diff --git a/CONTRIBUTORS b/CONTRIBUTORS index e87a335f..bd4d363e 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -7,20 +7,27 @@ Adrien CLERC Alexandre Avenel Alexis Métaireau Allan Nordhøy +am97 +Andrew Dickinson Arnaud Bos Baptiste Jonglez Benjamin Bouvier Berteh +bmatticus Brice Maron Byron Ullauri Carey Metcalfe Daniel Schreiber +DavidRThrashJr donkers +Edwin Smulders Elizabeth Sherrock +eMerzh Feth AREZKI Frédéric Sureau Glandos Heimen Stoffels +James Leong Jocelyn Delalande Lucas Verney Luc Didry @@ -37,5 +44,6 @@ Richard Coates THANOS SIOURDAKIS Toover Xavier Mehrenberger +zorun The manual drawings are from Coline Billon, they are under CC BY 4.0. diff --git a/MANIFEST.in b/MANIFEST.in index 74ea23b1..d47086be 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,3 @@ include *.rst recursive-include ihatemoney *.rst *.py *.yaml *.po *.mo *.html *.css *.js *.eot *.svg *.woff *.txt *.png *.ini *.cfg *.j2 -include LICENSE CONTRIBUTORS CHANGELOG.rst requirements.txt +include LICENSE CONTRIBUTORS CHANGELOG.rst diff --git a/Makefile b/Makefile index 42b0e263..a6817094 100644 --- a/Makefile +++ b/Makefile @@ -11,10 +11,10 @@ ZOPFLIPNG := zopflipng .PHONY: all all: install ## Alias for install .PHONY: install -install: virtualenv $(INSTALL_STAMP) ## Install dependencies +install: virtualenv setup.cfg $(INSTALL_STAMP) ## Install dependencies $(INSTALL_STAMP): $(VENV)/bin/pip install -U pip - $(VENV)/bin/pip install -r requirements.txt + $(VENV)/bin/pip install -e . touch $(INSTALL_STAMP) .PHONY: virtualenv @@ -23,9 +23,9 @@ $(PYTHON): $(VIRTUALENV) $(VENV) .PHONY: install-dev -install-dev: $(INSTALL_STAMP) $(DEV_STAMP) ## Install development dependencies -$(DEV_STAMP): $(PYTHON) dev-requirements.txt - $(VENV)/bin/pip install -Ur dev-requirements.txt +install-dev: virtualenv setup.cfg $(INSTALL_STAMP) $(DEV_STAMP) ## Install development dependencies +$(DEV_STAMP): $(PYTHON) + $(VENV)/bin/pip install -Ue .[dev] touch $(DEV_STAMP) .PHONY: remove-install-stamp @@ -41,11 +41,19 @@ serve: install ## Run the ihatemoney server $(PYTHON) -m ihatemoney.manage runserver .PHONY: test -test: $(DEV_STAMP) ## Run the tests +test: install-dev ## Run the tests $(VENV)/bin/tox +.PHONY: black +black: install-dev ## Run the tests + $(VENV)/bin/black --target-version=py34 . + +.PHONY: isort +isort: install-dev ## Run the tests + $(VENV)/bin/isort -rc . + .PHONY: release -release: $(DEV_STAMP) ## Release a new version (see https://ihatemoney.readthedocs.io/en/latest/contributing.html#how-to-release) +release: install-dev ## Release a new version (see https://ihatemoney.readthedocs.io/en/latest/contributing.html#how-to-release) $(VENV)/bin/fullrelease .PHONY: compress-assets @@ -76,13 +84,6 @@ create-empty-database-revision: ## Create an empty database revision @read -p "Please enter a message describing this revision: " rev_message; \ $(PYTHON) -m ihatemoney.manage db revision -d ihatemoney/migrations -m "$${rev_message}" -.PHONY: build-requirements -build-requirements: ## Save currently installed packages to requirements.txt - $(VIRTUALENV) $(TEMPDIR) - $(TEMPDIR)/bin/pip install -U pip - $(TEMPDIR)/bin/pip install -Ue "." - $(TEMPDIR)/bin/pip freeze | grep -v -- '-e' > requirements.txt - .PHONY: clean clean: ## Destroy the virtual environment rm -rf .venv diff --git a/README.rst b/README.rst index c2166955..84d73b4a 100644 --- a/README.rst +++ b/README.rst @@ -5,6 +5,10 @@ I hate money :target: https://travis-ci.org/spiral-project/ihatemoney :alt: Travis CI Build Status +.. image:: https://hosted.weblate.org/widgets/i-hate-money/-/i-hate-money/svg-badge.svg + :target: https://hosted.weblate.org/engage/i-hate-money/?utm_source=widget + :alt: Translation status from Weblate + *I hate money* is a web application made to ease shared budget management. It keeps track of who bought what, when, and for whom; and helps to settle the bills. @@ -21,7 +25,7 @@ encouraged to do so. Requirements ============ -* **Python**: 3.5, 3.6, 3.7. +* **Python**: 3.6, 3.7, 3.8. * **Backends**: MySQL, PostgreSQL, SQLite, Memory. Contributing @@ -31,3 +35,9 @@ Do you wish to contribute to IHateMoney? Fantastic! There's a lot of very useful help on the official `contributing `_ page. +Translation status +================== + +.. image:: https://hosted.weblate.org/widgets/i-hate-money/-/i-hate-money/multi-blue.svg + :target: https://hosted.weblate.org/engage/i-hate-money/?utm_source=widget + :alt: Translation status for each language diff --git a/dev-requirements.txt b/dev-requirements.txt deleted file mode 100644 index 9163cf81..00000000 --- a/dev-requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -zest.releaser -tox -pytest -flake8 -Flask-Testing -black ; python_version >= '3.6' diff --git a/docs/conf.py b/docs/conf.py index 82c0e03d..1ec26a5d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,6 +1,3 @@ -# coding: utf8 -import sys, os - templates_path = ["_templates"] source_suffix = ".rst" master_doc = "index" diff --git a/docs/contributing.rst b/docs/contributing.rst index a810d121..8e0d69aa 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -1,14 +1,64 @@ Contributing ############ -Setup a dev environment -======================= +.. _how-to-contribute: -You must develop on top of the git master branch:: +How to contribute +================= + +You would like to contribute? First, thanks a bunch! This project is a small +project with just a few people behind it, so any help is appreciated! + +There are different ways to help us, regarding if you are a designer, +a developer or an user. + +As a developer +-------------- + +If you want to contribute code, you can write it and then issue a pull request +on github. To get started, please read :ref:`setup-dev-environment` and +:ref:`contributing-developer`. + +As a designer / Front-end developer +----------------------------------- + +Feel free to provide mockups, or to involve yourself in the discussions +happening on the GitHub issue tracker. All ideas are welcome. Of course, if you +know how to implement them, feel free to fork and make a pull request. + +As a translator +--------------- + +If you're able to translate Ihatemoney in your own language, +head over to `the website we use for translations `_ +and start translating. + +All the heavy lifting will be done automatically, and your strings will +eventually be integrated. + +Once a language is ready to be integrated, add it to the +``SUPPORTED_LANGUAGES`` list, in ``ihatemoney/default_settings.py``. + +End-user +-------- + +You are using the application and found a bug? You have some ideas about how to +improve the project? Please tell us `by filling a new issue `_. +Or, if you prefer, you can send me an e-mail to `alexis@notmyidea.org` and I +will update the issue tracker with your feedback. + +Thanks again! + +.. _setup-dev-environment: + +Set up a dev environment +======================== + +You must develop on top of the Git master branch:: git clone https://github.com/spiral-project/ihatemoney.git -Then you need to build your dev environments. Choose your way… +Then you need to build your dev environment. Choose your way… The quick way ------------- @@ -23,10 +73,8 @@ install dependencies, and run the test server. The hard way ------------ -Alternatively, you can also use the `requirements.txt` file to install the -dependencies yourself. That would be:: +Alternatively, you can use pip to install dependencies yourself. That would be:: - pip install -r requirements.txt pip install -e . And then run the application:: @@ -43,28 +91,10 @@ It's as simple as that! Updating -------- -In case you want to update to newer versions (from git), you can just run the "update" command:: +In case you want to update to newer versions (from Git), you can just run the "update" command:: make update -Create database migrations --------------------------- - -In case you need to modify the database schema, first update the models in -``ihatemoney/models.py``. Then run the following command to create a new -database revision file:: - - make create-database-revision - -If your changes are simple enough, the generated script will be populated with -the necessary migrations steps. You can edit the generated script. eg: to add -data migrations. - -For complex migrations, it is recommended to start from an empty revision file -which can be created with the following command:: - - make create-empty-database-revision - Useful settings ---------------- @@ -80,64 +110,81 @@ Then before running the application, declare its path with :: export IHATEMONEY_SETTINGS_FILE_PATH="$(pwd)/settings.cfg" -How to contribute -================= +.. _contributing-developer: -You would like to contribute? First, thanks a bunch! This project is a small -project with just a few people behind it, so any help is appreciated! +Contributing as a developer +=========================== -There are different ways to help us, regarding if you are a designer, -a developer or an user. +All code contributions should be submitted as Pull Requests on the +`github project `_. -As a developer --------------- +Below are some points that you should check to help you prepare your Pull Request. -If you want to contribute code, you can write it and then issue a pull request -on github. Please, think about updating and running the tests before asking for -a pull request as it will help us to maintain the code clean and running. +Running tests +------------- -To do so:: +Please, think about updating and running the tests before asking for a pull request +as it will help us to maintain the code clean and running. + +To run the tests:: make test -We are using the `black `_ formatter -for all the python files in this project. Be sure to run it locally on your -files. To do so, just run:: +Tests can be edited in ``ihatemoney/tests/tests.py``. If some test cases fail because +of your changes, first check whether your code correctly handle these cases. +If you are confident that your code is correct and that the test cases simply need +to be updated to match your changes, update the test cases and send them as part of +your pull request. - black ihatemoney +If you are introducing a new feature, you need to either add tests to existing classes, +or add a new class (if your new feature is significantly different from existing code). -You can also integrate it with your dev environment (as a *format-on-save* -hook, for instance). - -As a designer / Front-end developer ------------------------------------ - -Feel free to provide us mockups or to involve yourself into the discussions -hapenning on the github issue tracker. All ideas are welcome. Of course, if you -know how to implement them, feel free to fork and make a pull request. - -As a translator +Formatting code --------------- -If you're able to translate Ihatemoney in your own language, -head over to `the website we use for translations `_ -and start translating! +We are using `black `_ and +`isort `_ formatters for all the Python +files in this project. Be sure to run it locally on your files. +To do so, just run:: -All the heavy lifting will be done automatically, and your strings will -eventually be integrated. + make black isort -Once a language is ready to be integrated, add it to the -``SUPPORTED_LANGUAGES`` list, in ``ihatemoney/default_settings.py``. +You can also integrate them with your dev environment (as a *format-on-save* +hook, for instance). -End-user --------- +Creating database migrations +---------------------------- -You are using the application and found a bug? You have some ideas about how to -improve the project? Please tell us `by filling a new issue `_. -Or, if you prefer, you can send me an email to `alexis@notmyidea.org` and I -will update the issue tracker with your feedback. +In case you need to modify the database schema, first make sure that you have +an up-to-date database by running the dev server at least once (the quick way +or the hard way, see above). The dev server applies all existing migrations +when starting up. + +You can now update the models in ``ihatemoney/models.py``. Then run the following +command to create a new database revision file:: + + make create-database-revision + +If your changes are simple enough, the generated script will be populated with +the necessary migrations steps. You can view and edit the generated script, which +is useful to review that the expected model changes have been properly detected. +Usually the auto-detection works well in most cases, but you can of course edit the +script to fix small issues. You could also edit the script to add data migrations. + +When you are done with your changes, don't forget to add the migration script to +your final git commit! + +If the migration script looks completely wrong, remove the script and start again +with an empty database. The simplest way is to remove or rename the dev database +located at ``/tmp/ihatemoney.db``, and run the dev server at least once. + +For complex migrations, it is recommended to start from an empty revision file +which can be created with the following command:: + + make create-empty-database-revision + +You then need to write the migration steps yourself. -Thanks again! How to build the documentation ? ================================ @@ -150,7 +197,7 @@ Install doc dependencies (within the virtualenv, if any):: pip install -r docs/requirements.txt -And to produce html doc in `docs/_output` folder:: +And to produce a HTML doc in the `docs/_output` folder:: cd docs/ make html @@ -173,7 +220,7 @@ In order to prepare a new release, we are following the following steps: make compress-assets - Build the translations:: - + make update-translations make build-translations diff --git a/docs/installation.rst b/docs/installation.rst index 4994499e..ca2b9bdf 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -17,7 +17,7 @@ Requirements «Ihatemoney» depends on: -* **Python**: either 3.5, 3.6 or 3.7 will work. +* **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. diff --git a/docs/requirements.txt b/docs/requirements.txt index 75b31607..67e98eb8 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,2 @@ -Sphinx==1.5.5 -docutils==0.13.1 +Sphinx==3.0.3 +docutils==0.16 diff --git a/ihatemoney/api/common.py b/ihatemoney/api/common.py index 728d2a8e..9aefa2c8 100644 --- a/ihatemoney/api/common.py +++ b/ihatemoney/api/common.py @@ -1,12 +1,12 @@ -# coding: utf8 -from flask import request, current_app +from functools import wraps + +from flask import current_app, request from flask_restful import Resource, abort +from werkzeug.security import check_password_hash from wtforms.fields.core import BooleanField -from ihatemoney.models import db, Project, Person, Bill -from ihatemoney.forms import ProjectForm, EditProjectForm, MemberForm, get_billform_for -from werkzeug.security import check_password_hash -from functools import wraps +from ihatemoney.forms import EditProjectForm, MemberForm, ProjectForm, get_billform_for +from ihatemoney.models import Bill, Person, Project, db def need_auth(f): diff --git a/ihatemoney/api/v1/resources.py b/ihatemoney/api/v1/resources.py index 821ba2bd..dc1708ce 100644 --- a/ihatemoney/api/v1/resources.py +++ b/ihatemoney/api/v1/resources.py @@ -1,17 +1,16 @@ -# coding: utf8 from flask import Blueprint -from flask_restful import Api from flask_cors import CORS +from flask_restful import Api from ihatemoney.api.common import ( - ProjectsHandler, - ProjectHandler, - TokenHandler, - MemberHandler, - ProjectStatsHandler, - MembersHandler, BillHandler, BillsHandler, + MemberHandler, + MembersHandler, + ProjectHandler, + ProjectsHandler, + ProjectStatsHandler, + TokenHandler, ) api = Blueprint("api", __name__, url_prefix="/api") diff --git a/ihatemoney/budget.db b/ihatemoney/budget.db deleted file mode 100644 index e69de29b..00000000 diff --git a/ihatemoney/currency_convertor.py b/ihatemoney/currency_convertor.py new file mode 100644 index 00000000..75fa8342 --- /dev/null +++ b/ihatemoney/currency_convertor.py @@ -0,0 +1,46 @@ +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 + default = "No Currency" + 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.default] = 1.0 + return rates + + def get_currencies(self): + rates = [rate for rate in self.get_rates()] + rates.sort(key=lambda rate: "" if rate == self.default else rate) + return rates + + def exchange_currency(self, amount, source_currency, dest_currency): + if ( + source_currency == dest_currency + or source_currency == self.default + or dest_currency == self.default + ): + 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 7ce44220..4895ab98 100644 --- a/ihatemoney/default_settings.py +++ b/ihatemoney/default_settings.py @@ -8,4 +8,4 @@ ACTIVATE_DEMO_PROJECT = True ADMIN_PASSWORD = "" ALLOW_PUBLIC_PROJECT_CREATION = True ACTIVATE_ADMIN_DASHBOARD = False -SUPPORTED_LANGUAGES = ["en", "fr", "de", "nl", "es_419"] +SUPPORTED_LANGUAGES = ["en", "fr", "de", "nl", "es_419", "nb_NO", "id"] diff --git a/ihatemoney/forms.py b/ihatemoney/forms.py index 88afd296..7a6a57e4 100644 --- a/ihatemoney/forms.py +++ b/ihatemoney/forms.py @@ -1,29 +1,29 @@ +import copy +from datetime import datetime +from re import match + +import email_validator +from flask import request +from flask_babel import lazy_gettext as _ +from flask_wtf.file import FileAllowed, FileField, FileRequired from flask_wtf.form import FlaskForm -from wtforms.fields.core import SelectField, SelectMultipleField +from jinja2 import Markup +from werkzeug.security import check_password_hash, generate_password_hash +from wtforms.fields.core import Label, SelectField, SelectMultipleField from wtforms.fields.html5 import DateField, DecimalField, URLField -from wtforms.fields.simple import PasswordField, SubmitField, StringField +from wtforms.fields.simple import BooleanField, PasswordField, StringField, SubmitField from wtforms.validators import ( - Email, DataRequired, - ValidationError, + Email, EqualTo, NumberRange, Optional, + ValidationError, ) -from flask_wtf.file import FileField, FileAllowed, FileRequired -from flask_babel import lazy_gettext as _ -from flask import request -from werkzeug.security import generate_password_hash - -from datetime import datetime -from re import match -from jinja2 import Markup - -import email_validator - -from ihatemoney.models import Project, Person -from ihatemoney.utils import slugify, eval_arithmetic_expression +from ihatemoney.currency_convertor import CurrencyConverter +from ihatemoney.models import LoggingMode, Person, Project +from ihatemoney.utils import eval_arithmetic_expression, slugify def strip_filter(string): @@ -33,6 +33,18 @@ def strip_filter(string): return string +def get_editprojectform_for(project, **kwargs): + """Return an instance of EditProjectForm configured for a particular project. + """ + form = EditProjectForm(**kwargs) + choices = copy.copy(form.default_currency.choices) + choices.sort( + key=lambda rates: "" if rates[0] == project.default_currency else rates[0] + ) + form.default_currency.choices = choices + return form + + def get_billform_for(project, set_default=True, **kwargs): """Return an instance of BillForm configured for a particular project. @@ -41,6 +53,23 @@ 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 + + if form.original_currency.data != CurrencyConverter.default: + choices = copy.copy(form.original_currency.choices) + choices.remove((CurrencyConverter.default, CurrencyConverter.default)) + choices.sort( + key=lambda rates: "" if rates[0] == project.default_currency else rates[0] + ) + form.original_currency.choices = choices + else: + form.original_currency.render_kw = {"default": True} + form.original_currency.data = CurrencyConverter.default + + form.original_currency.label = Label( + "original_currency", "Currency (Default: %s)" % (project.default_currency) + ) active_members = [(m.id, m.name) for m in project.active_members] form.payed_for.choices = form.payer.choices = active_members @@ -89,6 +118,28 @@ class EditProjectForm(FlaskForm): name = StringField(_("Project name"), validators=[DataRequired()]) password = StringField(_("Private code"), validators=[DataRequired()]) 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"), + choices=[ + (currency_name, currency_name) + for currency_name in currency_helper.get_currencies() + ], + validators=[DataRequired()], + ) + + @property + def logging_preference(self): + """Get the LoggingMode object corresponding to current form data.""" + if not self.project_history.data: + return LoggingMode.DISABLED + else: + if self.ip_recording.data: + return LoggingMode.RECORD_IP + else: + return LoggingMode.ENABLED def save(self): """Create a new project with the information given by this form. @@ -100,22 +151,33 @@ class EditProjectForm(FlaskForm): id=self.id.data, 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 def update(self, project): """Update the project with the information from the form""" project.name = self.name.data - project.password = generate_password_hash(self.password.data) + + # Only update password if changed to prevent spurious log entries + if not check_password_hash(project.password, self.password.data): + project.password = generate_password_hash(self.password.data) + project.contact_email = self.contact_email.data + project.logging_preference = self.logging_preference + project.default_currency = self.default_currency.data return project class UploadForm(FlaskForm): file = FileField( - "JSON", validators=[FileRequired(), FileAllowed(["json", "JSON"], "JSON only!")] + "JSON", + validators=[FileRequired(), FileAllowed(["json", "JSON"], "JSON only!")], + description=_("Import previously exported JSON file"), ) + submit = SubmitField(_("Import")) class ProjectForm(EditProjectForm): @@ -123,6 +185,14 @@ class ProjectForm(EditProjectForm): password = PasswordField(_("Private code"), validators=[DataRequired()]) submit = SubmitField(_("Create the project")) + def save(self): + # WTForms Boolean Fields don't insert the default value when the + # request doesn't include any value the way that other fields do, + # so we'll manually do it here + self.project_history.data = LoggingMode.default() != LoggingMode.DISABLED + self.ip_recording.data = LoggingMode.default() == LoggingMode.RECORD_IP + return super().save() + def validate_id(form, field): form.id.data = slugify(field.data) if (form.id.data == "dashboard") or Project.query.get(form.id.data): @@ -171,6 +241,15 @@ 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"), + choices=[ + (currency_name, currency_name) + for currency_name in currency_helper.get_currencies() + ], + validators=[DataRequired()], + ) external_link = URLField( _("External link"), validators=[Optional()], @@ -189,6 +268,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): @@ -198,6 +281,10 @@ 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.default + bill.converted_amount = self.currency_helper.exchange_currency( + bill.amount, bill.original_currency, project.default_currency + ) return bill @@ -206,6 +293,7 @@ class BillForm(FlaskForm): 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] diff --git a/ihatemoney/history.py b/ihatemoney/history.py new file mode 100644 index 00000000..faa12c09 --- /dev/null +++ b/ihatemoney/history.py @@ -0,0 +1,139 @@ +from flask_babel import gettext as _ +from sqlalchemy_continuum import Operation, parent_class + +from ihatemoney.models import BillVersion, Person, PersonVersion, ProjectVersion + + +def get_history_queries(project): + """Generate queries for each type of version object for a given project.""" + person_changes = PersonVersion.query.filter_by(project_id=project.id) + + project_changes = ProjectVersion.query.filter_by(id=project.id) + + bill_changes = ( + BillVersion.query.with_entities(BillVersion.id.label("bill_version_id")) + .join(Person, BillVersion.payer_id == Person.id) + .filter(Person.project_id == project.id) + ) + sub_query = bill_changes.subquery() + bill_changes = BillVersion.query.filter(BillVersion.id.in_(sub_query)) + + return person_changes, project_changes, bill_changes + + +def history_sort_key(history_item_dict): + """ + Return the key necessary to sort history entries. First order sort is time + of modification, but for simultaneous modifications we make the re-name + modification occur last so that the simultaneous entries make sense using + the old name. + """ + second_order = 0 + if "prop_changed" in history_item_dict: + changed_property = history_item_dict["prop_changed"] + if changed_property == "name" or changed_property == "what": + second_order = 1 + + return history_item_dict["time"], second_order + + +def describe_version(version_obj): + """Use the base model str() function to describe a version object""" + return parent_class(type(version_obj)).__str__(version_obj) + + +def describe_owers_change(version, human_readable_names): + """Compute the set difference to get added/removed owers lists.""" + before_owers = {version.id: version for version in version.previous.owers} + after_owers = {version.id: version for version in version.owers} + + added_ids = set(after_owers).difference(set(before_owers)) + removed_ids = set(before_owers).difference(set(after_owers)) + + if not human_readable_names: + return added_ids, removed_ids + + added = [describe_version(after_owers[ower_id]) for ower_id in added_ids] + removed = [describe_version(before_owers[ower_id]) for ower_id in removed_ids] + + return added, removed + + +def get_history(project, human_readable_names=True): + """ + Fetch history for all models associated with a given project. + :param human_readable_names Whether to replace id numbers with readable names + :return A sorted list of dicts with history information + """ + person_query, project_query, bill_query = get_history_queries(project) + history = [] + for version_list in [person_query.all(), project_query.all(), bill_query.all()]: + for version in version_list: + object_type = { + "Person": _("Participant"), + "Bill": _("Bill"), + "Project": _("Project"), + }[parent_class(type(version)).__name__] + + # Use the old name if applicable + if version.previous: + object_str = describe_version(version.previous) + else: + object_str = describe_version(version) + + common_properties = { + "time": version.transaction.issued_at.strftime("%Y-%m-%dT%H:%M:%SZ"), + "operation_type": version.operation_type, + "object_type": object_type, + "object_desc": object_str, + "ip": version.transaction.remote_addr, + } + + if version.operation_type == Operation.UPDATE: + # Only iterate the changeset if the previous version + # Was logged + if version.previous: + changeset = version.changeset + if isinstance(version, BillVersion): + if version.owers != version.previous.owers: + added, removed = describe_owers_change( + version, human_readable_names + ) + + if added: + changeset["owers_added"] = (None, added) + if removed: + changeset["owers_removed"] = (None, removed) + + # 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" + if val_after is not None: + val_after = describe_version(version.payer) + if version.previous and val_before is not None: + val_before = describe_version( + version.previous.payer + ) + else: + val_after = None + + next_event = common_properties.copy() + next_event["prop_changed"] = prop + next_event["val_before"] = val_before + next_event["val_after"] = val_after + history.append(next_event) + else: + history.append(common_properties) + else: + history.append(common_properties) + + return sorted(history, key=history_sort_key, reverse=True) diff --git a/ihatemoney/manage.py b/ihatemoney/manage.py index 6343ee7c..eb1e24c2 100755 --- a/ihatemoney/manage.py +++ b/ihatemoney/manage.py @@ -1,16 +1,16 @@ #!/usr/bin/env python +import getpass import os import random import sys -import getpass -from flask_script import Manager, Command, Option from flask_migrate import Migrate, MigrateCommand +from flask_script import Command, Manager, Option from werkzeug.security import generate_password_hash +from ihatemoney.models import Project, db from ihatemoney.run import create_app -from ihatemoney.models import db, Project from ihatemoney.utils import create_jinja_env @@ -51,7 +51,7 @@ class GenerateConfig(Command): def run(self, config_file): env = create_jinja_env("conf-templates", strict_rendering=True) - template = env.get_template("%s.j2" % config_file) + template = env.get_template(f"{config_file}.j2") bin_path = os.path.dirname(sys.executable) pkg_path = os.path.abspath(os.path.dirname(__file__)) diff --git a/ihatemoney/messages.pot b/ihatemoney/messages.pot index d6401a56..63f16cd0 100644 --- a/ihatemoney/messages.pot +++ b/ihatemoney/messages.pot @@ -12,6 +12,18 @@ msgstr "" msgid "Email" msgstr "" +msgid "Enable project history" +msgstr "" + +msgid "Use IP tracking for project history" +msgstr "" + +msgid "Import previously exported JSON file" +msgstr "" + +msgid "Import" +msgstr "" + msgid "Project identifier" msgstr "" @@ -106,6 +118,15 @@ msgstr "" msgid "The email %(email)s is not valid" msgstr "" +msgid "Participant" +msgstr "" + +msgid "Bill" +msgstr "" + +msgid "Project" +msgstr "" + msgid "Too many failed login attempts, please retry later." msgstr "" @@ -139,6 +160,12 @@ msgstr "" msgid "Password successfully reset." msgstr "" +msgid "Project successfully uploaded" +msgstr "" + +msgid "Invalid JSON" +msgstr "" + msgid "Project successfully deleted" msgstr "" @@ -150,7 +177,7 @@ msgid "Your invitations have been sent" msgstr "" #, python-format -msgid "%(member)s had been added" +msgid "%(member)s has been added" msgstr "" #, python-format @@ -204,9 +231,6 @@ msgstr "" msgid "Create a new project" msgstr "" -msgid "Project" -msgstr "" - msgid "Number of members" msgstr "" @@ -228,6 +252,9 @@ msgstr "" msgid "delete" msgstr "" +msgid "see" +msgstr "" + msgid "The Dashboard is currently deactivated." msgstr "" @@ -237,6 +264,12 @@ msgstr "" msgid "Edit project" msgstr "" +msgid "Import JSON" +msgstr "" + +msgid "Choose file" +msgstr "" + msgid "Download project's data" msgstr "" @@ -258,6 +291,9 @@ msgstr "" msgid "Cancel" msgstr "" +msgid "Privacy Settings" +msgstr "" + msgid "Edit the project" msgstr "" @@ -288,6 +324,177 @@ msgstr "" msgid "Download" msgstr "" +msgid "Disabled Project History" +msgstr "" + +msgid "Disabled Project History & IP Address Recording" +msgstr "" + +msgid "Enabled Project History" +msgstr "" + +msgid "Disabled IP Address Recording" +msgstr "" + +msgid "Enabled Project History & IP Address Recording" +msgstr "" + +msgid "Enabled IP Address Recording" +msgstr "" + +msgid "History Settings Changed" +msgstr "" + +msgid "changed" +msgstr "" + +msgid "from" +msgstr "" + +msgid "to" +msgstr "" + +msgid "Confirm Remove IP Adresses" +msgstr "" + +msgid "" +"Are you sure you want to delete all recorded IP addresses from this " +"project?\n" +" The rest of the project history will be unaffected. This " +"action cannot be undone." +msgstr "" + +msgid "Close" +msgstr "" + +msgid "Confirm Delete" +msgstr "" + +msgid "Delete Confirmation" +msgstr "" + +msgid "" +"Are you sure you want to erase all history for this project? This action " +"cannot be undone." +msgstr "" + +msgid "Added" +msgstr "" + +msgid "Removed" +msgstr "" + +msgid "and" +msgstr "" + +msgid "owers list" +msgstr "" + +msgid "Who?" +msgstr "" + +msgid "Balance" +msgstr "" + +#, python-format +msgid "" +"\n" +" This project has history disabled. New actions won't " +"appear below. You can enable history on the\n" +" settings page\n" +" " +msgstr "" + +msgid "" +"\n" +" The table below reflects actions recorded prior to " +"disabling project history. You can\n" +" clear project history to remove " +"them.

\n" +" " +msgstr "" + +msgid "" +"Some entries below contain IP addresses, even though this project has IP " +"recording disabled. " +msgstr "" + +msgid "Delete stored IP addresses" +msgstr "" + +msgid "No history to erase" +msgstr "" + +msgid "Clear Project History" +msgstr "" + +msgid "No IP Addresses to erase" +msgstr "" + +msgid "Delete Stored IP Addresses" +msgstr "" + +msgid "Time" +msgstr "" + +msgid "Event" +msgstr "" + +msgid "IP address recording can be enabled on the settings page" +msgstr "" + +msgid "IP address recording can be disabled on the settings page" +msgstr "" + +msgid "From IP" +msgstr "" + +msgid "added" +msgstr "" + +msgid "Project private code changed" +msgstr "" + +msgid "Project renamed to" +msgstr "" + +msgid "Project contact email changed to" +msgstr "" + +msgid "Project settings modified" +msgstr "" + +msgid "deactivated" +msgstr "" + +msgid "reactivated" +msgstr "" + +msgid "renamed to" +msgstr "" + +msgid "External link changed to" +msgstr "" + +msgid "Amount" +msgstr "" + +msgid "modified" +msgstr "" + +msgid "removed" +msgstr "" + +msgid "changed in a unknown way" +msgstr "" + +msgid "Nothing to list" +msgstr "" + +msgid "Someone probably cleared the project history." +msgstr "" + msgid "Manage your shared
expenses, easily" msgstr "" @@ -335,6 +542,9 @@ msgstr "" msgid "Statistics" msgstr "" +msgid "History" +msgstr "" + msgid "Settings" msgstr "" @@ -392,6 +602,12 @@ msgstr "" msgid "Add a new bill" msgstr "" +msgid "Newer bills" +msgstr "" + +msgid "Older bills" +msgstr "" + msgid "When?" msgstr "" @@ -418,9 +634,6 @@ msgstr "" msgid "each" msgstr "" -msgid "see" -msgstr "" - msgid "No bills" msgstr "" @@ -489,15 +702,15 @@ msgstr "" msgid "To whom?" msgstr "" -msgid "Who?" -msgstr "" - msgid "Paid" msgstr "" msgid "Spent" msgstr "" -msgid "Balance" +msgid "Expenses by Month" +msgstr "" + +msgid "Period" msgstr "" diff --git a/ihatemoney/migrations/env.py b/ihatemoney/migrations/env.py index 4d4729c5..0bd0031e 100755 --- a/ihatemoney/migrations/env.py +++ b/ihatemoney/migrations/env.py @@ -41,7 +41,7 @@ def run_migrations_offline(): """ url = config.get_main_option("sqlalchemy.url") - context.configure(url=url) + context.configure(url=url, include_object=include_object) with context.begin_transaction(): context.run_migrations() @@ -75,6 +75,7 @@ def run_migrations_online(): context.configure( connection=connection, target_metadata=target_metadata, + include_object=include_object, process_revision_directives=process_revision_directives, **current_app.extensions["migrate"].configure_args ) @@ -86,6 +87,12 @@ def run_migrations_online(): connection.close() +def include_object(object, name, type_, reflected, compare_to): + if name == "sqlite_sequence": + return False + return True + + if context.is_offline_mode(): run_migrations_offline() else: diff --git a/ihatemoney/migrations/versions/2dcb0c0048dc_autologger.py b/ihatemoney/migrations/versions/2dcb0c0048dc_autologger.py new file mode 100644 index 00000000..b0b4f44c --- /dev/null +++ b/ihatemoney/migrations/versions/2dcb0c0048dc_autologger.py @@ -0,0 +1,232 @@ +"""autologger + +Revision ID: 2dcb0c0048dc +Revises: 6c6fb2b7f229 +Create Date: 2020-04-10 18:12:41.285590 + +""" + +# revision identifiers, used by Alembic. +revision = "2dcb0c0048dc" +down_revision = "6c6fb2b7f229" + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "bill_version", + sa.Column("id", sa.Integer(), autoincrement=False, nullable=False), + sa.Column("payer_id", sa.Integer(), autoincrement=False, nullable=True), + sa.Column("amount", sa.Float(), autoincrement=False, nullable=True), + sa.Column("date", sa.Date(), autoincrement=False, nullable=True), + sa.Column("creation_date", sa.Date(), autoincrement=False, nullable=True), + sa.Column("what", sa.UnicodeText(), autoincrement=False, nullable=True), + sa.Column( + "external_link", sa.UnicodeText(), autoincrement=False, nullable=True + ), + sa.Column("archive", sa.Integer(), autoincrement=False, nullable=True), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint("id", "transaction_id"), + ) + op.create_index( + op.f("ix_bill_version_end_transaction_id"), + "bill_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_bill_version_operation_type"), + "bill_version", + ["operation_type"], + unique=False, + ) + op.create_index( + op.f("ix_bill_version_transaction_id"), + "bill_version", + ["transaction_id"], + unique=False, + ) + op.create_table( + "billowers_version", + sa.Column("bill_id", sa.Integer(), autoincrement=False, nullable=False), + sa.Column("person_id", sa.Integer(), autoincrement=False, nullable=False), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint("bill_id", "person_id", "transaction_id"), + ) + op.create_index( + op.f("ix_billowers_version_end_transaction_id"), + "billowers_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_billowers_version_operation_type"), + "billowers_version", + ["operation_type"], + unique=False, + ) + op.create_index( + op.f("ix_billowers_version_transaction_id"), + "billowers_version", + ["transaction_id"], + unique=False, + ) + op.create_table( + "person_version", + sa.Column("id", sa.Integer(), autoincrement=False, nullable=False), + sa.Column( + "project_id", sa.String(length=64), autoincrement=False, nullable=True + ), + sa.Column("name", sa.UnicodeText(), autoincrement=False, nullable=True), + sa.Column("weight", sa.Float(), autoincrement=False, nullable=True), + sa.Column("activated", sa.Boolean(), autoincrement=False, nullable=True), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint("id", "transaction_id"), + ) + op.create_index( + op.f("ix_person_version_end_transaction_id"), + "person_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_person_version_operation_type"), + "person_version", + ["operation_type"], + unique=False, + ) + op.create_index( + op.f("ix_person_version_transaction_id"), + "person_version", + ["transaction_id"], + unique=False, + ) + op.create_table( + "project_version", + sa.Column("id", sa.String(length=64), autoincrement=False, nullable=False), + sa.Column("name", sa.UnicodeText(), autoincrement=False, nullable=True), + sa.Column( + "password", sa.String(length=128), autoincrement=False, nullable=True + ), + sa.Column( + "contact_email", sa.String(length=128), autoincrement=False, nullable=True + ), + sa.Column( + "logging_preference", + sa.Enum("DISABLED", "ENABLED", "RECORD_IP", name="loggingmode"), + server_default="ENABLED", + autoincrement=False, + nullable=True, + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint("id", "transaction_id"), + ) + op.create_index( + op.f("ix_project_version_end_transaction_id"), + "project_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_project_version_operation_type"), + "project_version", + ["operation_type"], + unique=False, + ) + op.create_index( + op.f("ix_project_version_transaction_id"), + "project_version", + ["transaction_id"], + unique=False, + ) + op.create_table( + "transaction", + sa.Column("issued_at", sa.DateTime(), nullable=True), + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("remote_addr", sa.String(length=50), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + bind = op.get_bind() + if bind.engine.name == "sqlite": + with op.batch_alter_table("project", recreate="always") as batch_op: + batch_op.add_column( + sa.Column( + "logging_preference", + sa.Enum("DISABLED", "ENABLED", "RECORD_IP", name="loggingmode"), + server_default="ENABLED", + nullable=False, + ), + ) + else: + op.add_column( + "project", + sa.Column( + "logging_preference", + sa.Enum("DISABLED", "ENABLED", "RECORD_IP", name="loggingmode"), + server_default="ENABLED", + nullable=False, + ), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + + bind = op.get_bind() + if bind.engine.name == "sqlite": + with op.batch_alter_table("project", recreate="always") as batch_op: + batch_op.drop_column("logging_preference") + else: + op.drop_column("project", "logging_preference") + op.drop_table("transaction") + op.drop_index( + op.f("ix_project_version_transaction_id"), table_name="project_version" + ) + op.drop_index( + op.f("ix_project_version_operation_type"), table_name="project_version" + ) + op.drop_index( + op.f("ix_project_version_end_transaction_id"), table_name="project_version" + ) + op.drop_table("project_version") + op.drop_index(op.f("ix_person_version_transaction_id"), table_name="person_version") + op.drop_index(op.f("ix_person_version_operation_type"), table_name="person_version") + op.drop_index( + op.f("ix_person_version_end_transaction_id"), table_name="person_version" + ) + op.drop_table("person_version") + op.drop_index( + op.f("ix_billowers_version_transaction_id"), table_name="billowers_version" + ) + op.drop_index( + op.f("ix_billowers_version_operation_type"), table_name="billowers_version" + ) + op.drop_index( + op.f("ix_billowers_version_end_transaction_id"), table_name="billowers_version" + ) + op.drop_table("billowers_version") + op.drop_index(op.f("ix_bill_version_transaction_id"), table_name="bill_version") + op.drop_index(op.f("ix_bill_version_operation_type"), table_name="bill_version") + op.drop_index(op.f("ix_bill_version_end_transaction_id"), table_name="bill_version") + op.drop_table("bill_version") + # ### end Alembic commands ### diff --git a/ihatemoney/migrations/versions/927ed575acbd_add_currencies.py b/ihatemoney/migrations/versions/927ed575acbd_add_currencies.py new file mode 100644 index 00000000..b70d9025 --- /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.default, + 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.default, + 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/migrations/versions/cb038f79982e_sqlite_autoincrement.py b/ihatemoney/migrations/versions/cb038f79982e_sqlite_autoincrement.py new file mode 100644 index 00000000..718aa75a --- /dev/null +++ b/ihatemoney/migrations/versions/cb038f79982e_sqlite_autoincrement.py @@ -0,0 +1,60 @@ +"""sqlite_autoincrement + +Revision ID: cb038f79982e +Revises: 2dcb0c0048dc +Create Date: 2020-04-13 17:40:02.426957 + +""" + +# revision identifiers, used by Alembic. +revision = "cb038f79982e" +down_revision = "2dcb0c0048dc" + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + bind = op.get_bind() + if bind.engine.name == "sqlite": + alter_table_batches = [ + op.batch_alter_table( + "person", recreate="always", table_kwargs={"sqlite_autoincrement": True} + ), + op.batch_alter_table( + "bill", recreate="always", table_kwargs={"sqlite_autoincrement": True} + ), + op.batch_alter_table( + "billowers", + recreate="always", + table_kwargs={"sqlite_autoincrement": True}, + ), + ] + + for batch_op in alter_table_batches: + with batch_op: + pass + + +def downgrade(): + bind = op.get_bind() + if bind.engine.name == "sqlite": + alter_table_batches = [ + op.batch_alter_table( + "person", + recreate="always", + table_kwargs={"sqlite_autoincrement": False}, + ), + op.batch_alter_table( + "bill", recreate="always", table_kwargs={"sqlite_autoincrement": False} + ), + op.batch_alter_table( + "billowers", + recreate="always", + table_kwargs={"sqlite_autoincrement": False}, + ), + ] + + for batch_op in alter_table_batches: + with batch_op: + pass diff --git a/ihatemoney/models.py b/ihatemoney/models.py index 4d32fd97..9e474c60 100644 --- a/ihatemoney/models.py +++ b/ihatemoney/models.py @@ -1,17 +1,49 @@ from collections import defaultdict - from datetime import datetime -from flask_sqlalchemy import SQLAlchemy, BaseQuery -from flask import g, current_app from debts import settle -from sqlalchemy import orm -from sqlalchemy.sql import func +from flask import current_app, g +from flask_sqlalchemy import BaseQuery, SQLAlchemy from itsdangerous import ( - TimedJSONWebSignatureSerializer, - URLSafeSerializer, BadSignature, SignatureExpired, + TimedJSONWebSignatureSerializer, + URLSafeSerializer, +) +import sqlalchemy +from sqlalchemy import orm +from sqlalchemy.sql import func +from sqlalchemy_continuum import make_versioned, version_class +from sqlalchemy_continuum.plugins import FlaskPlugin + +from ihatemoney.patch_sqlalchemy_continuum import PatchedBuilder +from ihatemoney.versioning import ( + ConditionalVersioningManager, + LoggingMode, + get_ip_if_allowed, + version_privacy_predicate, +) + +make_versioned( + user_cls=None, + manager=ConditionalVersioningManager( + # Conditionally Disable the versioning based on each + # project's privacy preferences + tracking_predicate=version_privacy_predicate, + # Patch in a fix to a SQLAchemy-Continuum Bug. + # See patch_sqlalchemy_continuum.py + builder=PatchedBuilder(), + ), + plugins=[ + FlaskPlugin( + # Redirect to our own function, which respects user preferences + # on IP address collection + remote_addr_factory=get_ip_if_allowed, + # Suppress the plugin's attempt to grab a user id, + # which imports the flask_login module (causing an error) + current_user_id_factory=lambda: None, + ) + ], ) db = SQLAlchemy() @@ -22,14 +54,24 @@ class Project(db.Model): def get_by_name(self, name): return Project.query.filter(Project.name == name).one() + # Direct SQLAlchemy-Continuum to track changes to this model + __versioned__ = {} + id = db.Column(db.String(64), primary_key=True) name = db.Column(db.UnicodeText) password = db.Column(db.String(128)) contact_email = db.Column(db.String(128)) + logging_preference = db.Column( + db.Enum(LoggingMode), + default=LoggingMode.default(), + nullable=False, + server_default=LoggingMode.default().name, + ) members = db.relationship("Person", backref="project") query_class = ProjectQuery + default_currency = db.Column(db.String(3)) @property def _to_serialize(self): @@ -37,7 +79,9 @@ class Project(db.Model): "id": self.id, "name": self.name, "contact_email": self.contact_email, + "logging_preference": self.logging_preference.value, "members": [], + "default_currency": self.default_currency, } balance = self.balance @@ -86,7 +130,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( [ @@ -109,7 +156,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 @@ -277,8 +324,11 @@ class Project(db.Model): return None return data["project_id"] + def __str__(self): + return self.name + def __repr__(self): - return "" % self.name + return f"" class Person(db.Model): @@ -301,6 +351,11 @@ class Person(db.Model): query_class = PersonQuery + # Direct SQLAlchemy-Continuum to track changes to this model + __versioned__ = {} + + __table_args__ = {"sqlite_autoincrement": True} + id = db.Column(db.Integer, primary_key=True) project_id = db.Column(db.String(64), db.ForeignKey("project.id")) bills = db.relationship("Bill", backref="payer") @@ -331,14 +386,15 @@ class Person(db.Model): return self.name def __repr__(self): - return "" % (self.name, self.project.name) + return f"" # We need to manually define a join table for m2m relations billowers = db.Table( "billowers", - db.Column("bill_id", db.Integer, db.ForeignKey("bill.id")), - db.Column("person_id", db.Integer, db.ForeignKey("person.id")), + db.Column("bill_id", db.Integer, db.ForeignKey("bill.id"), primary_key=True), + db.Column("person_id", db.Integer, db.ForeignKey("person.id"), primary_key=True), + sqlite_autoincrement=True, ) @@ -365,6 +421,11 @@ class Bill(db.Model): query_class = BillQuery + # Direct SQLAlchemy-Continuum to track changes to this model + __versioned__ = {} + + __table_args__ = {"sqlite_autoincrement": True} + id = db.Column(db.Integer, primary_key=True) payer_id = db.Column(db.Integer, db.ForeignKey("person.id")) @@ -376,6 +437,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 @@ -389,9 +453,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 = ( @@ -399,15 +465,20 @@ 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 "" % ( - self.amount, - self.payer, - ", ".join([o.name for o in self.owers]), + return ( + f"" ) @@ -426,3 +497,10 @@ class Archive(db.Model): def __repr__(self): return "" + + +sqlalchemy.orm.configure_mappers() + +PersonVersion = version_class(Person) +ProjectVersion = version_class(Project) +BillVersion = version_class(Bill) diff --git a/ihatemoney/patch_sqlalchemy_continuum.py b/ihatemoney/patch_sqlalchemy_continuum.py new file mode 100644 index 00000000..dbbd9083 --- /dev/null +++ b/ihatemoney/patch_sqlalchemy_continuum.py @@ -0,0 +1,138 @@ +""" +A temporary work-around to patch SQLAlchemy-continuum per: +https://github.com/kvesteri/sqlalchemy-continuum/pull/242 + +Source code reproduced under their license: + + Copyright (c) 2012, Konsta Vesterinen + + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * The names of the contributors may not be used to endorse or promote products + derived from this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY DIRECT, + INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF + ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +""" + +import sqlalchemy as sa +from sqlalchemy_continuum import Operation +from sqlalchemy_continuum.builder import Builder +from sqlalchemy_continuum.expression_reflector import VersionExpressionReflector +from sqlalchemy_continuum.relationship_builder import RelationshipBuilder +from sqlalchemy_continuum.utils import adapt_columns, option + + +class PatchedRelationShipBuilder(RelationshipBuilder): + def association_subquery(self, obj): + """ + Returns an EXISTS clause that checks if an association exists for given + SQLAlchemy declarative object. This query is used by + many_to_many_criteria method. + + Example query: + + .. code-block:: sql + + EXISTS ( + SELECT 1 + FROM article_tag_version + WHERE article_id = 3 + AND tag_id = tags_version.id + AND operation_type != 2 + AND EXISTS ( + SELECT 1 + FROM article_tag_version as article_tag_version2 + WHERE article_tag_version2.tag_id = article_tag_version.tag_id + AND article_tag_version2.tx_id <=5 + AND article_tag_version2.article_id = 3 + GROUP BY article_tag_version2.tag_id + HAVING + MAX(article_tag_version2.tx_id) = + article_tag_version.tx_id + ) + ) + + :param obj: SQLAlchemy declarative object + """ + + tx_column = option(obj, "transaction_column_name") + join_column = self.property.primaryjoin.right.name + object_join_column = self.property.primaryjoin.left.name + reflector = VersionExpressionReflector(obj, self.property) + + association_table_alias = self.association_version_table.alias() + association_cols = [ + association_table_alias.c[association_col.name] + for _, association_col in self.remote_to_association_column_pairs + ] + + association_exists = sa.exists( + sa.select([1]) + .where( + sa.and_( + association_table_alias.c[tx_column] <= getattr(obj, tx_column), + association_table_alias.c[join_column] + == getattr(obj, object_join_column), + *[ + association_col + == self.association_version_table.c[association_col.name] + for association_col in association_cols + ] + ) + ) + .group_by(*association_cols) + .having( + sa.func.max(association_table_alias.c[tx_column]) + == self.association_version_table.c[tx_column] + ) + .correlate(self.association_version_table) + ) + return sa.exists( + sa.select([1]) + .where( + sa.and_( + reflector(self.property.primaryjoin), + association_exists, + self.association_version_table.c.operation_type != Operation.DELETE, + adapt_columns(self.property.secondaryjoin), + ) + ) + .correlate(self.local_cls, self.remote_cls) + ) + + +class PatchedBuilder(Builder): + def build_relationships(self, version_classes): + """ + Builds relationships for all version classes. + + :param version_classes: list of generated version classes + """ + for cls in version_classes: + if not self.manager.option(cls, "versioning"): + continue + + for prop in sa.inspect(cls).iterate_properties: + if prop.key == "versions": + continue + builder = PatchedRelationShipBuilder(self.manager, cls, prop) + builder() diff --git a/ihatemoney/run.py b/ihatemoney/run.py index 161761c0..b6c8cbb2 100644 --- a/ihatemoney/run.py +++ b/ihatemoney/run.py @@ -2,13 +2,15 @@ import os import os.path import warnings -from flask import Flask, g, request, session, render_template +from flask import Flask, g, render_template, request, session from flask_babel import Babel from flask_mail import Mail -from flask_migrate import Migrate, upgrade, stamp +from flask_migrate import Migrate, stamp, upgrade from werkzeug.middleware.proxy_fix import ProxyFix +from ihatemoney import default_settings from ihatemoney.api.v1 import api as apiv1 +from ihatemoney.currency_convertor import CurrencyConverter from ihatemoney.models import db from ihatemoney.utils import ( IhmJSONEncoder, @@ -19,8 +21,6 @@ from ihatemoney.utils import ( ) 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.""" @@ -138,6 +138,9 @@ def create_app( # Configure the a, root="main"pplication setup_database(app) + # Setup Currency Cache + CurrencyConverter() + mail = Mail() mail.init_app(app) app.mail = mail diff --git a/ihatemoney/static/css/main.css b/ihatemoney/static/css/main.css index 245d573e..ec1481bc 100644 --- a/ihatemoney/static/css/main.css +++ b/ihatemoney/static/css/main.css @@ -162,6 +162,11 @@ body { margin-bottom: 20px; margin-left: 25px; } +@media (max-width: 400px) { + .home .card { + min-width: unset; + } +} /* Other */ #bills { @@ -192,7 +197,7 @@ footer { padding: 45px 50px; } -@media (min-width: 768px) { +@media (min-width: 1024px) { footer { padding-left: calc(25% + 50px); } @@ -332,13 +337,16 @@ footer .footer-left { background: url("../images/see.png") no-repeat right; } -#bill_table, #monthly_stats { +#bill_table, +#monthly_stats, +#history_table { margin-top: 30px; margin-bottom: 30px; } @media (min-width: 768px) { - .split_bills, #table_overflow.statistics { + .split_bills, + #table_overflow.statistics { /* The table is shifted to left, so add the spacer width on the right to match */ width: calc(100% + 15px); } @@ -368,6 +376,36 @@ footer .footer-left { background: url("../images/see.png") no-repeat right; } +.history_icon > .delete, +.history_icon > .add, +.history_icon > .edit { + font-size: 0px; + display: block; + width: 16px; + height: 16px; + margin: 2px; + margin-right: 10px; + margin-top: 3px; + float: left; +} + +.history_icon > .delete { + background: url("../images/delete.png") no-repeat right; +} + +.history_icon > .edit { + background: url("../images/edit.png") no-repeat right; +} + +.history_icon > .add { + background: url("../images/add.png") no-repeat right; +} + +.history_text { + display: table-cell; +} + + .balance .balance-value { text-align: right; } @@ -529,6 +567,13 @@ footer .icon svg { fill: white; } +.icon.icon-red { + fill: #dc3545; +} +.btn:hover .icon.icon-red { + fill: white !important; +} + /* align the first column */ #monthly_stats tr *:first-child { text-align: right; @@ -538,3 +583,17 @@ footer .icon svg { .hiddenpswp { display: none; } + +#history_warnings { + margin-top: 30px; +} + +/* edit settings */ + +.edit-project form { + margin-top: 1em; + margin-bottom: 3em; +} +.edit-project .custom-file { + margin-bottom: 2em; +} diff --git a/ihatemoney/static/images/add.png b/ihatemoney/static/images/add.png new file mode 100644 index 00000000..262891bf Binary files /dev/null and b/ihatemoney/static/images/add.png differ diff --git a/ihatemoney/static/images/x.svg b/ihatemoney/static/images/x.svg new file mode 100644 index 00000000..3416d7af --- /dev/null +++ b/ihatemoney/static/images/x.svg @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/ihatemoney/static/js/ihatemoney.js b/ihatemoney/static/js/ihatemoney.js index c240dc1a..9ffc8771 100644 --- a/ihatemoney/static/js/ihatemoney.js +++ b/ihatemoney/static/js/ihatemoney.js @@ -5,3 +5,7 @@ function selectCheckboxes(value){ els[i].checked = value; } } + +function localizeTime(utcTimestamp) { + return new Date(utcTimestamp).toLocaleString() +} diff --git a/ihatemoney/templates/404.html b/ihatemoney/templates/404.html index 7e282c2e..353f0be1 100644 --- a/ihatemoney/templates/404.html +++ b/ihatemoney/templates/404.html @@ -10,4 +10,5 @@ + {% endblock %} diff --git a/ihatemoney/templates/edit_project.html b/ihatemoney/templates/edit_project.html index 3f70e40f..478b7b42 100644 --- a/ihatemoney/templates/edit_project.html +++ b/ihatemoney/templates/edit_project.html @@ -5,18 +5,43 @@ { $(this).html("{{_("you sure?")}}"); }); + + $('.custom-file-input').on('change', function(event) { + var filename = [].slice.call(this.files).map(function (file) { return file.name}).join(',') + var $labelElement = $(this).parents('.custom-file').find('.custom-file-label') + $labelElement.text(filename) + }) {% endblock %} {% block content %} -

{{ _("Edit project") }}

-

-

- {{ forms.edit_project(edit_form) }} -
-

+
-

{{ _("Download project's data") }}

-

+

{{ _("Edit project") }}

+
+ {{ forms.edit_project(edit_form) }} +
+ + +

{{ _("Import JSON") }}

+
+ {{ import_form.hidden_tag() }} + +
+
+ {{ import_form.file(class="custom-file-input") }} + + {{ import_form.file.description }} + +
+ +
+ +
+ {{ import_form.submit(class="btn btn-primary") }} +
+
+ +

{{ _("Download project's data") }}

@@ -51,5 +76,5 @@

{{ _('Download the list of transactions needed to settle the current bills.') }}

-

+
{% endblock %} diff --git a/ihatemoney/templates/forms.html b/ihatemoney/templates/forms.html index bec70184..0900d2f0 100644 --- a/ihatemoney/templates/forms.html +++ b/ihatemoney/templates/forms.html @@ -20,6 +20,16 @@ {% endmacro %} +{% macro checkbox(field) %} +
+ {{ field(id=field.name) }} + + {% if field.description %} + {{ field.description }} + {% endif %} +
+{% endmacro %} + {% macro submit(field, cancel=False, home=False) -%}
@@ -65,6 +75,7 @@ {{ input(form.name) }} {{ input(form.password) }} {{ input(form.contact_email) }} + {{ input(form.default_currency) }} {% if not home %} {{ submit(form.submit, home=True) }} {% endif %} @@ -78,6 +89,15 @@ {{ input(form.name) }} {{ input(form.password) }} {{ input(form.contact_email) }} +
+ +
+ {{ checkbox(form.project_history) }} + {{ checkbox(form.ip_recording) }} +
+
+ + {{ input(form.default_currency) }}
{{ _("delete") }} @@ -104,6 +124,9 @@ {{ input(form.what, inline=True) }} {{ input(form.payer, inline=True, class="form-control custom-select") }} {{ input(form.amount, inline=True) }} + {% if not form.original_currency.render_kw %} + {{ 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 new file mode 100644 index 00000000..a9a9a4db --- /dev/null +++ b/ihatemoney/templates/history.html @@ -0,0 +1,261 @@ +{% extends "sidebar_table_layout.html" %} + +{% macro change_to_logging_preference(event) %} +{% if event.val_after == LoggingMode.DISABLED %} + {% if event.val_before == LoggingMode.ENABLED %} + {{ _("Disabled Project History") }} + {% else %} + {{ _("Disabled Project History & IP Address Recording") }} + {% endif %} +{% elif event.val_after == LoggingMode.ENABLED %} + {% if event.val_before == LoggingMode.DISABLED %} + {{ _("Enabled Project History") }} + {% elif event.val_before == LoggingMode.RECORD_IP %} + {{ _("Disabled IP Address Recording") }} + {% else %} + {{ _("Enabled Project History") }} + {% endif %} +{% elif event.val_after == LoggingMode.RECORD_IP %} + {% if event.val_before == LoggingMode.DISABLED %} + {{ _("Enabled Project History & IP Address Recording") }} + {% elif event.val_before == LoggingMode.ENABLED %} + {{ _("Enabled IP Address Recording") }} + {% else %} + {{ _("Enabled Project History & IP Address Recording") }} + {% endif %} +{% else %} + {# Should be unreachable #} + {{ _("History Settings Changed") }} +{% endif %} +{% endmacro %} + +{% macro describe_object(event) %}{{ event.object_type }} {{ event.object_desc }}{% endmacro %} + +{% macro simple_property_change(event, localized_property_name, from=True) %} + {{ describe_object(event) }}: + {{ localized_property_name }} {{ _("changed") }} + {% if from %}{{ _("from") }} {{ event.val_before }}{% endif %} + {{ _("to") }} {{ event.val_after }} +{% endmacro %} + +{% macro clear_history_modals() %} + + + + +{% endmacro %} + +{% macro owers_changed(event, add) %} + {{ describe_object(event) }}: {% if add %}{{ _("Added") }}{% else %}{{ _("Removed") }}{% endif %} + {% if event.val_after|length > 1 %} + {% for name in event.val_after %} + {{ name }}{% if event.val_after|length > 2 and loop.index != event.val_after|length %},{% endif %} + {% if loop.index == event.val_after|length - 1 %} {{ _("and") }} {% endif %} + {% endfor %} + {% else %} + {{ event.val_after[0] }} + {% endif %} + {% if add %}{{ _("to") }}{% else %}{{ _("from") }}{% endif %} + {{ _("owers list") }} +{% endmacro %} + +{% 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]) }} +
+
+{% endblock %} + + + +{% block content %} + {% if current_log_pref == LoggingMode.DISABLED or (current_log_pref != LoggingMode.RECORD_IP and any_ip_addresses) %} +
+ {% if current_log_pref == LoggingMode.DISABLED %} +

{% set url = url_for(".edit_project") %} + {% trans %} + This project has history disabled. New actions won't appear below. You can enable history on the + settings page + {% endtrans %} +

+ {% if history %} +

+ {% trans %} + The table below reflects actions recorded prior to disabling project history. You can + clear project history to remove them.

+ {% endtrans %} + {% endif %} + {% endif %} + {% if current_log_pref != LoggingMode.RECORD_IP and any_ip_addresses %} +

+ {{ _("Some entries below contain IP addresses, even though this project has IP recording disabled. ") }} + {{ _("Delete stored IP addresses") }} +

+ {% endif %} +
+ {% endif %} + {{ clear_history_modals() }} + + + {{ static_include("images/x.svg") | safe }} + {{ _("Clear Project History") }} + + + + + {{ static_include("images/x.svg") | safe }} + {{ _("Delete Stored IP Addresses") }} + + + +
+ {% if history %} + + + + + + + + {% for event in history %} + + + + + + {% endfor %} + +
{{ _("Time") }}{{ _("Event") }} + + {{ _("From IP") }}
+
+ +
+
+ {% if event.operation_type == OperationType.INSERT %} + {{ event.object_type }} {{ event.object_desc }} {{ _("added") }} + {% elif event.operation_type == OperationType.UPDATE %} + {% if event.object_type == _("Project") %} + {% if event.prop_changed == "password" %} + {{ _("Project private code changed") }} + {% elif event.prop_changed == "logging_preference" %} + {{ change_to_logging_preference(event) }} + {% elif event.prop_changed == "name" %} + {{ _("Project renamed to") }} {{ event.val_after }} + {% elif event.prop_changed == "contact_email" %} + {{ _("Project contact email changed to") }} {{ event.val_after }} + {% else %} + {{ _("Project settings modified") }} + {% endif %} + {% elif event.prop_changed == "activated" %} + {{ event.object_type }} {{ event.object_desc }} + {% if event.val_after == False %}{{ _("deactivated") }}{% else %}{{ _("reactivated") }}{% endif %} + {% elif event.prop_changed == "name" or event.prop_changed == "what" %} + {{ describe_object(event) }} {{ _("renamed to") }} {{ event.val_after }} + {% elif event.prop_changed == "weight" %} + {{ simple_property_change(event, _("Weight")) }} + {% elif event.prop_changed == "external_link" %} + {{ describe_object(event) }}: {{ _("External link changed to") }} + {{ event.val_after }} + {% elif event.prop_changed == "owers_added" %} + {{ owers_changed(event, True)}} + {% elif event.prop_changed == "owers_removed" %} + {{ owers_changed(event, False)}} + {% elif event.prop_changed == "payer" %} + {{ simple_property_change(event, _("Payer"))}} + {% elif event.prop_changed == "amount" %} + {{ simple_property_change(event, _("Amount")) }} + {% elif event.prop_changed == "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 %} + {{ describe_object(event) }} {{ _("modified") }} + {% endif %} + {% elif event.operation_type == OperationType.DELETE %} + {{ event.object_type }} {{ event.object_desc }} {{ _("removed") }} + {% else %} + {# Should be unreachable #} + {{ describe_object(event) }} {{ _("changed in a unknown way") }} + {% endif %} +
+
{% if event.ip %}{{ event.ip }}{% else %} -- {% endif %}
+ {% else %} +
+
+
+ {{ static_include("images/hand-holding-heart.svg") | safe }} +

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

+

+ {{ _("Someone probably cleared the project history.") }} +

+
+
+ {% endif %} + +{% endblock %} diff --git a/ihatemoney/templates/layout.html b/ihatemoney/templates/layout.html index 7a70fc81..6204ad57 100644 --- a/ihatemoney/templates/layout.html +++ b/ihatemoney/templates/layout.html @@ -47,8 +47,8 @@ + - {% endblock %} {% endif %} @@ -127,9 +127,13 @@
- {% 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 %} diff --git a/ihatemoney/templates/list_bills.html b/ihatemoney/templates/list_bills.html index 7f3f61b3..be55c199 100644 --- a/ihatemoney/templates/list_bills.html +++ b/ihatemoney/templates/list_bills.html @@ -117,7 +117,19 @@
- + + + {% for bill in bills.items %} @@ -136,7 +148,14 @@ {%- else -%} {{ bill.owers|join(', ', 'name') }} {%- endif %} - + + - {% for stat in members_stats| sort(attribute='member.name') %} + {% for stat in members_stats|sort(attribute='member.name') %}
{{ _("When?") }}{{ _("Who paid?") }}{{ _("For what?") }}{{ _("For whom?") }}{{ _("How much?") }}{{ _("Actions") }}
{{ _("When?") }} + {{ _("Who paid?") }} + {{ _("For what?") }} + {{ _("For whom?") }} + {{ _("How much?") }} + {% if g.project.default_currency != "No Currency" %} + {{ _("Amount in %(currency)s", currency=g.project.default_currency) }} + {%- else -%} + {{ _("Amount") }} + {% endif %} + {{ _("Actions") }}
{{ "%0.2f"|format(bill.amount) }} ({{ "%0.2f"|format(bill.pay_each()) }} {{ _("each") }}) + {% if bill.original_currency != "No Currency" %} + {{ "%0.2f"|format(bill.amount) }} {{bill.original_currency}} ({{ "%0.2f"|format(bill.pay_each_default(bill.amount)) }} {{bill.original_currency}} {{ _(" each") }}) + {%- else -%} + {{ "%0.2f"|format(bill.amount) }} ({{ "%0.2f"|format(bill.pay_each_default(bill.amount)) }} {{ _(" each") }}) + {% endif %} + {{ "%0.2f"|format(bill.converted_amount) }} {{ _('edit') }} {{ _('delete') }} diff --git a/ihatemoney/templates/statistics.html b/ihatemoney/templates/statistics.html index 73211883..b38abb12 100644 --- a/ihatemoney/templates/statistics.html +++ b/ihatemoney/templates/statistics.html @@ -9,7 +9,7 @@ {{ _("Balance") }}
{{ stat.member.name }} @@ -27,7 +27,7 @@ - {% for stat in members_stats %} + {% for stat in members_stats|sort(attribute='member.name') %} diff --git a/ihatemoney/templates/upload_json.html b/ihatemoney/templates/upload_json.html deleted file mode 100644 index 64aca0fe..00000000 --- a/ihatemoney/templates/upload_json.html +++ /dev/null @@ -1,10 +0,0 @@ -{% extends "layout.html" %} - -{% block content %} -

{{ _("Import JSON") }}

-

-
- {{ forms.upload_json(form) }} - -

-{% endblock %} diff --git a/ihatemoney/tests/tests.py b/ihatemoney/tests/tests.py index c4b1585c..b13c7c07 100644 --- a/ihatemoney/tests/tests.py +++ b/ihatemoney/tests/tests.py @@ -1,25 +1,24 @@ -# coding: utf8 -import unittest -from unittest.mock import patch - +import base64 +from collections import defaultdict import datetime -import os import io import json -import base64 - -from collections import defaultdict +import os +import re from time import sleep +import unittest +from unittest.mock import MagicMock, patch -from werkzeug.security import generate_password_hash, check_password_hash from flask import session from flask_testing import TestCase - -from ihatemoney.run import create_app, db, load_configuration -from ihatemoney.manage import GenerateConfig, GeneratePasswordHash, DeleteProject -from ihatemoney import models -from ihatemoney import utils from sqlalchemy import orm +from werkzeug.security import check_password_hash, generate_password_hash + +from ihatemoney import history, models, utils +from ihatemoney.currency_convertor import CurrencyConverter +from ihatemoney.manage import DeleteProject, GenerateConfig, GeneratePasswordHash +from ihatemoney.run import create_app, db, load_configuration +from ihatemoney.versioning import LoggingMode # Unset configuration file env var if previously set os.environ.pop("IHATEMONEY_SETTINGS_FILE_PATH", None) @@ -61,7 +60,8 @@ class BaseTestCase(TestCase): "name": name, "id": name, "password": name, - "contact_email": "%s@notmyidea.org" % name, + "contact_email": f"{name}@notmyidea.org", + "default_currency": "USD", }, ) @@ -70,7 +70,8 @@ class BaseTestCase(TestCase): id=name, name=str(name), password=generate_password_hash(name), - contact_email="%s@notmyidea.org" % name, + contact_email=f"{name}@notmyidea.org", + default_currency="USD", ) models.db.session.add(project) models.db.session.commit() @@ -85,7 +86,7 @@ class IhatemoneyTestCase(BaseTestCase): return self.assertEqual( expected, resp.status_code, - "%s expected %s, got %s" % (url, expected, resp.status_code), + f"{url} expected {expected}, got {resp.status_code}", ) @@ -141,24 +142,24 @@ class BudgetTestCase(IhatemoneyTestCase): self.post_project("raclette") self.client.post( - "/raclette/invite", data={"emails": "alexis@notmyidea.org"} + "/raclette/invite", data={"emails": "zorglub@notmyidea.org"} ) self.assertEqual(len(outbox), 2) self.assertEqual(outbox[0].recipients, ["raclette@notmyidea.org"]) - self.assertEqual(outbox[1].recipients, ["alexis@notmyidea.org"]) + self.assertEqual(outbox[1].recipients, ["zorglub@notmyidea.org"]) # sending a message to multiple persons with self.app.mail.record_messages() as outbox: self.client.post( "/raclette/invite", - data={"emails": "alexis@notmyidea.org, toto@notmyidea.org"}, + data={"emails": "zorglub@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"] + outbox[0].recipients, ["zorglub@notmyidea.org", "toto@notmyidea.org"] ) # mail address checking @@ -170,7 +171,7 @@ class BudgetTestCase(IhatemoneyTestCase): # mixing good and wrong addresses shouldn't send any messages with self.app.mail.record_messages() as outbox: self.client.post( - "/raclette/invite", data={"emails": "alexis@notmyidea.org, alexis"} + "/raclette/invite", data={"emails": "zorglub@notmyidea.org, zorglub"} ) # not valid # only one message is sent to multiple persons @@ -257,6 +258,7 @@ class BudgetTestCase(IhatemoneyTestCase): "id": "raclette", "password": "party", "contact_email": "raclette@notmyidea.org", + "default_currency": "USD", }, ) @@ -276,6 +278,7 @@ class BudgetTestCase(IhatemoneyTestCase): "id": "raclette", # already used ! "password": "party", "contact_email": "raclette@notmyidea.org", + "default_currency": "USD", }, ) @@ -293,6 +296,7 @@ class BudgetTestCase(IhatemoneyTestCase): "id": "raclette", "password": "party", "contact_email": "raclette@notmyidea.org", + "default_currency": "USD", }, ) @@ -313,6 +317,7 @@ class BudgetTestCase(IhatemoneyTestCase): "id": "raclette", "password": "party", "contact_email": "raclette@notmyidea.org", + "default_currency": "USD", }, ) @@ -332,6 +337,7 @@ class BudgetTestCase(IhatemoneyTestCase): "id": "raclette", "password": "party", "contact_email": "raclette@notmyidea.org", + "default_currency": "USD", }, ) @@ -355,7 +361,7 @@ class BudgetTestCase(IhatemoneyTestCase): result.data.decode("utf-8"), ) - result = self.client.post("/raclette/members/add", data={"name": "alexis"}) + result = self.client.post("/raclette/members/add", data={"name": "zorglub"}) result = self.client.get("/raclette/") @@ -369,11 +375,11 @@ class BudgetTestCase(IhatemoneyTestCase): self.login("raclette") # adds a member to this project - self.client.post("/raclette/members/add", data={"name": "alexis"}) + self.client.post("/raclette/members/add", data={"name": "zorglub"}) self.assertEqual(len(models.Project.query.get("raclette").members), 1) # adds him twice - result = self.client.post("/raclette/members/add", data={"name": "alexis"}) + result = self.client.post("/raclette/members/add", data={"name": "zorglub"}) # should not accept him self.assertEqual(len(models.Project.query.get("raclette").members), 1) @@ -412,7 +418,7 @@ class BudgetTestCase(IhatemoneyTestCase): ) # remove fred - self.client.post("/raclette/members/%s/delete" % fred_id) + self.client.post(f"/raclette/members/{fred_id}/delete") # he is still in the database, but is deactivated self.assertEqual(len(models.Project.query.get("raclette").members), 2) @@ -422,7 +428,7 @@ class BudgetTestCase(IhatemoneyTestCase): # a bill or displaying the balance result = self.client.get("/raclette/") self.assertNotIn( - ("/raclette/members/%s/delete" % fred_id), result.data.decode("utf-8") + (f"/raclette/members/{fred_id}/delete"), result.data.decode("utf-8") ) result = self.client.get("/raclette/add") @@ -444,11 +450,11 @@ class BudgetTestCase(IhatemoneyTestCase): self.login("raclette") # adds a member to this project - self.client.post("/raclette/members/add", data={"name": "alexis"}) - alexis = models.Project.query.get("raclette").members[-1] + self.client.post("/raclette/members/add", data={"name": "zorglub"}) + zorglub = models.Project.query.get("raclette").members[-1] # should not have any bills - self.assertFalse(alexis.has_bills()) + self.assertFalse(zorglub.has_bills()) # bound him to a bill self.client.post( @@ -456,22 +462,22 @@ class BudgetTestCase(IhatemoneyTestCase): data={ "date": "2011-08-10", "what": "fromage à raclette", - "payer": alexis.id, - "payed_for": [alexis.id], + "payer": zorglub.id, + "payed_for": [zorglub.id], "amount": "25", }, ) # should have a bill now - alexis = models.Project.query.get("raclette").members[-1] - self.assertTrue(alexis.has_bills()) + zorglub = models.Project.query.get("raclette").members[-1] + self.assertTrue(zorglub.has_bills()) def test_member_delete_method(self): self.post_project("raclette") self.login("raclette") # adds a member to this project - self.client.post("/raclette/members/add", data={"name": "alexis"}) + self.client.post("/raclette/members/add", data={"name": "zorglub"}) # try to remove the member using GET method response = self.client.get("/raclette/members/1/delete") @@ -599,7 +605,7 @@ class BudgetTestCase(IhatemoneyTestCase): self.post_project("raclette") # add two persons - self.client.post("/raclette/members/add", data={"name": "alexis"}) + self.client.post("/raclette/members/add", data={"name": "zorglub"}) self.client.post("/raclette/members/add", data={"name": "fred"}) members_ids = [m.id for m in models.Project.query.get("raclette").members] @@ -621,7 +627,7 @@ class BudgetTestCase(IhatemoneyTestCase): # edit the bill self.client.post( - "/raclette/edit/%s" % bill.id, + f"/raclette/edit/{bill.id}", data={ "date": "2011-08-10", "what": "fromage à raclette", @@ -635,7 +641,7 @@ class BudgetTestCase(IhatemoneyTestCase): self.assertEqual(bill.amount, 10, "bill edition") # delete the bill - self.client.get("/raclette/delete/%s" % bill.id) + self.client.get(f"/raclette/delete/{bill.id}") self.assertEqual(0, len(models.Bill.query.all()), "bill deletion") # test balance @@ -707,7 +713,7 @@ class BudgetTestCase(IhatemoneyTestCase): self.post_project("raclette") # add two persons - self.client.post("/raclette/members/add", data={"name": "alexis"}) + self.client.post("/raclette/members/add", data={"name": "zorglub"}) self.client.post( "/raclette/members/add", data={"name": "freddy familly", "weight": 4} ) @@ -744,8 +750,8 @@ class BudgetTestCase(IhatemoneyTestCase): self.post_project("raclette") # Add two times the same person (with a space at the end). - self.client.post("/raclette/members/add", data={"name": "alexis"}) - self.client.post("/raclette/members/add", data={"name": "alexis "}) + self.client.post("/raclette/members/add", data={"name": "zorglub"}) + self.client.post("/raclette/members/add", data={"name": "zorglub "}) members = models.Project.query.get("raclette").members self.assertEqual(len(members), 1) @@ -754,7 +760,7 @@ class BudgetTestCase(IhatemoneyTestCase): self.post_project("raclette") # add two persons - self.client.post("/raclette/members/add", data={"name": "alexis"}) + self.client.post("/raclette/members/add", data={"name": "zorglub"}) self.client.post("/raclette/members/add", data={"name": "tata", "weight": 1}) resp = self.client.get("/raclette/") @@ -771,9 +777,9 @@ class BudgetTestCase(IhatemoneyTestCase): self.post_project("raclette") # Add one user and edit it to have a negative share - self.client.post("/raclette/members/add", data={"name": "alexis"}) + self.client.post("/raclette/members/add", data={"name": "zorglub"}) resp = self.client.post( - "/raclette/members/1/edit", data={"name": "alexis", "weight": -1} + "/raclette/members/1/edit", data={"name": "zorglub", "weight": -1} ) # An error should be generated, and its weight should still be 1. @@ -785,7 +791,7 @@ class BudgetTestCase(IhatemoneyTestCase): self.post_project("raclette") # add members - self.client.post("/raclette/members/add", data={"name": "alexis"}) + self.client.post("/raclette/members/add", data={"name": "zorglub"}) self.client.post("/raclette/members/add", data={"name": "fred"}) self.client.post("/raclette/members/add", data={"name": "tata"}) @@ -841,8 +847,10 @@ class BudgetTestCase(IhatemoneyTestCase): self.post_project("raclette") new_data = { "name": "Super raclette party!", - "contact_email": "alexis@notmyidea.org", + "contact_email": "zorglub@notmyidea.org", "password": "didoudida", + "logging_preference": LoggingMode.ENABLED.value, + "default_currency": "USD", } resp = self.client.post("/raclette/edit", data=new_data, follow_redirects=True) @@ -851,6 +859,7 @@ class BudgetTestCase(IhatemoneyTestCase): self.assertEqual(project.name, new_data["name"]) self.assertEqual(project.contact_email, new_data["contact_email"]) + self.assertEqual(project.default_currency, new_data["default_currency"]) self.assertTrue(check_password_hash(project.password, new_data["password"])) # Editing a project with a wrong email address should fail @@ -890,11 +899,11 @@ class BudgetTestCase(IhatemoneyTestCase): self.post_project("raclette") # add members - self.client.post("/raclette/members/add", data={"name": "alexis", "weight": 2}) + self.client.post("/raclette/members/add", data={"name": "zorglub", "weight": 2}) 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"}) + self.client.post("/raclette/members/add", data={"name": "pépé"}) # create bills self.client.post( @@ -931,45 +940,34 @@ class BudgetTestCase(IhatemoneyTestCase): ) response = self.client.get("/raclette/statistics") - first_cell = '" - + indent - + "" - + indent - + "\n", - response.data.decode("utf-8"), + regex = r"\s+\s+" + self.assertRegex( + response.data.decode("utf-8"), regex.format("zorglub", "20.00", "31.67"), ) - self.assertIn( - first_cell - + "fred" - + indent - + "" - + indent - + "\n", - response.data.decode("utf-8"), + self.assertRegex( + response.data.decode("utf-8"), regex.format("fred", "20.00", "5.83"), ) - self.assertIn( - first_cell - + "tata" - + indent - + "" - + indent - + "\n", - response.data.decode("utf-8"), + self.assertRegex( + response.data.decode("utf-8"), regex.format("tata", "0.00", "2.50"), ) - self.assertIn( - first_cell - + "toto" - + indent - + "" - + indent - + "\n", - response.data.decode("utf-8"), + self.assertRegex( + response.data.decode("utf-8"), regex.format("pépé", "0.00", "0.00"), ) + # Check that the order of participants in the sidebar table is the + # same as in the main table. + order = ["fred", "pépé", "tata", "zorglub"] + regex1 = r".*".join( + r"".format(name) for name in order + ) + regex2 = r".*".join( + r"".format(name) for name in order + ) + # Build the regexp ourselves to be able to pass the DOTALL flag + # (so that ".*" matches newlines) + self.assertRegex(response.data.decode("utf-8"), re.compile(regex1, re.DOTALL)) + self.assertRegex(response.data.decode("utf-8"), re.compile(regex2, re.DOTALL)) + def test_settle_page(self): self.post_project("raclette") response = self.client.get("/raclette/settle_bills") @@ -979,11 +977,11 @@ class BudgetTestCase(IhatemoneyTestCase): self.post_project("raclette") # add members - self.client.post("/raclette/members/add", data={"name": "alexis"}) + self.client.post("/raclette/members/add", data={"name": "zorglub"}) 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"}) + self.client.post("/raclette/members/add", data={"name": "pépé"}) # create bills self.client.post( @@ -1034,7 +1032,7 @@ class BudgetTestCase(IhatemoneyTestCase): self.post_project("raclette") # add members - self.client.post("/raclette/members/add", data={"name": "alexis"}) + self.client.post("/raclette/members/add", data={"name": "zorglub"}) self.client.post("/raclette/members/add", data={"name": "fred"}) self.client.post("/raclette/members/add", data={"name": "tata"}) @@ -1080,14 +1078,14 @@ class BudgetTestCase(IhatemoneyTestCase): self.assertNotEqual( 0.0, rounded_amount, - msg="%f is equal to zero after rounding" % t["amount"], + msg=f"{t['amount']} is equal to zero after rounding", ) def test_export(self): self.post_project("raclette") # add members - self.client.post("/raclette/members/add", data={"name": "alexis", "weight": 2}) + self.client.post("/raclette/members/add", data={"name": "zorglub", "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é"}) @@ -1101,6 +1099,7 @@ class BudgetTestCase(IhatemoneyTestCase): "payer": 1, "payed_for": [1, 2, 3, 4], "amount": "10.0", + "original_currency": "USD", }, ) @@ -1112,6 +1111,7 @@ class BudgetTestCase(IhatemoneyTestCase): "payer": 2, "payed_for": [1, 3], "amount": "200", + "original_currency": "USD", }, ) @@ -1123,6 +1123,7 @@ class BudgetTestCase(IhatemoneyTestCase): "payer": 3, "payed_for": [2], "amount": "13.33", + "original_currency": "USD", }, ) @@ -1143,15 +1144,15 @@ class BudgetTestCase(IhatemoneyTestCase): "amount": 200.0, "payer_name": "fred", "payer_weight": 1.0, - "owers": ["alexis", "tata"], + "owers": ["zorglub", "tata"], }, { "date": "2016-12-31", "what": "fromage \xe0 raclette", "amount": 10.0, - "payer_name": "alexis", + "payer_name": "zorglub", "payer_weight": 2.0, - "owers": ["alexis", "fred", "tata", "p\xe9p\xe9"], + "owers": ["zorglub", "fred", "tata", "p\xe9p\xe9"], }, ] self.assertEqual(json.loads(resp.data.decode("utf-8")), expected) @@ -1161,8 +1162,8 @@ class BudgetTestCase(IhatemoneyTestCase): 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é"', + '2016-12-31,red wine,200.0,fred,1.0,"zorglub, tata"', + '2016-12-31,fromage à raclette,10.0,zorglub,2.0,"zorglub, fred, tata, pépé"', ] received_lines = resp.data.decode("utf-8").split("\n") @@ -1176,7 +1177,7 @@ class BudgetTestCase(IhatemoneyTestCase): expected = [ {"amount": 2.00, "receiver": "fred", "ower": "p\xe9p\xe9"}, {"amount": 55.34, "receiver": "fred", "ower": "tata"}, - {"amount": 127.33, "receiver": "fred", "ower": "alexis"}, + {"amount": 127.33, "receiver": "fred", "ower": "zorglub"}, ] self.assertEqual(json.loads(resp.data.decode("utf-8")), expected) @@ -1188,7 +1189,7 @@ class BudgetTestCase(IhatemoneyTestCase): "amount,receiver,ower", "2.0,fred,pépé", "55.34,fred,tata", - "127.33,fred,alexis", + "127.33,fred,zorglub", ] received_lines = resp.data.decode("utf-8").split("\n") @@ -1224,15 +1225,15 @@ class BudgetTestCase(IhatemoneyTestCase): "amount": 200.0, "payer_name": "fred", "payer_weight": 1.0, - "owers": ["alexis", "tata"], + "owers": ["zorglub", "tata"], }, { "date": "2016-12-31", "what": "fromage a raclette", "amount": 10.0, - "payer_name": "alexis", + "payer_name": "zorglub", "payer_weight": 2.0, - "owers": ["alexis", "fred", "tata", "pepe"], + "owers": ["zorglub", "fred", "tata", "pepe"], }, ] @@ -1280,7 +1281,7 @@ class BudgetTestCase(IhatemoneyTestCase): project = models.Project.query.get("raclette") - self.client.post("/raclette/members/add", data={"name": "alexis", "weight": 2}) + self.client.post("/raclette/members/add", data={"name": "zorglub", "weight": 2}) self.client.post("/raclette/members/add", data={"name": "fred"}) self.client.post("/raclette/members/add", data={"name": "tata"}) self.client.post( @@ -1309,15 +1310,15 @@ class BudgetTestCase(IhatemoneyTestCase): "amount": 200.0, "payer_name": "fred", "payer_weight": 1.0, - "owers": ["alexis", "tata"], + "owers": ["zorglub", "tata"], }, { "date": "2016-12-31", "what": "fromage a raclette", "amount": 10.0, - "payer_name": "alexis", + "payer_name": "zorglub", "payer_weight": 2.0, - "owers": ["alexis", "fred", "tata", "pepe"], + "owers": ["zorglub", "fred", "tata", "pepe"], }, ] @@ -1418,7 +1419,7 @@ class APITestCase(IhatemoneyTestCase): def api_create(self, name, id=None, password=None, contact=None): id = id or name password = password or name - contact = contact or "%s@notmyidea.org" % name + contact = contact or f"{name}@notmyidea.org" return self.client.post( "/api/projects", @@ -1427,12 +1428,13 @@ class APITestCase(IhatemoneyTestCase): "id": id, "password": password, "contact_email": contact, + "default_currency": "USD", }, ) def api_add_member(self, project, name, weight=1): self.client.post( - "/api/projects/%s/members" % project, + f"/api/projects/{project}/members", data={"name": name, "weight": weight}, headers=self.get_auth(project), ) @@ -1440,11 +1442,11 @@ class APITestCase(IhatemoneyTestCase): def get_auth(self, username, password=None): password = password or username base64string = ( - base64.encodebytes(("%s:%s" % (username, password)).encode("utf-8")) + base64.encodebytes(f"{username}:{password}".encode("utf-8")) .decode("utf-8") .replace("\n", "") ) - return {"Authorization": "Basic %s" % base64string} + return {"Authorization": f"Basic {base64string}"} def test_cors_requests(self): # Create a project and test that CORS headers are present if requested. @@ -1488,6 +1490,7 @@ class APITestCase(IhatemoneyTestCase): "id": "raclette", "password": "raclette", "contact_email": "not-an-email", + "default_currency": "USD", }, ) @@ -1516,7 +1519,9 @@ class APITestCase(IhatemoneyTestCase): "members": [], "name": "raclette", "contact_email": "raclette@notmyidea.org", + "default_currency": "USD", "id": "raclette", + "logging_preference": 1, } decoded_resp = json.loads(resp.data.decode("utf-8")) self.assertDictEqual(decoded_resp, expected) @@ -1526,8 +1531,10 @@ class APITestCase(IhatemoneyTestCase): "/api/projects/raclette", data={ "contact_email": "yeah@notmyidea.org", + "default_currency": "USD", "password": "raclette", "name": "The raclette party", + "project_history": "y", }, headers=self.get_auth("raclette"), ) @@ -1542,8 +1549,10 @@ class APITestCase(IhatemoneyTestCase): expected = { "name": "The raclette party", "contact_email": "yeah@notmyidea.org", + "default_currency": "USD", "members": [], "id": "raclette", + "logging_preference": 1, } decoded_resp = json.loads(resp.data.decode("utf-8")) self.assertDictEqual(decoded_resp, expected) @@ -1553,6 +1562,7 @@ class APITestCase(IhatemoneyTestCase): "/api/projects/raclette", data={ "contact_email": "yeah@notmyidea.org", + "default_currency": "USD", "password": "tartiflette", "name": "The raclette party", }, @@ -1597,7 +1607,7 @@ class APITestCase(IhatemoneyTestCase): # Access with token resp = self.client.get( "/api/projects/raclette/token", - headers={"Authorization": "Basic %s" % decoded_resp["token"]}, + headers={"Authorization": f"Basic {decoded_resp['token']}"}, ) self.assertEqual(200, resp.status_code) @@ -1628,7 +1638,7 @@ class APITestCase(IhatemoneyTestCase): # add a member req = self.client.post( "/api/projects/raclette/members", - data={"name": "Alexis"}, + data={"name": "Zorglub"}, headers=self.get_auth("raclette"), ) @@ -1647,7 +1657,7 @@ class APITestCase(IhatemoneyTestCase): # Try to add another member with the same name. req = self.client.post( "/api/projects/raclette/members", - data={"name": "Alexis"}, + data={"name": "Zorglub"}, headers=self.get_auth("raclette"), ) self.assertStatus(400, req) @@ -1728,9 +1738,9 @@ class APITestCase(IhatemoneyTestCase): self.api_create("raclette") # add members - self.api_add_member("raclette", "alexis") + self.api_add_member("raclette", "zorglub") self.api_add_member("raclette", "fred") - self.api_add_member("raclette", "arnaud") + self.api_add_member("raclette", "quentin") # get the list of bills (should be empty) req = self.client.get( @@ -1769,12 +1779,14 @@ class APITestCase(IhatemoneyTestCase): "what": "fromage", "payer_id": 1, "owers": [ - {"activated": True, "id": 1, "name": "alexis", "weight": 1}, + {"activated": True, "id": 1, "name": "zorglub", "weight": 1}, {"activated": True, "id": 2, "name": "fred", "weight": 1}, ], "amount": 25.0, "date": "2011-08-10", "id": 1, + "converted_amount": 25.0, + "original_currency": "USD", "external_link": "https://raclette.fr", } @@ -1838,12 +1850,14 @@ class APITestCase(IhatemoneyTestCase): "what": "beer", "payer_id": 2, "owers": [ - {"activated": True, "id": 1, "name": "alexis", "weight": 1}, + {"activated": True, "id": 1, "name": "zorglub", "weight": 1}, {"activated": True, "id": 2, "name": "fred", "weight": 1}, ], "amount": 25.0, "date": "2011-09-10", "external_link": "https://raclette.fr", + "converted_amount": 25.0, + "original_currency": "USD", "id": 1, } @@ -1872,7 +1886,7 @@ class APITestCase(IhatemoneyTestCase): self.api_create("raclette") # add members - self.api_add_member("raclette", "alexis") + self.api_add_member("raclette", "zorglub") self.api_add_member("raclette", "fred") # valid amounts @@ -1914,13 +1928,15 @@ class APITestCase(IhatemoneyTestCase): "what": "fromage", "payer_id": 1, "owers": [ - {"activated": True, "id": 1, "name": "alexis", "weight": 1}, + {"activated": True, "id": 1, "name": "zorglub", "weight": 1}, {"activated": True, "id": 2, "name": "fred", "weight": 1}, ], "amount": expected_amount, "date": "2011-08-10", "id": id, "external_link": "", + "original_currency": "USD", + "converted_amount": expected_amount, } got = json.loads(req.data.decode("utf-8")) @@ -1959,7 +1975,7 @@ class APITestCase(IhatemoneyTestCase): self.api_create("raclette") # add members - self.api_add_member("raclette", "alexis") + self.api_add_member("raclette", "zorglub") self.api_add_member("raclette", "fred") # add a bill @@ -1987,7 +2003,7 @@ class APITestCase(IhatemoneyTestCase): "member": { "activated": True, "id": 1, - "name": "alexis", + "name": "zorglub", "weight": 1.0, }, "paid": 25.0, @@ -2025,9 +2041,9 @@ class APITestCase(IhatemoneyTestCase): self.api_create("raclette") # add members - self.api_add_member("raclette", "alexis") + self.api_add_member("raclette", "zorglub") self.api_add_member("raclette", "freddy familly", 4) - self.api_add_member("raclette", "arnaud") + self.api_add_member("raclette", "quentin") # add a bill req = self.client.post( @@ -2056,13 +2072,15 @@ class APITestCase(IhatemoneyTestCase): "what": "fromage", "payer_id": 1, "owers": [ - {"activated": True, "id": 1, "name": "alexis", "weight": 1}, + {"activated": True, "id": 1, "name": "zorglub", "weight": 1}, {"activated": True, "id": 2, "name": "freddy familly", "weight": 4}, ], "amount": 25.0, "date": "2011-08-10", "id": 1, "external_link": "", + "converted_amount": 25.0, + "original_currency": "USD", } got = json.loads(req.data.decode("utf-8")) self.assertEqual( @@ -2082,7 +2100,7 @@ class APITestCase(IhatemoneyTestCase): { "activated": True, "id": 1, - "name": "alexis", + "name": "zorglub", "weight": 1.0, "balance": 20.0, }, @@ -2096,7 +2114,7 @@ class APITestCase(IhatemoneyTestCase): { "activated": True, "id": 3, - "name": "arnaud", + "name": "quentin", "weight": 1.0, "balance": 0, }, @@ -2104,12 +2122,33 @@ class APITestCase(IhatemoneyTestCase): "contact_email": "raclette@notmyidea.org", "id": "raclette", "name": "raclette", + "logging_preference": 1, + "default_currency": "USD", } self.assertStatus(200, req) decoded_req = json.loads(req.data.decode("utf-8")) self.assertDictEqual(decoded_req, expected) + def test_log_created_from_api_call(self): + # create a project + self.api_create("raclette") + self.login("raclette") + + # add members + self.api_add_member("raclette", "zorglub") + + resp = self.client.get("/raclette/history", follow_redirects=True) + self.assertEqual(resp.status_code, 200) + self.assertIn( + f"Participant {em_surround('zorglub')} added", resp.data.decode("utf-8") + ) + self.assertIn( + f"Project {em_surround('raclette')} added", resp.data.decode("utf-8"), + ) + self.assertEqual(resp.data.decode("utf-8").count(""), 2) + self.assertNotIn("127.0.0.1", resp.data.decode("utf-8")) + class ServerTestCase(IhatemoneyTestCase): def test_homepage(self): @@ -2167,11 +2206,11 @@ class ModelsTestCase(IhatemoneyTestCase): self.post_project("raclette") # add members - self.client.post("/raclette/members/add", data={"name": "alexis", "weight": 2}) + self.client.post("/raclette/members/add", data={"name": "zorglub", "weight": 2}) 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"}) + self.client.post("/raclette/members/add", data={"name": "pépé"}) # create bills self.client.post( @@ -2208,11 +2247,11 @@ class ModelsTestCase(IhatemoneyTestCase): ) project = models.Project.query.get_by_name(name="raclette") - alexis = models.Person.query.get_by_name(name="alexis", project=project) - alexis_bills = models.Bill.query.options( + zorglub = models.Person.query.get_by_name(name="zorglub", project=project) + zorglub_bills = models.Bill.query.options( orm.subqueryload(models.Bill.owers) - ).filter(models.Bill.owers.contains(alexis)) - for bill in alexis_bills.all(): + ).filter(models.Bill.owers.contains(zorglub)) + for bill in zorglub_bills.all(): if bill.what == "red wine": pay_each_expected = 20 / 2 self.assertEqual(bill.pay_each(), pay_each_expected) @@ -2224,5 +2263,631 @@ class ModelsTestCase(IhatemoneyTestCase): self.assertEqual(bill.pay_each(), pay_each_expected) +def em_surround(string, regex_escape=False): + if regex_escape: + return r'%s<\/em>' % string + else: + return '%s' % string + + +class HistoryTestCase(IhatemoneyTestCase): + def setUp(self): + super().setUp() + self.post_project("demo") + self.login("demo") + + def test_simple_create_logentry_no_ip(self): + resp = self.client.get("/demo/history") + self.assertEqual(resp.status_code, 200) + self.assertIn( + f"Project {em_surround('demo')} added", resp.data.decode("utf-8"), + ) + self.assertEqual(resp.data.decode("utf-8").count(""), 1) + self.assertNotIn("127.0.0.1", resp.data.decode("utf-8")) + + def change_privacy_to(self, logging_preference): + # Change only logging_preferences + new_data = { + "name": "demo", + "contact_email": "demo@notmyidea.org", + "password": "demo", + "default_currency": "USD", + } + + if logging_preference != LoggingMode.DISABLED: + new_data["project_history"] = "y" + if logging_preference == LoggingMode.RECORD_IP: + new_data["ip_recording"] = "y" + + # Disable History + resp = self.client.post("/demo/edit", data=new_data, follow_redirects=True) + self.assertEqual(resp.status_code, 200) + self.assertNotIn("danger", resp.data.decode("utf-8")) + + resp = self.client.get("/demo/edit") + self.assertEqual(resp.status_code, 200) + if logging_preference == LoggingMode.DISABLED: + self.assertIn(' -- ", resp.data.decode("utf-8")) + self.assertNotIn( + f"Project {em_surround('demo')} added", resp.data.decode("utf-8") + ) + + def test_project_edit(self): + new_data = { + "name": "demo2", + "contact_email": "demo2@notmyidea.org", + "password": "123456", + "project_history": "y", + "default_currency": "USD", + } + + resp = self.client.post("/demo/edit", data=new_data, follow_redirects=True) + self.assertEqual(resp.status_code, 200) + + resp = self.client.get("/demo/history") + self.assertEqual(resp.status_code, 200) + self.assertIn(f"Project {em_surround('demo')} added", resp.data.decode("utf-8")) + self.assertIn( + f"Project contact email changed to {em_surround('demo2@notmyidea.org')}", + resp.data.decode("utf-8"), + ) + self.assertIn( + "Project private code changed", resp.data.decode("utf-8"), + ) + self.assertIn( + f"Project renamed to {em_surround('demo2')}", resp.data.decode("utf-8"), + ) + self.assertLess( + resp.data.decode("utf-8").index("Project renamed "), + resp.data.decode("utf-8").index("Project contact email changed to "), + ) + self.assertLess( + resp.data.decode("utf-8").index("Project renamed "), + resp.data.decode("utf-8").index("Project private code changed"), + ) + self.assertEqual(resp.data.decode("utf-8").count(""), 4) + self.assertNotIn("127.0.0.1", resp.data.decode("utf-8")) + + def test_project_privacy_edit(self): + resp = self.client.get("/demo/edit") + self.assertEqual(resp.status_code, 200) + self.assertIn( + '', + resp.data.decode("utf-8"), + ) + + self.change_privacy_to(LoggingMode.DISABLED) + + resp = self.client.get("/demo/history") + self.assertEqual(resp.status_code, 200) + self.assertIn("Disabled Project History\n", resp.data.decode("utf-8")) + self.assertEqual(resp.data.decode("utf-8").count(""), 2) + self.assertNotIn("127.0.0.1", resp.data.decode("utf-8")) + + self.change_privacy_to(LoggingMode.RECORD_IP) + + resp = self.client.get("/demo/history") + self.assertEqual(resp.status_code, 200) + self.assertIn( + "Enabled Project History & IP Address Recording", resp.data.decode("utf-8") + ) + self.assertEqual(resp.data.decode("utf-8").count(""), 2) + self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 1) + + self.change_privacy_to(LoggingMode.ENABLED) + + resp = self.client.get("/demo/history") + self.assertEqual(resp.status_code, 200) + self.assertIn("Disabled IP Address Recording\n", resp.data.decode("utf-8")) + self.assertEqual(resp.data.decode("utf-8").count(""), 2) + self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 2) + + def test_project_privacy_edit2(self): + self.change_privacy_to(LoggingMode.RECORD_IP) + + resp = self.client.get("/demo/history") + self.assertEqual(resp.status_code, 200) + self.assertIn("Enabled IP Address Recording\n", resp.data.decode("utf-8")) + self.assertEqual(resp.data.decode("utf-8").count(""), 1) + self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 1) + + self.change_privacy_to(LoggingMode.DISABLED) + + resp = self.client.get("/demo/history") + self.assertEqual(resp.status_code, 200) + self.assertIn( + "Disabled Project History & IP Address Recording", resp.data.decode("utf-8") + ) + self.assertEqual(resp.data.decode("utf-8").count(""), 1) + self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 2) + + self.change_privacy_to(LoggingMode.ENABLED) + + resp = self.client.get("/demo/history") + self.assertEqual(resp.status_code, 200) + self.assertIn("Enabled Project History\n", resp.data.decode("utf-8")) + self.assertEqual(resp.data.decode("utf-8").count(""), 2) + self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 2) + + def do_misc_database_operations(self, logging_mode): + new_data = { + "name": "demo2", + "contact_email": "demo2@notmyidea.org", + "password": "123456", + "default_currency": "USD", + } + + # Keep privacy settings where they were + if logging_mode != LoggingMode.DISABLED: + new_data["project_history"] = "y" + if logging_mode == LoggingMode.RECORD_IP: + new_data["ip_recording"] = "y" + + resp = self.client.post("/demo/edit", data=new_data, follow_redirects=True) + self.assertEqual(resp.status_code, 200) + + # adds a member to this project + resp = self.client.post( + "/demo/members/add", data={"name": "zorglub"}, follow_redirects=True + ) + self.assertEqual(resp.status_code, 200) + + user_id = models.Person.query.one().id + + # create a bill + resp = self.client.post( + "/demo/add", + data={ + "date": "2011-08-10", + "what": "fromage à raclette", + "payer": user_id, + "payed_for": [user_id], + "amount": "25", + }, + follow_redirects=True, + ) + self.assertEqual(resp.status_code, 200) + + bill_id = models.Bill.query.one().id + + # edit the bill + resp = self.client.post( + f"/demo/edit/{bill_id}", + data={ + "date": "2011-08-10", + "what": "fromage à raclette", + "payer": user_id, + "payed_for": [user_id], + "amount": "10", + }, + follow_redirects=True, + ) + self.assertEqual(resp.status_code, 200) + # delete the bill + resp = self.client.get(f"/demo/delete/{bill_id}", follow_redirects=True) + self.assertEqual(resp.status_code, 200) + + # delete user using POST method + resp = self.client.post( + f"/demo/members/{user_id}/delete", follow_redirects=True + ) + self.assertEqual(resp.status_code, 200) + + def test_disable_clear_no_new_records(self): + # Disable logging + self.change_privacy_to(LoggingMode.DISABLED) + + resp = self.client.get("/demo/history") + self.assertEqual(resp.status_code, 200) + self.assertIn( + "This project has history disabled. New actions won't appear below. ", + resp.data.decode("utf-8"), + ) + self.assertIn( + "The table below reflects actions recorded prior to disabling project history.", + resp.data.decode("utf-8"), + ) + self.assertNotIn( + "Nothing to list", resp.data.decode("utf-8"), + ) + self.assertNotIn( + "Some entries below contain IP addresses,", resp.data.decode("utf-8"), + ) + + # Clear Existing Entries + resp = self.client.post("/demo/erase_history", follow_redirects=True) + self.assertEqual(resp.status_code, 200) + self.assert_empty_history_logging_disabled() + + # Do lots of database operations & check that there's still no history + self.do_misc_database_operations(LoggingMode.DISABLED) + + self.assert_empty_history_logging_disabled() + + def test_clear_ip_records(self): + # Enable IP Recording + self.change_privacy_to(LoggingMode.RECORD_IP) + + # Do lots of database operations to generate IP address entries + self.do_misc_database_operations(LoggingMode.RECORD_IP) + + # Disable IP Recording + self.change_privacy_to(LoggingMode.ENABLED) + + resp = self.client.get("/demo/history") + self.assertEqual(resp.status_code, 200) + self.assertNotIn( + "This project has history disabled. New actions won't appear below. ", + resp.data.decode("utf-8"), + ) + self.assertNotIn( + "The table below reflects actions recorded prior to disabling project history.", + resp.data.decode("utf-8"), + ) + self.assertNotIn( + "Nothing to list", resp.data.decode("utf-8"), + ) + self.assertIn( + "Some entries below contain IP addresses,", resp.data.decode("utf-8"), + ) + self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 10) + self.assertEqual(resp.data.decode("utf-8").count(""), 1) + + # Generate more operations to confirm additional IP info isn't recorded + self.do_misc_database_operations(LoggingMode.ENABLED) + + resp = self.client.get("/demo/history") + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 10) + self.assertEqual(resp.data.decode("utf-8").count(""), 6) + + # Clear IP Data + resp = self.client.post("/demo/strip_ip_addresses", follow_redirects=True) + self.assertEqual(resp.status_code, 200) + self.assertNotIn( + "This project has history disabled. New actions won't appear below. ", + resp.data.decode("utf-8"), + ) + self.assertNotIn( + "The table below reflects actions recorded prior to disabling project history.", + resp.data.decode("utf-8"), + ) + self.assertNotIn( + "Nothing to list", resp.data.decode("utf-8"), + ) + self.assertNotIn( + "Some entries below contain IP addresses,", resp.data.decode("utf-8"), + ) + self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 0) + self.assertEqual(resp.data.decode("utf-8").count(""), 16) + + def test_logs_for_common_actions(self): + # adds a member to this project + resp = self.client.post( + "/demo/members/add", data={"name": "zorglub"}, follow_redirects=True + ) + self.assertEqual(resp.status_code, 200) + + resp = self.client.get("/demo/history") + self.assertEqual(resp.status_code, 200) + self.assertIn( + f"Participant {em_surround('zorglub')} added", resp.data.decode("utf-8") + ) + + # create a bill + resp = self.client.post( + "/demo/add", + data={ + "date": "2011-08-10", + "what": "fromage à raclette", + "payer": 1, + "payed_for": [1], + "amount": "25", + }, + follow_redirects=True, + ) + self.assertEqual(resp.status_code, 200) + + resp = self.client.get("/demo/history") + self.assertEqual(resp.status_code, 200) + self.assertIn( + f"Bill {em_surround('fromage à raclette')} added", + resp.data.decode("utf-8"), + ) + + # edit the bill + resp = self.client.post( + "/demo/edit/1", + data={ + "date": "2011-08-10", + "what": "new thing", + "payer": 1, + "payed_for": [1], + "amount": "10", + }, + follow_redirects=True, + ) + self.assertEqual(resp.status_code, 200) + + resp = self.client.get("/demo/history") + self.assertEqual(resp.status_code, 200) + self.assertIn( + f"Bill {em_surround('fromage à raclette')} added", + resp.data.decode("utf-8"), + ) + self.assertRegex( + resp.data.decode("utf-8"), + r"Bill %s:\s* Amount changed\s* from %s\s* to %s" + % ( + em_surround("fromage à raclette", regex_escape=True), + em_surround("25.0", regex_escape=True), + em_surround("10.0", regex_escape=True), + ), + ) + self.assertIn( + "Bill %s renamed to %s" + % (em_surround("fromage à raclette"), em_surround("new thing"),), + resp.data.decode("utf-8"), + ) + self.assertLess( + resp.data.decode("utf-8").index( + f"Bill {em_surround('fromage à raclette')} renamed to" + ), + resp.data.decode("utf-8").index("Amount changed"), + ) + + # delete the bill + resp = self.client.get("/demo/delete/1", follow_redirects=True) + self.assertEqual(resp.status_code, 200) + + resp = self.client.get("/demo/history") + self.assertEqual(resp.status_code, 200) + self.assertIn( + f"Bill {em_surround('new thing')} removed", resp.data.decode("utf-8"), + ) + + # edit user + resp = self.client.post( + "/demo/members/1/edit", + data={"weight": 2, "name": "new name"}, + follow_redirects=True, + ) + self.assertEqual(resp.status_code, 200) + + resp = self.client.get("/demo/history") + self.assertEqual(resp.status_code, 200) + self.assertRegex( + resp.data.decode("utf-8"), + r"Participant %s:\s* Weight changed\s* from %s\s* to %s" + % ( + em_surround("zorglub", regex_escape=True), + em_surround("1.0", regex_escape=True), + em_surround("2.0", regex_escape=True), + ), + ) + self.assertIn( + "Participant %s renamed to %s" + % (em_surround("zorglub"), em_surround("new name"),), + resp.data.decode("utf-8"), + ) + self.assertLess( + resp.data.decode("utf-8").index( + f"Participant {em_surround('zorglub')} renamed" + ), + resp.data.decode("utf-8").index("Weight changed"), + ) + + # delete user using POST method + resp = self.client.post("/demo/members/1/delete", follow_redirects=True) + self.assertEqual(resp.status_code, 200) + + resp = self.client.get("/demo/history") + self.assertEqual(resp.status_code, 200) + self.assertIn( + f"Participant {em_surround('new name')} removed", resp.data.decode("utf-8") + ) + + def test_double_bill_double_person_edit_second(self): + + # add two members + self.client.post("/demo/members/add", data={"name": "User 1"}) + self.client.post("/demo/members/add", data={"name": "User 2"}) + + # add two bills + self.client.post( + "/demo/add", + data={ + "date": "2020-04-13", + "what": "Bill 1", + "payer": 1, + "payed_for": [1, 2], + "amount": "25", + }, + ) + self.client.post( + "/demo/add", + data={ + "date": "2020-04-13", + "what": "Bill 2", + "payer": 1, + "payed_for": [1, 2], + "amount": "20", + }, + ) + + # Should be 5 history entries at this point + resp = self.client.get("/demo/history") + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.data.decode("utf-8").count(""), 5) + self.assertNotIn("127.0.0.1", resp.data.decode("utf-8")) + + # Edit ONLY the amount on the first bill + self.client.post( + "/demo/edit/1", + data={ + "date": "2020-04-13", + "what": "Bill 1", + "payer": 1, + "payed_for": [1, 2], + "amount": "88", + }, + ) + + resp = self.client.get("/demo/history") + self.assertEqual(resp.status_code, 200) + self.assertRegex( + resp.data.decode("utf-8"), + r"Bill {}:\s* Amount changed\s* from {}\s* to {}".format( + em_surround("Bill 1", regex_escape=True), + em_surround("25.0", regex_escape=True), + em_surround("88.0", regex_escape=True), + ), + ) + + self.assertNotRegex( + resp.data.decode("utf-8"), + r"Removed\s* {}\s* and\s* {}\s* from\s* owers list".format( + em_surround("User 1", regex_escape=True), + em_surround("User 2", regex_escape=True), + ), + resp.data.decode("utf-8"), + ) + + # Should be 6 history entries at this point + self.assertEqual(resp.data.decode("utf-8").count(""), 6) + self.assertNotIn("127.0.0.1", resp.data.decode("utf-8")) + + def test_bill_add_remove_add(self): + # add two members + self.client.post("/demo/members/add", data={"name": "User 1"}) + self.client.post("/demo/members/add", data={"name": "User 2"}) + + # add 1 bill + self.client.post( + "/demo/add", + data={ + "date": "2020-04-13", + "what": "Bill 1", + "payer": 1, + "payed_for": [1, 2], + "amount": "25", + }, + ) + + # delete the bill + self.client.get("/demo/delete/1", follow_redirects=True) + + resp = self.client.get("/demo/history") + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.data.decode("utf-8").count(""), 5) + self.assertNotIn("127.0.0.1", resp.data.decode("utf-8")) + self.assertIn(f"Bill {em_surround('Bill 1')} added", resp.data.decode("utf-8")) + self.assertIn( + f"Bill {em_surround('Bill 1')} removed", resp.data.decode("utf-8"), + ) + + # Add a new bill + self.client.post( + "/demo/add", + data={ + "date": "2020-04-13", + "what": "Bill 2", + "payer": 1, + "payed_for": [1, 2], + "amount": "20", + }, + ) + + resp = self.client.get("/demo/history") + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.data.decode("utf-8").count(""), 6) + self.assertNotIn("127.0.0.1", resp.data.decode("utf-8")) + self.assertIn(f"Bill {em_surround('Bill 1')} added", resp.data.decode("utf-8")) + self.assertEqual( + resp.data.decode("utf-8").count(f"Bill {em_surround('Bill 1')} added"), 1, + ) + self.assertIn(f"Bill {em_surround('Bill 2')} added", resp.data.decode("utf-8")) + self.assertIn( + f"Bill {em_surround('Bill 1')} removed", resp.data.decode("utf-8"), + ) + + def test_double_bill_double_person_edit_second_no_web(self): + u1 = models.Person(project_id="demo", name="User 1") + u2 = models.Person(project_id="demo", name="User 1") + + models.db.session.add(u1) + models.db.session.add(u2) + models.db.session.commit() + + b1 = models.Bill(what="Bill 1", payer_id=u1.id, owers=[u2], amount=10,) + b2 = models.Bill(what="Bill 2", payer_id=u2.id, owers=[u2], amount=11,) + + # This db commit exposes the "spurious owers edit" bug + models.db.session.add(b1) + models.db.session.commit() + + models.db.session.add(b2) + models.db.session.commit() + + history_list = history.get_history(models.Project.query.get("demo")) + self.assertEqual(len(history_list), 5) + + # Change just the amount + b1.amount = 5 + models.db.session.commit() + + history_list = history.get_history(models.Project.query.get("demo")) + for entry in history_list: + if "prop_changed" in entry: + self.assertNotIn("owers", entry["prop_changed"]) + self.assertEqual(len(history_list), 6) + + +class TestCurrencyConverter(unittest.TestCase): + converter = CurrencyConverter() + mock_data = {"USD": 1, "EUR": 0.8115} + converter.get_rates = MagicMock(return_value=mock_data) + + def test_only_one_instance(self): + one = id(CurrencyConverter()) + two = id(CurrencyConverter()) + self.assertEqual(one, two) + + def test_get_currencies(self): + self.assertCountEqual(self.converter.get_currencies(), ["USD", "EUR"]) + + def test_exchange_currency(self): + result = self.converter.exchange_currency(100, "USD", "EUR") + self.assertEqual(result, 81.15) + + if __name__ == "__main__": unittest.main() diff --git a/ihatemoney/translations/cs/LC_MESSAGES/messages.mo b/ihatemoney/translations/cs/LC_MESSAGES/messages.mo new file mode 100644 index 00000000..58260758 Binary files /dev/null and b/ihatemoney/translations/cs/LC_MESSAGES/messages.mo differ diff --git a/ihatemoney/translations/cs/LC_MESSAGES/messages.po b/ihatemoney/translations/cs/LC_MESSAGES/messages.po index adc914fb..ed2fddf0 100644 --- a/ihatemoney/translations/cs/LC_MESSAGES/messages.po +++ b/ihatemoney/translations/cs/LC_MESSAGES/messages.po @@ -1,16 +1,18 @@ + msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2019-10-24 18:27+0200\n" +"POT-Creation-Date: 2020-04-25 13:02+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Automatically generated\n" -"Language-Team: none\n" "Language: cs\n" +"Language-Team: none\n" +"Plural-Forms: nplurals=3; plural=((n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2)\n" "MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" +"Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"X-Generator: Translate Toolkit 2.4.0\n" +"Generated-By: Babel 2.8.0\n" msgid "" "Not a valid amount or expression. Only numbers and + - * / operators are " @@ -26,6 +28,18 @@ msgstr "" msgid "Email" msgstr "" +msgid "Enable project history" +msgstr "" + +msgid "Use IP tracking for project history" +msgstr "" + +msgid "Import previously exported JSON file" +msgstr "" + +msgid "Import" +msgstr "" + msgid "Project identifier" msgstr "" @@ -120,6 +134,15 @@ msgstr "" msgid "The email %(email)s is not valid" msgstr "" +msgid "Participant" +msgstr "" + +msgid "Bill" +msgstr "" + +msgid "Project" +msgstr "" + msgid "Too many failed login attempts, please retry later." msgstr "" @@ -153,6 +176,12 @@ msgstr "" msgid "Password successfully reset." msgstr "" +msgid "Project successfully uploaded" +msgstr "" + +msgid "Invalid JSON" +msgstr "" + msgid "Project successfully deleted" msgstr "" @@ -164,7 +193,7 @@ msgid "Your invitations have been sent" msgstr "" #, python-format -msgid "%(member)s had been added" +msgid "%(member)s has been added" msgstr "" #, python-format @@ -218,9 +247,6 @@ msgstr "" msgid "Create a new project" msgstr "" -msgid "Project" -msgstr "" - msgid "Number of members" msgstr "" @@ -242,6 +268,9 @@ msgstr "" msgid "delete" msgstr "" +msgid "see" +msgstr "" + msgid "The Dashboard is currently deactivated." msgstr "" @@ -251,6 +280,12 @@ msgstr "" msgid "Edit project" msgstr "" +msgid "Import JSON" +msgstr "" + +msgid "Choose file" +msgstr "" + msgid "Download project's data" msgstr "" @@ -272,6 +307,9 @@ msgstr "" msgid "Cancel" msgstr "" +msgid "Privacy Settings" +msgstr "" + msgid "Edit the project" msgstr "" @@ -302,6 +340,177 @@ msgstr "" msgid "Download" msgstr "" +msgid "Disabled Project History" +msgstr "" + +msgid "Disabled Project History & IP Address Recording" +msgstr "" + +msgid "Enabled Project History" +msgstr "" + +msgid "Disabled IP Address Recording" +msgstr "" + +msgid "Enabled Project History & IP Address Recording" +msgstr "" + +msgid "Enabled IP Address Recording" +msgstr "" + +msgid "History Settings Changed" +msgstr "" + +msgid "changed" +msgstr "" + +msgid "from" +msgstr "" + +msgid "to" +msgstr "" + +msgid "Confirm Remove IP Adresses" +msgstr "" + +msgid "" +"Are you sure you want to delete all recorded IP addresses from this " +"project?\n" +" The rest of the project history will be unaffected. This " +"action cannot be undone." +msgstr "" + +msgid "Close" +msgstr "" + +msgid "Confirm Delete" +msgstr "" + +msgid "Delete Confirmation" +msgstr "" + +msgid "" +"Are you sure you want to erase all history for this project? This action " +"cannot be undone." +msgstr "" + +msgid "Added" +msgstr "" + +msgid "Removed" +msgstr "" + +msgid "and" +msgstr "" + +msgid "owers list" +msgstr "" + +msgid "Who?" +msgstr "" + +msgid "Balance" +msgstr "" + +#, python-format +msgid "" +"\n" +" This project has history disabled. New actions won't " +"appear below. You can enable history on the\n" +" settings page\n" +" " +msgstr "" + +msgid "" +"\n" +" The table below reflects actions recorded prior to " +"disabling project history. You can\n" +" clear project history to remove " +"them.

\n" +" " +msgstr "" + +msgid "" +"Some entries below contain IP addresses, even though this project has IP " +"recording disabled. " +msgstr "" + +msgid "Delete stored IP addresses" +msgstr "" + +msgid "No history to erase" +msgstr "" + +msgid "Clear Project History" +msgstr "" + +msgid "No IP Addresses to erase" +msgstr "" + +msgid "Delete Stored IP Addresses" +msgstr "" + +msgid "Time" +msgstr "" + +msgid "Event" +msgstr "" + +msgid "IP address recording can be enabled on the settings page" +msgstr "" + +msgid "IP address recording can be disabled on the settings page" +msgstr "" + +msgid "From IP" +msgstr "" + +msgid "added" +msgstr "" + +msgid "Project private code changed" +msgstr "" + +msgid "Project renamed to" +msgstr "" + +msgid "Project contact email changed to" +msgstr "" + +msgid "Project settings modified" +msgstr "" + +msgid "deactivated" +msgstr "" + +msgid "reactivated" +msgstr "" + +msgid "renamed to" +msgstr "" + +msgid "External link changed to" +msgstr "" + +msgid "Amount" +msgstr "" + +msgid "modified" +msgstr "" + +msgid "removed" +msgstr "" + +msgid "changed in a unknown way" +msgstr "" + +msgid "Nothing to list" +msgstr "" + +msgid "Someone probably cleared the project history." +msgstr "" + msgid "Manage your shared
expenses, easily" msgstr "" @@ -349,6 +558,9 @@ msgstr "" msgid "Statistics" msgstr "" +msgid "History" +msgstr "" + msgid "Settings" msgstr "" @@ -406,6 +618,12 @@ msgstr "" msgid "Add a new bill" msgstr "" +msgid "Newer bills" +msgstr "" + +msgid "Older bills" +msgstr "" + msgid "When?" msgstr "" @@ -432,9 +650,6 @@ msgstr "" msgid "each" msgstr "" -msgid "see" -msgstr "" - msgid "No bills" msgstr "" @@ -503,14 +718,14 @@ msgstr "" msgid "To whom?" msgstr "" -msgid "Who?" -msgstr "" - msgid "Paid" msgstr "" msgid "Spent" msgstr "" -msgid "Balance" +msgid "Expenses by Month" +msgstr "" + +msgid "Period" msgstr "" diff --git a/ihatemoney/translations/de/LC_MESSAGES/messages.mo b/ihatemoney/translations/de/LC_MESSAGES/messages.mo index f5065170..8cf55064 100644 Binary files a/ihatemoney/translations/de/LC_MESSAGES/messages.mo and b/ihatemoney/translations/de/LC_MESSAGES/messages.mo differ diff --git a/ihatemoney/translations/de/LC_MESSAGES/messages.po b/ihatemoney/translations/de/LC_MESSAGES/messages.po index d96ba17a..ee83f826 100644 --- a/ihatemoney/translations/de/LC_MESSAGES/messages.po +++ b/ihatemoney/translations/de/LC_MESSAGES/messages.po @@ -1,25 +1,26 @@ + msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2019-10-12 09:58+0200\n" +"POT-Creation-Date: 2020-04-25 13:02+0200\n" "PO-Revision-Date: 2020-02-12 10:50+0000\n" "Last-Translator: flolilo \n" -"Language-Team: German \n" "Language: de\n" +"Language-Team: German \n" +"Plural-Forms: nplurals=2; plural=n != 1\n" "MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" +"Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 3.11-dev\n" +"Generated-By: Babel 2.8.0\n" msgid "" "Not a valid amount or expression. Only numbers and + - * / operators are " "accepted." msgstr "" -"Kein gültiger Betrag oder Ausdruck. Es werden nur Zahlen und die Operatoren +" -" - * / akzeptiert." +"Kein gültiger Betrag oder Ausdruck. Es werden nur Zahlen und die " +"Operatoren + - * / akzeptiert." msgid "Project name" msgstr "Projektname" @@ -30,6 +31,18 @@ msgstr "Privater Code" msgid "Email" msgstr "E-Mail" +msgid "Enable project history" +msgstr "" + +msgid "Use IP tracking for project history" +msgstr "" + +msgid "Import previously exported JSON file" +msgstr "" + +msgid "Import" +msgstr "" + msgid "Project identifier" msgstr "Projektkennung" @@ -41,8 +54,8 @@ msgid "" "A project with this identifier (\"%(project)s\") already exists. Please " "choose a new identifier" msgstr "" -"Ein Projekt mit der Kennung (\"%(project)s\") existiert bereits. Bitte wähle " -"eine andere Kennung" +"Ein Projekt mit der Kennung (\"%(project)s\") existiert bereits. Bitte " +"wähle eine andere Kennung" msgid "Get in" msgstr "Eintreten" @@ -80,6 +93,12 @@ msgstr "Von" msgid "Amount paid" msgstr "Betrag" +msgid "External link" +msgstr "" + +msgid "A link to an external document, related to this bill" +msgstr "" + msgid "For whom?" msgstr "Für wen?" @@ -120,6 +139,15 @@ msgstr "Einladung senden" msgid "The email %(email)s is not valid" msgstr "Die E-Mail-Adresse(n) %(email)s ist/sind nicht gültig" +msgid "Participant" +msgstr "" + +msgid "Bill" +msgstr "" + +msgid "Project" +msgstr "Projekt" + msgid "Too many failed login attempts, please retry later." msgstr "Zu viele fehlgeschlagene Anmeldeversuche, bitte versuche es später." @@ -130,8 +158,7 @@ msgstr "" "verbleibend." msgid "You either provided a bad token or no project identifier." -msgstr "" -"Du hast entweder einen ungültigen Token oder keine Projekt-ID angegeben." +msgstr "Du hast entweder einen ungültigen Token oder keine Projekt-ID angegeben." msgid "This private code is not the right one" msgstr "Der private Code ist nicht korrekt" @@ -158,6 +185,12 @@ msgstr "Unbekanntes Projekt" msgid "Password successfully reset." msgstr "Passwort erfolgreich zurückgesetzt." +msgid "Project successfully uploaded" +msgstr "" + +msgid "Invalid JSON" +msgstr "" + msgid "Project successfully deleted" msgstr "Projekt erfolgreich gelöscht" @@ -169,8 +202,8 @@ msgid "Your invitations have been sent" msgstr "Deine Einladungen wurden versendet" #, python-format -msgid "%(member)s had been added" -msgstr "%(member)s wurde(n) hinzugefügt" +msgid "%(member)s has been added" +msgstr "" #, python-format msgid "%(name)s is part of this project again" @@ -225,9 +258,6 @@ msgstr "?" msgid "Create a new project" msgstr "Neues Projekt erstellen" -msgid "Project" -msgstr "Projekt" - msgid "Number of members" msgstr "Anzahl der Teilnehmer" @@ -249,6 +279,9 @@ msgstr "Bearbeiten" msgid "delete" msgstr "Löschen" +msgid "see" +msgstr "" + msgid "The Dashboard is currently deactivated." msgstr "Das Dashboard ist aktuell deaktiviert." @@ -258,6 +291,12 @@ msgstr "Bist du sicher?" msgid "Edit project" msgstr "Projekt bearbeiten" +msgid "Import JSON" +msgstr "" + +msgid "Choose file" +msgstr "" + msgid "Download project's data" msgstr "Projektdaten herunterladen" @@ -279,6 +318,9 @@ msgstr "Passwort vergessen?" msgid "Cancel" msgstr "Abbrechen" +msgid "Privacy Settings" +msgstr "" + msgid "Edit the project" msgstr "Projekt bearbeiten" @@ -309,6 +351,177 @@ msgstr "Einladung versenden" msgid "Download" msgstr "Herunterladen" +msgid "Disabled Project History" +msgstr "" + +msgid "Disabled Project History & IP Address Recording" +msgstr "" + +msgid "Enabled Project History" +msgstr "" + +msgid "Disabled IP Address Recording" +msgstr "" + +msgid "Enabled Project History & IP Address Recording" +msgstr "" + +msgid "Enabled IP Address Recording" +msgstr "" + +msgid "History Settings Changed" +msgstr "" + +msgid "changed" +msgstr "" + +msgid "from" +msgstr "" + +msgid "to" +msgstr "" + +msgid "Confirm Remove IP Adresses" +msgstr "" + +msgid "" +"Are you sure you want to delete all recorded IP addresses from this " +"project?\n" +" The rest of the project history will be unaffected. This " +"action cannot be undone." +msgstr "" + +msgid "Close" +msgstr "" + +msgid "Confirm Delete" +msgstr "" + +msgid "Delete Confirmation" +msgstr "" + +msgid "" +"Are you sure you want to erase all history for this project? This action " +"cannot be undone." +msgstr "" + +msgid "Added" +msgstr "" + +msgid "Removed" +msgstr "" + +msgid "and" +msgstr "" + +msgid "owers list" +msgstr "" + +msgid "Who?" +msgstr "Wer?" + +msgid "Balance" +msgstr "Bilanz" + +#, python-format +msgid "" +"\n" +" This project has history disabled. New actions won't " +"appear below. You can enable history on the\n" +" settings page\n" +" " +msgstr "" + +msgid "" +"\n" +" The table below reflects actions recorded prior to " +"disabling project history. You can\n" +" clear project history to remove " +"them.

\n" +" " +msgstr "" + +msgid "" +"Some entries below contain IP addresses, even though this project has IP " +"recording disabled. " +msgstr "" + +msgid "Delete stored IP addresses" +msgstr "" + +msgid "No history to erase" +msgstr "" + +msgid "Clear Project History" +msgstr "" + +msgid "No IP Addresses to erase" +msgstr "" + +msgid "Delete Stored IP Addresses" +msgstr "" + +msgid "Time" +msgstr "" + +msgid "Event" +msgstr "" + +msgid "IP address recording can be enabled on the settings page" +msgstr "" + +msgid "IP address recording can be disabled on the settings page" +msgstr "" + +msgid "From IP" +msgstr "" + +msgid "added" +msgstr "" + +msgid "Project private code changed" +msgstr "" + +msgid "Project renamed to" +msgstr "" + +msgid "Project contact email changed to" +msgstr "" + +msgid "Project settings modified" +msgstr "" + +msgid "deactivated" +msgstr "" + +msgid "reactivated" +msgstr "" + +msgid "renamed to" +msgstr "" + +msgid "External link changed to" +msgstr "" + +msgid "Amount" +msgstr "" + +msgid "modified" +msgstr "" + +msgid "removed" +msgstr "" + +msgid "changed in a unknown way" +msgstr "" + +msgid "Nothing to list" +msgstr "" + +msgid "Someone probably cleared the project history." +msgstr "" + msgid "Manage your shared
expenses, easily" msgstr "Verwalten deine geteilten
Ausgaben ganz einfach" @@ -343,8 +556,9 @@ 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!" msgstr "" -"Dieser Zugangscode wird an deine Freunde gesendet. Es wird als Klartext auf " -"dem Server gespeichert. Bitte verwenden daher kein persönliches Passwort!" +"Dieser Zugangscode wird an deine Freunde gesendet. Es wird als Klartext " +"auf dem Server gespeichert. Bitte verwenden daher kein persönliches " +"Passwort!" msgid "Account manager" msgstr "Konten" @@ -358,6 +572,9 @@ msgstr "Bilanz" msgid "Statistics" msgstr "Statistik" +msgid "History" +msgstr "" + msgid "Settings" msgstr "Einstellungen" @@ -415,6 +632,12 @@ msgstr "Du kannst anfangen, Teilnehmer hinzuzufügen" msgid "Add a new bill" msgstr "Neue Ausgabe" +msgid "Newer bills" +msgstr "" + +msgid "Older bills" +msgstr "" + msgid "When?" msgstr "Wann?" @@ -485,8 +708,8 @@ msgid "" "You can share the project identifier and the private code by any " "communication means." msgstr "" -"Du kannst die Projekt-ID und den privaten Code auf jedem Kommunikationsweg " -"weitergeben." +"Du kannst die Projekt-ID und den privaten Code auf jedem " +"Kommunikationsweg weitergeben." msgid "Identifier:" msgstr "ID:" @@ -495,8 +718,7 @@ msgid "Share the Link" msgstr "Link teilen" msgid "You can directly share the following link via your prefered medium" -msgstr "" -"Du kannst den folgenden Link direkt über dein bevorzugtes Medium teilen" +msgstr "Du kannst den folgenden Link direkt über dein bevorzugtes Medium teilen" msgid "Send via Emails" msgstr "Per E-Mail versenden" @@ -507,10 +729,10 @@ msgid "" " creation of this budget management project and we will " "send them an email for you." msgstr "" -"Gib eine (durch Kommas getrennte) Liste von E-Mail-Adressen an, die du über " -"die\n" -"\t\t\tErstellung dieses Projekts informieren möchtest, und wir senden ihnen " -"eine E-Mail." +"Gib eine (durch Kommas getrennte) Liste von E-Mail-Adressen an, die du " +"über die\n" +"\t\t\tErstellung dieses Projekts informieren möchtest, und wir senden " +"ihnen eine E-Mail." msgid "Who pays?" msgstr "Wer zahlt?" @@ -518,14 +740,21 @@ msgstr "Wer zahlt?" msgid "To whom?" msgstr "An wen?" -msgid "Who?" -msgstr "Wer?" - msgid "Paid" msgstr "Bezahlt" msgid "Spent" msgstr "Ausgegeben" -msgid "Balance" -msgstr "Bilanz" +msgid "Expenses by Month" +msgstr "" + +msgid "Period" +msgstr "" + +#~ msgid "Someone probably" +#~ msgstr "" + +#~ msgid "cleared the project history." +#~ msgstr "" + diff --git a/ihatemoney/translations/el/LC_MESSAGES/messages.mo b/ihatemoney/translations/el/LC_MESSAGES/messages.mo index 84234951..69086172 100644 Binary files a/ihatemoney/translations/el/LC_MESSAGES/messages.mo and b/ihatemoney/translations/el/LC_MESSAGES/messages.mo differ diff --git a/ihatemoney/translations/el/LC_MESSAGES/messages.po b/ihatemoney/translations/el/LC_MESSAGES/messages.po index 14132161..337012bb 100644 --- a/ihatemoney/translations/el/LC_MESSAGES/messages.po +++ b/ihatemoney/translations/el/LC_MESSAGES/messages.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2019-10-01 21:48+0200\n" +"POT-Creation-Date: 2020-04-25 13:02+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Automatically generated\n" "Language: el\n" @@ -12,7 +12,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.7.0\n" +"Generated-By: Babel 2.8.0\n" msgid "" "Not a valid amount or expression. Only numbers and + - * / operators are " @@ -28,6 +28,18 @@ msgstr "" msgid "Email" msgstr "" +msgid "Enable project history" +msgstr "" + +msgid "Use IP tracking for project history" +msgstr "" + +msgid "Import previously exported JSON file" +msgstr "" + +msgid "Import" +msgstr "" + msgid "Project identifier" msgstr "" @@ -122,6 +134,15 @@ msgstr "" msgid "The email %(email)s is not valid" msgstr "" +msgid "Participant" +msgstr "" + +msgid "Bill" +msgstr "" + +msgid "Project" +msgstr "" + msgid "Too many failed login attempts, please retry later." msgstr "" @@ -155,6 +176,12 @@ msgstr "" msgid "Password successfully reset." msgstr "" +msgid "Project successfully uploaded" +msgstr "" + +msgid "Invalid JSON" +msgstr "" + msgid "Project successfully deleted" msgstr "" @@ -166,7 +193,7 @@ msgid "Your invitations have been sent" msgstr "" #, python-format -msgid "%(member)s had been added" +msgid "%(member)s has been added" msgstr "" #, python-format @@ -220,9 +247,6 @@ msgstr "" msgid "Create a new project" msgstr "" -msgid "Project" -msgstr "" - msgid "Number of members" msgstr "" @@ -244,6 +268,9 @@ msgstr "" msgid "delete" msgstr "" +msgid "see" +msgstr "" + msgid "The Dashboard is currently deactivated." msgstr "" @@ -253,6 +280,12 @@ msgstr "" msgid "Edit project" msgstr "" +msgid "Import JSON" +msgstr "" + +msgid "Choose file" +msgstr "" + msgid "Download project's data" msgstr "" @@ -274,6 +307,9 @@ msgstr "" msgid "Cancel" msgstr "" +msgid "Privacy Settings" +msgstr "" + msgid "Edit the project" msgstr "" @@ -304,6 +340,177 @@ msgstr "" msgid "Download" msgstr "" +msgid "Disabled Project History" +msgstr "" + +msgid "Disabled Project History & IP Address Recording" +msgstr "" + +msgid "Enabled Project History" +msgstr "" + +msgid "Disabled IP Address Recording" +msgstr "" + +msgid "Enabled Project History & IP Address Recording" +msgstr "" + +msgid "Enabled IP Address Recording" +msgstr "" + +msgid "History Settings Changed" +msgstr "" + +msgid "changed" +msgstr "" + +msgid "from" +msgstr "" + +msgid "to" +msgstr "" + +msgid "Confirm Remove IP Adresses" +msgstr "" + +msgid "" +"Are you sure you want to delete all recorded IP addresses from this " +"project?\n" +" The rest of the project history will be unaffected. This " +"action cannot be undone." +msgstr "" + +msgid "Close" +msgstr "" + +msgid "Confirm Delete" +msgstr "" + +msgid "Delete Confirmation" +msgstr "" + +msgid "" +"Are you sure you want to erase all history for this project? This action " +"cannot be undone." +msgstr "" + +msgid "Added" +msgstr "" + +msgid "Removed" +msgstr "" + +msgid "and" +msgstr "" + +msgid "owers list" +msgstr "" + +msgid "Who?" +msgstr "" + +msgid "Balance" +msgstr "" + +#, python-format +msgid "" +"\n" +" This project has history disabled. New actions won't " +"appear below. You can enable history on the\n" +" settings page\n" +" " +msgstr "" + +msgid "" +"\n" +" The table below reflects actions recorded prior to " +"disabling project history. You can\n" +" clear project history to remove " +"them.

\n" +" " +msgstr "" + +msgid "" +"Some entries below contain IP addresses, even though this project has IP " +"recording disabled. " +msgstr "" + +msgid "Delete stored IP addresses" +msgstr "" + +msgid "No history to erase" +msgstr "" + +msgid "Clear Project History" +msgstr "" + +msgid "No IP Addresses to erase" +msgstr "" + +msgid "Delete Stored IP Addresses" +msgstr "" + +msgid "Time" +msgstr "" + +msgid "Event" +msgstr "" + +msgid "IP address recording can be enabled on the settings page" +msgstr "" + +msgid "IP address recording can be disabled on the settings page" +msgstr "" + +msgid "From IP" +msgstr "" + +msgid "added" +msgstr "" + +msgid "Project private code changed" +msgstr "" + +msgid "Project renamed to" +msgstr "" + +msgid "Project contact email changed to" +msgstr "" + +msgid "Project settings modified" +msgstr "" + +msgid "deactivated" +msgstr "" + +msgid "reactivated" +msgstr "" + +msgid "renamed to" +msgstr "" + +msgid "External link changed to" +msgstr "" + +msgid "Amount" +msgstr "" + +msgid "modified" +msgstr "" + +msgid "removed" +msgstr "" + +msgid "changed in a unknown way" +msgstr "" + +msgid "Nothing to list" +msgstr "" + +msgid "Someone probably cleared the project history." +msgstr "" + msgid "Manage your shared
expenses, easily" msgstr "" @@ -351,6 +558,9 @@ msgstr "" msgid "Statistics" msgstr "" +msgid "History" +msgstr "" + msgid "Settings" msgstr "" @@ -408,6 +618,12 @@ msgstr "" msgid "Add a new bill" msgstr "" +msgid "Newer bills" +msgstr "" + +msgid "Older bills" +msgstr "" + msgid "When?" msgstr "" @@ -434,9 +650,6 @@ msgstr "" msgid "each" msgstr "" -msgid "see" -msgstr "" - msgid "No bills" msgstr "" @@ -505,97 +718,14 @@ msgstr "" msgid "To whom?" msgstr "" -msgid "Who?" -msgstr "" - msgid "Paid" msgstr "" msgid "Spent" msgstr "" -msgid "Balance" +msgid "Expenses by Month" msgstr "" -#~ msgid "" -#~ "Not a valid amount or expression.Only" -#~ " numbers and + - * / " -#~ "operatorsare accepted." -#~ msgstr "" - -#~ msgid "What do you want to download ?" -#~ msgstr "" - -#~ msgid "bills" -#~ msgstr "" - -#~ msgid "transactions" -#~ msgstr "" - -#~ msgid "Export file format" -#~ msgstr "" - -#~ msgid "Edit this project" -#~ msgstr "" - -#~ msgid "Download this project's data" -#~ msgstr "" - -#~ msgid "Type user name here" -#~ msgstr "" - -#~ msgid "No, thanks" -#~ msgstr "" - -#~ msgid "Manage your shared
expenses, easily" -#~ msgstr "" - -#~ msgid "Log to an existing project" -#~ msgstr "" - -#~ msgid "log in" -#~ msgstr "" - -#~ msgid "or create a new one" -#~ msgstr "" - -#~ msgid "let's get started" -#~ msgstr "" - -#~ msgid "options" -#~ msgstr "" - -#~ msgid "Project settings" -#~ msgstr "" - -#~ msgid "This is a free software" -#~ msgstr "" - -#~ msgid "Invite people to join this project!" -#~ msgstr "" - -#~ msgid "Added on" -#~ msgstr "" - -#~ msgid "Nothing to list yet. You probably want to" -#~ msgstr "" - -#~ msgid "" -#~ "Specify a (comma separated) list of " -#~ "email adresses you want to notify " -#~ "about the\n" -#~ "creation of this budget management " -#~ "project and we will send them an" -#~ " email for you." -#~ msgstr "" - -#~ msgid "" -#~ "If you prefer, you can share the project identifier and the shared\n" -#~ "password by other communication means. " -#~ "Or even directly share the following " -#~ "link:" -#~ msgstr "" - -#~ msgid "A link to reset your password has been sent to your email." -#~ msgstr "" - +msgid "Period" +msgstr "" diff --git a/ihatemoney/translations/es_419/LC_MESSAGES/messages.mo b/ihatemoney/translations/es_419/LC_MESSAGES/messages.mo index 7f1de2cc..97e55c96 100644 Binary files a/ihatemoney/translations/es_419/LC_MESSAGES/messages.mo and b/ihatemoney/translations/es_419/LC_MESSAGES/messages.mo differ diff --git a/ihatemoney/translations/es_419/LC_MESSAGES/messages.po b/ihatemoney/translations/es_419/LC_MESSAGES/messages.po index 1ce97596..216edcc6 100644 --- a/ihatemoney/translations/es_419/LC_MESSAGES/messages.po +++ b/ihatemoney/translations/es_419/LC_MESSAGES/messages.po @@ -1,19 +1,19 @@ - msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2019-10-20 11:52+0200\n" -"PO-Revision-Date: 2019-09-25 22:28+0000\n" -"Last-Translator: Diego Caraballo \n" +"POT-Creation-Date: 2020-04-25 13:02+0200\n" +"PO-Revision-Date: 2020-05-03 15:20+0000\n" +"Last-Translator: Fabian Rodriguez \n" +"Language-Team: Spanish (Latin America) \n" "Language: es_419\n" -"Language-Team: Spanish (Latin America) " -"\n" -"Plural-Forms: nplurals=2; plural=n != 1\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.7.0\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.1-dev\n" +"Generated-By: Babel 2.8.0\n" msgid "" "Not a valid amount or expression. Only numbers and + - * / operators are " @@ -31,6 +31,18 @@ msgstr "Código privado" msgid "Email" msgstr "Correo Electrónico" +msgid "Enable project history" +msgstr "Habilitar historial del proyecto" + +msgid "Use IP tracking for project history" +msgstr "Registrar la IPs para el historial del proyecto" + +msgid "Import previously exported JSON file" +msgstr "Importar archivo JSON previamente exportado" + +msgid "Import" +msgstr "Importar" + msgid "Project identifier" msgstr "Identificador de proyecto" @@ -127,6 +139,15 @@ msgstr "Enviar invitaciones" msgid "The email %(email)s is not valid" msgstr "El correo electrónico %(email)s no es válido" +msgid "Participant" +msgstr "Participante" + +msgid "Bill" +msgstr "Factura" + +msgid "Project" +msgstr "Proyecto" + msgid "Too many failed login attempts, please retry later." msgstr "" "Demasiados intentos fallidos de inicio de sesión, vuelva a intentarlo más" @@ -164,6 +185,12 @@ msgstr "Proyecto desconocido" msgid "Password successfully reset." msgstr "Contraseña restablecida con éxito." +msgid "Project successfully uploaded" +msgstr "El proyecto se subió exitosamente" + +msgid "Invalid JSON" +msgstr "JSON inválido" + msgid "Project successfully deleted" msgstr "Proyecto eliminado correctamente" @@ -175,8 +202,8 @@ msgid "Your invitations have been sent" msgstr "Sus invitaciones han sido enviadas" #, python-format -msgid "%(member)s had been added" -msgstr "se han añadido %(member)s" +msgid "%(member)s has been added" +msgstr "Se añadieron %(member)s" #, python-format msgid "%(name)s is part of this project again" @@ -231,9 +258,6 @@ msgstr "?" msgid "Create a new project" msgstr "Crear un nuevo proyecto" -msgid "Project" -msgstr "Proyecto" - msgid "Number of members" msgstr "Número de miembros" @@ -255,6 +279,9 @@ msgstr "Editar" msgid "delete" msgstr "Eliminar" +msgid "see" +msgstr "ver" + msgid "The Dashboard is currently deactivated." msgstr "El panel está desactivado actualmente." @@ -264,6 +291,12 @@ msgstr "¿Estás seguro?" msgid "Edit project" msgstr "Editar proyecto" +msgid "Import JSON" +msgstr "Importar JSON" + +msgid "Choose file" +msgstr "Escoger un archivo" + msgid "Download project's data" msgstr "Descargar datos del proyecto" @@ -287,6 +320,9 @@ msgstr "¿No recuerdas la contraseña?" msgid "Cancel" msgstr "Cancelar" +msgid "Privacy Settings" +msgstr "Ajustes de privacidad" + msgid "Edit the project" msgstr "Editar el proyecto" @@ -309,7 +345,7 @@ msgid "Edit this member" msgstr "Editar este miembro" msgid "john.doe@example.com, mary.moe@site.com" -msgstr "john.doe@example.com, mary.moe@site.com" +msgstr "juan.perez@example.com, ana.rodriguez@site.com" msgid "Send the invitations" msgstr "Enviar las invitaciones" @@ -317,6 +353,199 @@ msgstr "Enviar las invitaciones" msgid "Download" msgstr "Descargar" +msgid "Disabled Project History" +msgstr "Historial de proyecto activo" + +msgid "Disabled Project History & IP Address Recording" +msgstr "Historial de proyecto y registros de dirección IP inactivos" + +msgid "Enabled Project History" +msgstr "Historial de proyecto activo" + +msgid "Disabled IP Address Recording" +msgstr "Registro de direcciones IP activo" + +msgid "Enabled Project History & IP Address Recording" +msgstr "Historial de proyecto y registros de dirección IP activos" + +msgid "Enabled IP Address Recording" +msgstr "Se activó el registros de dirección IP" + +msgid "History Settings Changed" +msgstr "Se cambiaron los ajustes del historial" + +msgid "changed" +msgstr "cambió" + +msgid "from" +msgstr "de" + +msgid "to" +msgstr "a" + +msgid "Confirm Remove IP Adresses" +msgstr "Confirmar eliminación de direcciones IP" + +msgid "" +"Are you sure you want to delete all recorded IP addresses from this " +"project?\n" +" The rest of the project history will be unaffected. This " +"action cannot be undone." +msgstr "" +"Por favor confirme la eliminación completa del registro de direcciones IP " +"del proyecto.\n" +" El resto de historial del proyecto no será afectado. Este " +"cambio es irreversible." + +msgid "Close" +msgstr "Cerrar" + +msgid "Confirm Delete" +msgstr "Confirmar eliminación" + +msgid "Delete Confirmation" +msgstr "Confirmación de eliminación" + +msgid "" +"Are you sure you want to erase all history for this project? This action " +"cannot be undone." +msgstr "" +"Por favor confirme la eliminación completa del historial del proyecto. Esta " +"acción es irreversible." + +msgid "Added" +msgstr "Agregado" + +msgid "Removed" +msgstr "Eliminado" + +msgid "and" +msgstr "y" + +msgid "owers list" +msgstr "lista de deudores" + +msgid "Who?" +msgstr "¿Quién?" + +msgid "Balance" +msgstr "Balance" + +#, python-format +msgid "" +"\n" +" This project has history disabled. New actions won't " +"appear below. You can enable history on the\n" +" settings page\n" +" " +msgstr "" +"\n" +" El historial de este proyecto ha sido desactivado. Nuevas " +"operaciones no apareceran a continuacion. El historial se puede agregar " +"\n" +" en la página de ajustes\n" +" " + +msgid "" +"\n" +" The table below reflects actions recorded prior to " +"disabling project history. You can\n" +" clear project history to remove " +"them.

\n" +" " +msgstr "" +"\n" +" Este registro muestra la actividad previa a la desactivación " +"del historial del proyecto. Use la opción \n" +" Eliminar historial del proyecto para " +"borrarlo.

\n" +" " + +msgid "" +"Some entries below contain IP addresses, even though this project has IP " +"recording disabled. " +msgstr "" +"Algunos registros contienen direcciones IP, a pesar de que el registro de " +"direcciones IP del proyecto no está activo. " + +msgid "Delete stored IP addresses" +msgstr "Borrar las direcciones IP registradas" + +msgid "No history to erase" +msgstr "No hay historial para borrar" + +msgid "Clear Project History" +msgstr "Borrar el historial del proyecto" + +msgid "No IP Addresses to erase" +msgstr "No hay direcciones IP para borrar" + +msgid "Delete Stored IP Addresses" +msgstr "Borrar direcciones IP registradas" + +msgid "Time" +msgstr "Hora" + +msgid "Event" +msgstr "Evento" + +msgid "IP address recording can be enabled on the settings page" +msgstr "El registro de direcciones IP se puede activar en la página de ajustes" + +msgid "IP address recording can be disabled on the settings page" +msgstr "" +"El registro de direcciones IP se puede desactivar en la página de ajustes" + +msgid "From IP" +msgstr "IP de origen" + +msgid "added" +msgstr "agregado" + +msgid "Project private code changed" +msgstr "Se cambió el código privado del proyecto" + +msgid "Project renamed to" +msgstr "Se cambió el nombre del proyecto a" + +msgid "Project contact email changed to" +msgstr "Se cambió el correo electrónico de contacto a" + +msgid "Project settings modified" +msgstr "Ajustes del proyecto modificados" + +msgid "deactivated" +msgstr "desactivado" + +msgid "reactivated" +msgstr "reactivado" + +msgid "renamed to" +msgstr "se cambió de nombre a" + +msgid "External link changed to" +msgstr "Se cambió el enlace externo por" + +msgid "Amount" +msgstr "Monto" + +msgid "modified" +msgstr "modificado" + +msgid "removed" +msgstr "removido" + +msgid "changed in a unknown way" +msgstr "se cambió de manera desconocida" + +msgid "Nothing to list" +msgstr "Nada por listar" + +msgid "Someone probably cleared the project history." +msgstr "Es probable que alguien borrara el historial del proyecto." + msgid "Manage your shared
expenses, easily" msgstr "Gestione sus gastos compartidos
fácilmente" @@ -366,6 +595,9 @@ msgstr "Resolver" msgid "Statistics" msgstr "Estadísticas" +msgid "History" +msgstr "Historial" + msgid "Settings" msgstr "Configuración" @@ -423,6 +655,12 @@ msgstr "Deberías comenzar agregando participantes" msgid "Add a new bill" msgstr "Añadir una nueva factura" +msgid "Newer bills" +msgstr "Nuevas facturas" + +msgid "Older bills" +msgstr "Facturas anteriores" + msgid "When?" msgstr "¿Cuando?" @@ -449,9 +687,6 @@ msgstr "Todo el mundo menos %(excluded)s" msgid "each" msgstr "Cada" -msgid "see" -msgstr "ver" - msgid "No bills" msgstr "Sin facturas" @@ -478,7 +713,7 @@ msgstr "" "correos electrónicos." msgid "Return to home page" -msgstr "Regresar a la página principal" +msgstr "Regresar al inicio" msgid "Your projects" msgstr "Sus proyectos" @@ -530,15 +765,17 @@ msgstr "¿Quién paga?" msgid "To whom?" msgstr "¿A quién?" -msgid "Who?" -msgstr "¿Quién?" - msgid "Paid" msgstr "Pagado" msgid "Spent" msgstr "Gastado" -msgid "Balance" -msgstr "Balance" +msgid "Expenses by Month" +msgstr "Gastos por mes" +msgid "Period" +msgstr "Período" + +#~ msgid "%(member)s had been added" +#~ msgstr "se han añadido %(member)s" diff --git a/ihatemoney/translations/fr/LC_MESSAGES/messages.mo b/ihatemoney/translations/fr/LC_MESSAGES/messages.mo index 1b05ab4f..21076f19 100644 Binary files a/ihatemoney/translations/fr/LC_MESSAGES/messages.mo and b/ihatemoney/translations/fr/LC_MESSAGES/messages.mo differ diff --git a/ihatemoney/translations/fr/LC_MESSAGES/messages.po b/ihatemoney/translations/fr/LC_MESSAGES/messages.po index f4c1f36c..56f19c9b 100644 --- a/ihatemoney/translations/fr/LC_MESSAGES/messages.po +++ b/ihatemoney/translations/fr/LC_MESSAGES/messages.po @@ -7,9 +7,9 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2019-10-01 21:48+0200\n" -"PO-Revision-Date: 2019-10-07 22:56+0000\n" -"Last-Translator: Alexis Metaireau \n" +"POT-Creation-Date: 2020-04-25 13:02+0200\n" +"PO-Revision-Date: 2020-04-25 11:14+0000\n" +"Last-Translator: Rémy Hubscher \n" "Language-Team: French \n" "Language: fr\n" @@ -17,15 +17,15 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n > 1;\n" -"X-Generator: Weblate 3.9-dev\n" -"Generated-By: Babel 2.7.0\n" +"X-Generator: Weblate 4.0.2-dev\n" +"Generated-By: Babel 2.8.0\n" msgid "" "Not a valid amount or expression. Only numbers and + - * / operators are " "accepted." msgstr "" -"Ceci n'est pas pas un montant ou une expression valide. Seuls les nombres" -" et les opérateurs + - * / sont acceptés." +"Ceci n'est pas un montant ou une expression valide. Seuls les nombres et " +"les opérateurs + - * / sont acceptés." msgid "Project name" msgstr "Nom de projet" @@ -36,6 +36,18 @@ msgstr "Code d’accès" msgid "Email" msgstr "Email" +msgid "Enable project history" +msgstr "Activer l'historique de projet" + +msgid "Use IP tracking for project history" +msgstr "Collecter les adresses IP dans l'historique de projet" + +msgid "Import previously exported JSON file" +msgstr "Importer un fichier JSON précédemment exporté" + +msgid "Import" +msgstr "Importer" + msgid "Project identifier" msgstr "Identifiant du projet" @@ -132,6 +144,15 @@ msgstr "Envoyer les invitations" msgid "The email %(email)s is not valid" msgstr "L’email %(email)s est invalide" +msgid "Participant" +msgstr "Participant" + +msgid "Bill" +msgstr "Facture" + +msgid "Project" +msgstr "Projet" + msgid "Too many failed login attempts, please retry later." msgstr "Trop d'échecs d’authentification successifs, veuillez réessayer plus tard." @@ -167,6 +188,12 @@ msgstr "Project inconnu" msgid "Password successfully reset." msgstr "Le mot de passe a été changé avec succès." +msgid "Project successfully uploaded" +msgstr "Le projet a été correctement importé" + +msgid "Invalid JSON" +msgstr "Le fichier JSON est invalide" + msgid "Project successfully deleted" msgstr "Projet supprimé" @@ -178,8 +205,8 @@ msgid "Your invitations have been sent" msgstr "Vos invitations ont bien été envoyées" #, python-format -msgid "%(member)s had been added" -msgstr "%(member)s a bien été ajouté" +msgid "%(member)s has been added" +msgstr "%(member)s a été ajouté" #, python-format msgid "%(name)s is part of this project again" @@ -234,9 +261,6 @@ msgstr " ?" msgid "Create a new project" msgstr "Créer un nouveau projet" -msgid "Project" -msgstr "Projets" - msgid "Number of members" msgstr "Nombre de membres" @@ -258,6 +282,9 @@ msgstr "éditer" msgid "delete" msgstr "supprimer" +msgid "see" +msgstr "voir" + msgid "The Dashboard is currently deactivated." msgstr "Le tableau de bord est actuellement désactivée." @@ -267,6 +294,12 @@ msgstr "c’est sûr ?" msgid "Edit project" msgstr "Éditer le projet" +msgid "Import JSON" +msgstr "Import JSON" + +msgid "Choose file" +msgstr "Choisir un fichier" + msgid "Download project's data" msgstr "Télécharger les données du projet" @@ -288,6 +321,9 @@ msgstr "Vous ne vous souvenez plus du code d’accès ?" msgid "Cancel" msgstr "Annuler" +msgid "Privacy Settings" +msgstr "Vie privée" + msgid "Edit the project" msgstr "Éditer le projet" @@ -318,6 +354,202 @@ msgstr "Envoyer les invitations" msgid "Download" msgstr "Télécharger" +msgid "Disabled Project History" +msgstr "Désactiver l'historique du projet" + +msgid "Disabled Project History & IP Address Recording" +msgstr "Désactiver l'historique du projet et l'enregistrement des adresses IP" + +msgid "Enabled Project History" +msgstr "Activer l'historique du projet" + +msgid "Disabled IP Address Recording" +msgstr "Désactiver l'enregistrement des adresses IP" + +msgid "Enabled Project History & IP Address Recording" +msgstr "Activer l'historique du projet et l’enregistrement des adresses IP" + +msgid "Enabled IP Address Recording" +msgstr "Activer l'enregistrement des adresses IP" + +msgid "History Settings Changed" +msgstr "Paramètres d'historique modifiés" + +msgid "changed" +msgstr "modifié" + +msgid "from" +msgstr "du" + +msgid "to" +msgstr "au" + +msgid "Confirm Remove IP Adresses" +msgstr "Confirmer la suppression des adresses IP" + +msgid "" +"Are you sure you want to delete all recorded IP addresses from this " +"project?\n" +" The rest of the project history will be unaffected. This " +"action cannot be undone." +msgstr "" +"Êtes-vous sur de vouloir supprimer toutes les adresses IP enregistrées pour " +"ce projet ?\n" +"Le reste de l'historique ne sera pas affecté. Cette action n'est pas " +"réversible." + +msgid "Close" +msgstr "Fermer" + +msgid "Confirm Delete" +msgstr "Confirmer la suppression" + +msgid "Delete Confirmation" +msgstr "Confirmation de suppression" + +msgid "" +"Are you sure you want to erase all history for this project? This action " +"cannot be undone." +msgstr "" +"Êtes-vous sur de vouloir supprimer tout l'historique du projet ? Cette " +"action n'est pas réversible." + +msgid "Added" +msgstr "Ajouté" + +msgid "Removed" +msgstr "Supprimé" + +msgid "and" +msgstr "et" + +msgid "owers list" +msgstr "Liste des débiteurs" + +msgid "Who?" +msgstr "Qui ?" + +msgid "Balance" +msgstr "Solde" + +#, python-format +msgid "" +"\n" +" This project has history disabled. New actions won't " +"appear below. You can enable history on the\n" +" settings page\n" +" " +msgstr "" +"\n" +" L'historique de ce projet a été désactivé. Les nouvelles " +"actions n'apparaîtront pas ci-dessous. Vous pouvez réactiver l'historique" +" du projet dans les \n" +" paramètres du projet\n" +" " + +msgid "" +"\n" +" The table below reflects actions recorded prior to " +"disabling project history. You can\n" +" clear project history to remove " +"them.

\n" +" " +msgstr "" +"\n" +" Le tableau ci-dessous liste les actions enregistrées avant" +" la désactivation de l'historique du projet. Vous pouvez\n" +" clear project history cliquer ici pour" +" les supprimer.

\n" +" " + +msgid "" +"Some entries below contain IP addresses, even though this project has IP " +"recording disabled. " +msgstr "" +"Certaines entrées de l'historique contiennent une adresse IP, bien que ce" +" projet ait désactivé l'enregistrement des adresses IP. " + +msgid "Delete stored IP addresses" +msgstr "Supprimer toutes les adresses IP enregistrées" + +msgid "No history to erase" +msgstr "Aucun historique à supprimer" + +msgid "Clear Project History" +msgstr "Supprimer les entrées de l'historique du projet" + +msgid "No IP Addresses to erase" +msgstr "Aucune adresse IP à supprimer" + +msgid "Delete Stored IP Addresses" +msgstr "Supprimer les adresses IP enregistrées" + +msgid "Time" +msgstr "Heure" + +msgid "Event" +msgstr "Évènement" + +msgid "IP address recording can be enabled on the settings page" +msgstr "" +"L'enregistrement des adresses IP peut-être activé dans les paramètres de " +"la page" + +msgid "IP address recording can be disabled on the settings page" +msgstr "" +"L'enregistrement des adresses IP peut-être désactivé dans les paramètres " +"de la page" + +msgid "From IP" +msgstr "Depuis l'IP" + +msgid "added" +msgstr "ajouté" + +msgid "Project private code changed" +msgstr "Le mot de passe du projet a été modifié" + +msgid "Project renamed to" +msgstr "Le projet a été renommé" + +msgid "Project contact email changed to" +msgstr "L'adresse email de contact du projet a été modifié en" + +msgid "Project settings modified" +msgstr "Les paramètres du projet ont été modifiés" + +msgid "deactivated" +msgstr "désactivé" + +msgid "reactivated" +msgstr "réactivé" + +msgid "renamed to" +msgstr "renommé en" + +msgid "External link changed to" +msgstr "Le lien d'accès a été modifié en" + +msgid "Amount" +msgstr "Montant" + +msgid "modified" +msgstr "modifié" + +msgid "removed" +msgstr "supprimé" + +msgid "changed in a unknown way" +msgstr "modifié d'une manière inconnue" + +msgid "Nothing to list" +msgstr "Rien à afficher" + +msgid "Someone probably cleared the project history." +msgstr "Quelqu'un a probablement vidé l'historique du projet." + msgid "Manage your shared
expenses, easily" msgstr "Gérez vos dépenses
partagées, facilement" @@ -367,6 +599,9 @@ msgstr "Remboursements" msgid "Statistics" msgstr "Statistiques" +msgid "History" +msgstr "Historique" + msgid "Settings" msgstr "Options" @@ -424,6 +659,12 @@ msgstr "Vous devriez commencer par ajouter des participants" msgid "Add a new bill" msgstr "Nouvelle facture" +msgid "Newer bills" +msgstr "Nouvelles factures" + +msgid "Older bills" +msgstr "Ancienne factures" + msgid "When?" msgstr "Quand ?" @@ -450,9 +691,6 @@ msgstr "Tout le monde sauf %(excluded)s" msgid "each" msgstr "chacun" -msgid "see" -msgstr "voir" - msgid "No bills" msgstr "Pas encore de factures" @@ -527,27 +765,17 @@ msgstr "Qui doit payer ?" msgid "To whom?" msgstr "Pour qui ?" -msgid "Who?" -msgstr "Qui ?" - msgid "Paid" msgstr "A payé" msgid "Spent" msgstr "A dépensé" -msgid "Balance" -msgstr "Solde" - -msgid "Import" -msgstr "Importer" - -msgid "Project successfully uploaded" -msgstr "Le projet a été correctement importé" - -msgid "Invalid JSON" -msgstr "Le fichier JSON est invalide" +msgid "Expenses by Month" +msgstr "Dépenses par mois" +msgid "Period" +msgstr "Période" #~ msgid "" #~ "The project identifier is used to " @@ -695,3 +923,72 @@ msgstr "Le fichier JSON est invalide" #~ msgid "A link to reset your password has been sent to your email." #~ msgstr "Un lien pour changer votre mot de passe vous a été envoyé par mail." + +#~ msgid "%(member)s had been added" +#~ msgstr "%(member)s a bien été ajouté" + +#~ msgid "Disabled Project History" +#~ msgstr "Historisation du projet désactivée" + +#~ msgid "Disabled Project History & IP Address Recording" +#~ msgstr "Historisation du projet et enregistrement des adresses IP désactivés" + +#~ msgid "Enabled Project History" +#~ msgstr "Historisation du projet activée" + +#~ msgid "Disabled IP Address Recording" +#~ msgstr "Enregistrement des adresses IP désactivé" + +#~ msgid "Enabled Project History & IP Address Recording" +#~ msgstr "Historisation du projet et enregistrement des adresses IP activés" + +#~ msgid "Enabled IP Address Recording" +#~ msgstr "Enregistrement des adresses IP activé" + +#~ msgid "History Settings Changed" +#~ msgstr "Changement des paramètres d’historisation" + +#~ msgid "changed" +#~ msgstr "modifié" + +#~ msgid "from" +#~ msgstr "depuis" + +#~ msgid "to" +#~ msgstr "vers" + +#~ msgid "Confirm Remove IP Adresses" +#~ msgstr "Confirmer la suppression des adresses IP" + +#~ msgid "" +#~ "Are you sure you want to delete" +#~ " all recorded IP addresses from this" +#~ " project?\n" +#~ " The rest of the project" +#~ " history will be unaffected. This " +#~ "action cannot be undone." +#~ msgstr "" +#~ "Êtes vous sûr de supprimer toutes " +#~ "les adresses IP enregistrées dans ce " +#~ "projet ?\n" +#~ "Le reste de l’historique du projet " +#~ "restera inchangé. Cette action est " +#~ "irréversible." + +#~ msgid "Close" +#~ msgstr "Fermer" + +#~ msgid "Confirm Delete" +#~ msgstr "Confirmer la suppression" + +#~ msgid "Delete Confirmation" +#~ msgstr "Confirmation de suppression" + +#~ msgid "" +#~ "Are you sure you want to erase " +#~ "all history for this project? This " +#~ "action cannot be undone." +#~ msgstr "" +#~ "Êtes vous sûr de supprimer la " +#~ "totalité de l’historique de ce projet" +#~ " ? Cette action est irréversible." diff --git a/ihatemoney/translations/id/LC_MESSAGES/messages.mo b/ihatemoney/translations/id/LC_MESSAGES/messages.mo new file mode 100644 index 00000000..ad2962c1 Binary files /dev/null and b/ihatemoney/translations/id/LC_MESSAGES/messages.mo differ diff --git a/ihatemoney/translations/id/LC_MESSAGES/messages.po b/ihatemoney/translations/id/LC_MESSAGES/messages.po index 1a7f7edf..079048c7 100644 --- a/ihatemoney/translations/id/LC_MESSAGES/messages.po +++ b/ihatemoney/translations/id/LC_MESSAGES/messages.po @@ -1,24 +1,26 @@ + msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2019-11-15 09:12+0200\n" +"POT-Creation-Date: 2020-04-25 13:02+0200\n" "PO-Revision-Date: 2019-11-16 10:04+0000\n" "Last-Translator: Muhammad Fauzi \n" -"Language-Team: Indonesian \n" "Language: id\n" +"Language-Team: Indonesian \n" +"Plural-Forms: nplurals=1; plural=0\n" "MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" +"Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=1; plural=0;\n" -"X-Generator: Weblate 3.10-dev\n" +"Generated-By: Babel 2.8.0\n" msgid "" "Not a valid amount or expression. Only numbers and + - * / operators are " "accepted." msgstr "" -"Jumlah atau operator tidak valid. Hanya angka dan opertaor +-* yang diterima." +"Jumlah atau operator tidak valid. Hanya angka dan opertaor +-* yang " +"diterima." msgid "Project name" msgstr "Nama proyek" @@ -29,6 +31,18 @@ msgstr "Kode pribadi" msgid "Email" msgstr "Surel" +msgid "Enable project history" +msgstr "" + +msgid "Use IP tracking for project history" +msgstr "" + +msgid "Import previously exported JSON file" +msgstr "" + +msgid "Import" +msgstr "" + msgid "Project identifier" msgstr "Pengidentifikasi proyek" @@ -40,8 +54,8 @@ msgid "" "A project with this identifier (\"%(project)s\") already exists. Please " "choose a new identifier" msgstr "" -"Sebuah proyek dengan ID ini (\"%(project)s\") sudah ada. Silakan pilih ID " -"baru" +"Sebuah proyek dengan ID ini (\"%(project)s\") sudah ada. Silakan pilih ID" +" baru" msgid "Get in" msgstr "Masuk" @@ -125,6 +139,15 @@ msgstr "Kirim undangan" msgid "The email %(email)s is not valid" msgstr "Surel %(email)s tidak valid" +msgid "Participant" +msgstr "" + +msgid "Bill" +msgstr "" + +msgid "Project" +msgstr "Proyek" + msgid "Too many failed login attempts, please retry later." msgstr "Terlalu banyak percobaan masuk, silakan coba lagi nanti." @@ -158,6 +181,12 @@ msgstr "Proyek tidak diketahui" msgid "Password successfully reset." msgstr "Kata sandi berhasil diatur ulang." +msgid "Project successfully uploaded" +msgstr "" + +msgid "Invalid JSON" +msgstr "" + msgid "Project successfully deleted" msgstr "Proyek berhasil dihapus" @@ -169,8 +198,8 @@ msgid "Your invitations have been sent" msgstr "Undangan Anda telah dikirim" #, python-format -msgid "%(member)s had been added" -msgstr "%(member)s telah ditambahkan" +msgid "%(member)s has been added" +msgstr "" #, python-format msgid "%(name)s is part of this project again" @@ -225,9 +254,6 @@ msgstr "?" msgid "Create a new project" msgstr "Buat proyek baru" -msgid "Project" -msgstr "Proyek" - msgid "Number of members" msgstr "Jumlah anggota" @@ -249,6 +275,9 @@ msgstr "ubah" msgid "delete" msgstr "hapus" +msgid "see" +msgstr "lihat" + msgid "The Dashboard is currently deactivated." msgstr "Dasbor sekarang ini sedang dinonaktifkan." @@ -258,6 +287,12 @@ msgstr "Anda yakin?" msgid "Edit project" msgstr "Ubah proyek" +msgid "Import JSON" +msgstr "" + +msgid "Choose file" +msgstr "" + msgid "Download project's data" msgstr "Unduh data proyek" @@ -279,6 +314,9 @@ msgstr "Tidak bisa mengingat kata sandi?" msgid "Cancel" msgstr "Batalkan" +msgid "Privacy Settings" +msgstr "" + msgid "Edit the project" msgstr "Ubah proyek" @@ -309,6 +347,177 @@ msgstr "Kirim undangan" msgid "Download" msgstr "Unduh" +msgid "Disabled Project History" +msgstr "" + +msgid "Disabled Project History & IP Address Recording" +msgstr "" + +msgid "Enabled Project History" +msgstr "" + +msgid "Disabled IP Address Recording" +msgstr "" + +msgid "Enabled Project History & IP Address Recording" +msgstr "" + +msgid "Enabled IP Address Recording" +msgstr "" + +msgid "History Settings Changed" +msgstr "" + +msgid "changed" +msgstr "" + +msgid "from" +msgstr "" + +msgid "to" +msgstr "" + +msgid "Confirm Remove IP Adresses" +msgstr "" + +msgid "" +"Are you sure you want to delete all recorded IP addresses from this " +"project?\n" +" The rest of the project history will be unaffected. This " +"action cannot be undone." +msgstr "" + +msgid "Close" +msgstr "" + +msgid "Confirm Delete" +msgstr "" + +msgid "Delete Confirmation" +msgstr "" + +msgid "" +"Are you sure you want to erase all history for this project? This action " +"cannot be undone." +msgstr "" + +msgid "Added" +msgstr "" + +msgid "Removed" +msgstr "" + +msgid "and" +msgstr "" + +msgid "owers list" +msgstr "" + +msgid "Who?" +msgstr "Siapa?" + +msgid "Balance" +msgstr "Saldo" + +#, python-format +msgid "" +"\n" +" This project has history disabled. New actions won't " +"appear below. You can enable history on the\n" +" settings page\n" +" " +msgstr "" + +msgid "" +"\n" +" The table below reflects actions recorded prior to " +"disabling project history. You can\n" +" clear project history to remove " +"them.

\n" +" " +msgstr "" + +msgid "" +"Some entries below contain IP addresses, even though this project has IP " +"recording disabled. " +msgstr "" + +msgid "Delete stored IP addresses" +msgstr "" + +msgid "No history to erase" +msgstr "" + +msgid "Clear Project History" +msgstr "" + +msgid "No IP Addresses to erase" +msgstr "" + +msgid "Delete Stored IP Addresses" +msgstr "" + +msgid "Time" +msgstr "" + +msgid "Event" +msgstr "" + +msgid "IP address recording can be enabled on the settings page" +msgstr "" + +msgid "IP address recording can be disabled on the settings page" +msgstr "" + +msgid "From IP" +msgstr "" + +msgid "added" +msgstr "" + +msgid "Project private code changed" +msgstr "" + +msgid "Project renamed to" +msgstr "" + +msgid "Project contact email changed to" +msgstr "" + +msgid "Project settings modified" +msgstr "" + +msgid "deactivated" +msgstr "" + +msgid "reactivated" +msgstr "" + +msgid "renamed to" +msgstr "" + +msgid "External link changed to" +msgstr "" + +msgid "Amount" +msgstr "" + +msgid "modified" +msgstr "" + +msgid "removed" +msgstr "" + +msgid "changed in a unknown way" +msgstr "" + +msgid "Nothing to list" +msgstr "" + +msgid "Someone probably cleared the project history." +msgstr "" + msgid "Manage your shared
expenses, easily" msgstr "Atur pembagian harga
Anda, dengan mudah" @@ -343,8 +552,8 @@ 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!" msgstr "" -"Kode akses ini akan dikirimkan ke teman Anda. Kode ini disimpan dalam bentuk " -"teks biasa dalam server, jadi jangan gunakan password Anda!" +"Kode akses ini akan dikirimkan ke teman Anda. Kode ini disimpan dalam " +"bentuk teks biasa dalam server, jadi jangan gunakan password Anda!" msgid "Account manager" msgstr "Pengatur akun" @@ -358,6 +567,9 @@ msgstr "Atur" msgid "Statistics" msgstr "Statistik" +msgid "History" +msgstr "" + msgid "Settings" msgstr "Pengaturan" @@ -415,6 +627,12 @@ msgstr "Anda harus mulai dengan menambahkan partisipan" msgid "Add a new bill" msgstr "Tambah tagihan baru" +msgid "Newer bills" +msgstr "" + +msgid "Older bills" +msgstr "" + msgid "When?" msgstr "Kapan?" @@ -441,9 +659,6 @@ msgstr "Semua orang kecuali %(excluded)s" msgid "each" msgstr "setiap" -msgid "see" -msgstr "lihat" - msgid "No bills" msgstr "Tidak ada tagihan" @@ -466,8 +681,8 @@ msgid "" "A link to reset your password has been sent to you, please check your " "emails." msgstr "" -"Tautan atur ulang kata sandi telah dikirim kepada Anda, silakan cek email " -"Anda." +"Tautan atur ulang kata sandi telah dikirim kepada Anda, silakan cek email" +" Anda." msgid "Return to home page" msgstr "Kembali ke halaman depan" @@ -499,7 +714,8 @@ msgstr "Bagikan tautan" msgid "You can directly share the following link via your prefered medium" msgstr "" -"Anda bisa membagikan tautan secara langsung melalui media yang Anda inginkan" +"Anda bisa membagikan tautan secara langsung melalui media yang Anda " +"inginkan" msgid "Send via Emails" msgstr "Kirim melalui surel" @@ -510,10 +726,10 @@ msgid "" " creation of this budget management project and we will " "send them an email for you." msgstr "" -"Spesifikkan daftar alamat surel (dipisah dengan koma) yang akan Anda kirim " -"pemberitahuan tentang\n" -" pembuatan dari manajemen anggaran proyek ini dan kami akan " -"memngirim mereka sebuah surel." +"Spesifikkan daftar alamat surel (dipisah dengan koma) yang akan Anda " +"kirim pemberitahuan tentang\n" +" pembuatan dari manajemen anggaran proyek ini dan kami " +"akan memngirim mereka sebuah surel." msgid "Who pays?" msgstr "Siapa membayar?" @@ -521,14 +737,24 @@ msgstr "Siapa membayar?" msgid "To whom?" msgstr "Kepada siapa?" -msgid "Who?" -msgstr "Siapa?" - msgid "Paid" msgstr "Dibayar" msgid "Spent" msgstr "Dihabiskan" -msgid "Balance" -msgstr "Saldo" +msgid "Expenses by Month" +msgstr "" + +msgid "Period" +msgstr "" + +#~ msgid "%(member)s had been added" +#~ msgstr "%(member)s telah ditambahkan" + +#~ msgid "Someone probably" +#~ msgstr "" + +#~ msgid "cleared the project history." +#~ msgstr "" + diff --git a/ihatemoney/translations/it/LC_MESSAGES/messages.po b/ihatemoney/translations/it/LC_MESSAGES/messages.po new file mode 100644 index 00000000..f9c05c9e --- /dev/null +++ b/ihatemoney/translations/it/LC_MESSAGES/messages.po @@ -0,0 +1,729 @@ +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-04-28 10:30+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Translate Toolkit 2.5.1\n" + +msgid "" +"Not a valid amount or expression. Only numbers and + - * / operators are " +"accepted." +msgstr "" + +msgid "Project name" +msgstr "" + +msgid "Private code" +msgstr "" + +msgid "Email" +msgstr "" + +msgid "Enable project history" +msgstr "" + +msgid "Use IP tracking for project history" +msgstr "" + +msgid "Import previously exported JSON file" +msgstr "" + +msgid "Import" +msgstr "" + +msgid "Project identifier" +msgstr "" + +msgid "Create the project" +msgstr "" + +#, python-format +msgid "" +"A project with this identifier (\"%(project)s\") already exists. Please " +"choose a new identifier" +msgstr "" + +msgid "Get in" +msgstr "" + +msgid "Admin password" +msgstr "" + +msgid "Send me the code by email" +msgstr "" + +msgid "This project does not exists" +msgstr "" + +msgid "Password mismatch" +msgstr "" + +msgid "Password" +msgstr "" + +msgid "Password confirmation" +msgstr "" + +msgid "Reset password" +msgstr "" + +msgid "Date" +msgstr "" + +msgid "What?" +msgstr "" + +msgid "Payer" +msgstr "" + +msgid "Amount paid" +msgstr "" + +msgid "External link" +msgstr "" + +msgid "A link to an external document, related to this bill" +msgstr "" + +msgid "For whom?" +msgstr "" + +msgid "Submit" +msgstr "" + +msgid "Submit and add a new one" +msgstr "" + +msgid "Bills can't be null" +msgstr "" + +msgid "Name" +msgstr "" + +msgid "Weights should be positive" +msgstr "" + +msgid "Weight" +msgstr "" + +msgid "Add" +msgstr "" + +msgid "User name incorrect" +msgstr "" + +msgid "This project already have this member" +msgstr "" + +msgid "People to notify" +msgstr "" + +msgid "Send invites" +msgstr "" + +#, python-format +msgid "The email %(email)s is not valid" +msgstr "" + +msgid "Participant" +msgstr "" + +msgid "Bill" +msgstr "" + +msgid "Project" +msgstr "" + +msgid "Too many failed login attempts, please retry later." +msgstr "" + +#, python-format +msgid "This admin password is not the right one. Only %(num)d attempts left." +msgstr "" + +msgid "You either provided a bad token or no project identifier." +msgstr "" + +msgid "This private code is not the right one" +msgstr "" + +#, python-format +msgid "You have just created '%(project)s' to share your expenses" +msgstr "" + +#, python-format +msgid "%(msg_compl)sThe project identifier is %(project)s" +msgstr "" + +msgid "No token provided" +msgstr "" + +msgid "Invalid token" +msgstr "" + +msgid "Unknown project" +msgstr "" + +msgid "Password successfully reset." +msgstr "" + +msgid "Project successfully uploaded" +msgstr "" + +msgid "Invalid JSON" +msgstr "" + +msgid "Project successfully deleted" +msgstr "" + +#, python-format +msgid "You have been invited to share your expenses for %(project)s" +msgstr "" + +msgid "Your invitations have been sent" +msgstr "" + +#, python-format +msgid "%(member)s has been added" +msgstr "" + +#, python-format +msgid "%(name)s is part of this project again" +msgstr "" + +#, python-format +msgid "" +"User '%(name)s' has been deactivated. It will still appear in the users " +"list until its balance becomes zero." +msgstr "" + +#, python-format +msgid "User '%(name)s' has been removed" +msgstr "" + +#, python-format +msgid "User '%(name)s' has been edited" +msgstr "" + +msgid "The bill has been added" +msgstr "" + +msgid "The bill has been deleted" +msgstr "" + +msgid "The bill has been modified" +msgstr "" + +msgid "Sorry, we were unable to find the page you've asked for." +msgstr "" + +msgid "The best thing to do is probably to get back to the main page." +msgstr "" + +msgid "Back to the list" +msgstr "" + +msgid "Administration tasks are currently disabled." +msgstr "" + +msgid "The project you are trying to access do not exist, do you want to" +msgstr "" + +msgid "create it" +msgstr "" + +msgid "?" +msgstr "" + +msgid "Create a new project" +msgstr "" + +msgid "Number of members" +msgstr "" + +msgid "Number of bills" +msgstr "" + +msgid "Newest bill" +msgstr "" + +msgid "Oldest bill" +msgstr "" + +msgid "Actions" +msgstr "" + +msgid "edit" +msgstr "" + +msgid "delete" +msgstr "" + +msgid "see" +msgstr "" + +msgid "The Dashboard is currently deactivated." +msgstr "" + +msgid "you sure?" +msgstr "" + +msgid "Edit project" +msgstr "" + +msgid "Import JSON" +msgstr "" + +msgid "Choose file" +msgstr "" + +msgid "Download project's data" +msgstr "" + +msgid "Bill items" +msgstr "" + +msgid "Download the list of bills with owner, amount, reason,... " +msgstr "" + +msgid "Settle plans" +msgstr "" + +msgid "Download the list of transactions needed to settle the current bills." +msgstr "" + +msgid "Can't remember the password?" +msgstr "" + +msgid "Cancel" +msgstr "" + +msgid "Privacy Settings" +msgstr "" + +msgid "Edit the project" +msgstr "" + +msgid "Edit this bill" +msgstr "" + +msgid "Add a bill" +msgstr "" + +msgid "Select all" +msgstr "" + +msgid "Select none" +msgstr "" + +msgid "Add participant" +msgstr "" + +msgid "Edit this member" +msgstr "" + +msgid "john.doe@example.com, mary.moe@site.com" +msgstr "" + +msgid "Send the invitations" +msgstr "" + +msgid "Download" +msgstr "" + +msgid "Disabled Project History" +msgstr "" + +msgid "Disabled Project History & IP Address Recording" +msgstr "" + +msgid "Enabled Project History" +msgstr "" + +msgid "Disabled IP Address Recording" +msgstr "" + +msgid "Enabled Project History & IP Address Recording" +msgstr "" + +msgid "Enabled IP Address Recording" +msgstr "" + +msgid "History Settings Changed" +msgstr "" + +msgid "changed" +msgstr "" + +msgid "from" +msgstr "" + +msgid "to" +msgstr "" + +msgid "Confirm Remove IP Adresses" +msgstr "" + +msgid "" +"Are you sure you want to delete all recorded IP addresses from this " +"project?\n" +" The rest of the project history will be unaffected. This " +"action cannot be undone." +msgstr "" + +msgid "Close" +msgstr "" + +msgid "Confirm Delete" +msgstr "" + +msgid "Delete Confirmation" +msgstr "" + +msgid "" +"Are you sure you want to erase all history for this project? This action " +"cannot be undone." +msgstr "" + +msgid "Added" +msgstr "" + +msgid "Removed" +msgstr "" + +msgid "and" +msgstr "" + +msgid "owers list" +msgstr "" + +msgid "Who?" +msgstr "" + +msgid "Balance" +msgstr "" + +#, python-format +msgid "" +"\n" +" This project has history disabled. New actions won't " +"appear below. You can enable history on the\n" +" settings page\n" +" " +msgstr "" + +msgid "" +"\n" +" The table below reflects actions recorded prior to " +"disabling project history. You can\n" +" clear project history to remove " +"them.

\n" +" " +msgstr "" + +msgid "" +"Some entries below contain IP addresses, even though this project has IP " +"recording disabled. " +msgstr "" + +msgid "Delete stored IP addresses" +msgstr "" + +msgid "No history to erase" +msgstr "" + +msgid "Clear Project History" +msgstr "" + +msgid "No IP Addresses to erase" +msgstr "" + +msgid "Delete Stored IP Addresses" +msgstr "" + +msgid "Time" +msgstr "" + +msgid "Event" +msgstr "" + +msgid "IP address recording can be enabled on the settings page" +msgstr "" + +msgid "IP address recording can be disabled on the settings page" +msgstr "" + +msgid "From IP" +msgstr "" + +msgid "added" +msgstr "" + +msgid "Project private code changed" +msgstr "" + +msgid "Project renamed to" +msgstr "" + +msgid "Project contact email changed to" +msgstr "" + +msgid "Project settings modified" +msgstr "" + +msgid "deactivated" +msgstr "" + +msgid "reactivated" +msgstr "" + +msgid "renamed to" +msgstr "" + +msgid "External link changed to" +msgstr "" + +msgid "Amount" +msgstr "" + +msgid "modified" +msgstr "" + +msgid "removed" +msgstr "" + +msgid "changed in a unknown way" +msgstr "" + +msgid "Nothing to list" +msgstr "" + +msgid "Someone probably cleared the project history." +msgstr "" + +msgid "Manage your shared
expenses, easily" +msgstr "" + +msgid "Try out the demo" +msgstr "" + +msgid "You're sharing a house?" +msgstr "" + +msgid "Going on holidays with friends?" +msgstr "" + +msgid "Simply sharing money with others?" +msgstr "" + +msgid "We can help!" +msgstr "" + +msgid "Log in to an existing project" +msgstr "" + +msgid "Log in" +msgstr "" + +msgid "can't remember your password?" +msgstr "" + +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!" +msgstr "" + +msgid "Account manager" +msgstr "" + +msgid "Bills" +msgstr "" + +msgid "Settle" +msgstr "" + +msgid "Statistics" +msgstr "" + +msgid "History" +msgstr "" + +msgid "Settings" +msgstr "" + +msgid "Languages" +msgstr "" + +msgid "Projects" +msgstr "" + +msgid "Start a new project" +msgstr "" + +msgid "Other projects :" +msgstr "" + +msgid "switch to" +msgstr "" + +msgid "Dashboard" +msgstr "" + +msgid "Logout" +msgstr "" + +msgid "Code" +msgstr "" + +msgid "Mobile Application" +msgstr "" + +msgid "Documentation" +msgstr "" + +msgid "Administation Dashboard" +msgstr "" + +msgid "\"I hate money\" is a free software" +msgstr "" + +msgid "you can contribute and improve it!" +msgstr "" + +msgid "deactivate" +msgstr "" + +msgid "reactivate" +msgstr "" + +msgid "Invite people" +msgstr "" + +msgid "You should start by adding participants" +msgstr "" + +msgid "Add a new bill" +msgstr "" + +msgid "Newer bills" +msgstr "" + +msgid "Older bills" +msgstr "" + +msgid "When?" +msgstr "" + +msgid "Who paid?" +msgstr "" + +msgid "For what?" +msgstr "" + +msgid "How much?" +msgstr "" + +#, python-format +msgid "Added on %(date)s" +msgstr "" + +msgid "Everyone" +msgstr "" + +#, python-format +msgid "Everyone but %(excluded)s" +msgstr "" + +msgid "each" +msgstr "" + +msgid "No bills" +msgstr "" + +msgid "Nothing to list yet." +msgstr "" + +msgid "You probably want to" +msgstr "" + +msgid "add a bill" +msgstr "" + +msgid "add participants" +msgstr "" + +msgid "Password reminder" +msgstr "" + +msgid "" +"A link to reset your password has been sent to you, please check your " +"emails." +msgstr "" + +msgid "Return to home page" +msgstr "" + +msgid "Your projects" +msgstr "" + +msgid "Reset your password" +msgstr "" + +msgid "Invite people to join this project" +msgstr "" + +msgid "Share Identifier & code" +msgstr "" + +msgid "" +"You can share the project identifier and the private code by any " +"communication means." +msgstr "" + +msgid "Identifier:" +msgstr "" + +msgid "Share the Link" +msgstr "" + +msgid "You can directly share the following link via your prefered medium" +msgstr "" + +msgid "Send via Emails" +msgstr "" + +msgid "" +"Specify a (comma separated) list of email adresses you want to notify " +"about the\n" +" creation of this budget management project and we will " +"send them an email for you." +msgstr "" + +msgid "Who pays?" +msgstr "" + +msgid "To whom?" +msgstr "" + +msgid "Paid" +msgstr "" + +msgid "Spent" +msgstr "" + +msgid "Expenses by Month" +msgstr "" + +msgid "Period" +msgstr "" diff --git a/ihatemoney/translations/nb_NO/LC_MESSAGES/messages.mo b/ihatemoney/translations/nb_NO/LC_MESSAGES/messages.mo index dd628180..ae0a3ca3 100644 Binary files a/ihatemoney/translations/nb_NO/LC_MESSAGES/messages.mo and b/ihatemoney/translations/nb_NO/LC_MESSAGES/messages.mo differ diff --git a/ihatemoney/translations/nb_NO/LC_MESSAGES/messages.po b/ihatemoney/translations/nb_NO/LC_MESSAGES/messages.po index 1a95c5e6..22cf027b 100644 --- a/ihatemoney/translations/nb_NO/LC_MESSAGES/messages.po +++ b/ihatemoney/translations/nb_NO/LC_MESSAGES/messages.po @@ -1,19 +1,19 @@ + msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2019-09-30 23:53+0200\n" +"POT-Creation-Date: 2020-04-25 13:02+0200\n" "PO-Revision-Date: 2019-11-12 09:04+0000\n" "Last-Translator: Allan Nordhøy \n" -"Language-Team: Norwegian Bokmål \n" "Language: nb_NO\n" +"Language-Team: Norwegian Bokmål \n" +"Plural-Forms: nplurals=2; plural=n != 1\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 3.10-dev\n" -"Generated-By: Babel 2.7.0\n" +"Generated-By: Babel 2.8.0\n" msgid "" "Not a valid amount or expression. Only numbers and + - * / operators are " @@ -31,6 +31,18 @@ msgstr "Privat kode" msgid "Email" msgstr "E-post" +msgid "Enable project history" +msgstr "" + +msgid "Use IP tracking for project history" +msgstr "" + +msgid "Import previously exported JSON file" +msgstr "" + +msgid "Import" +msgstr "" + msgid "Project identifier" msgstr "Prosjektidentifikator" @@ -129,6 +141,15 @@ msgstr "Send invitasjoner" msgid "The email %(email)s is not valid" msgstr "E-posten \"%(email)s\" er ikke gyldig" +msgid "Participant" +msgstr "" + +msgid "Bill" +msgstr "" + +msgid "Project" +msgstr "Prosjekt" + msgid "Too many failed login attempts, please retry later." msgstr "For mange mislykkede innloggingsforsøk, prøv igjen senere." @@ -165,6 +186,12 @@ msgstr "Ukjent prosjekt" msgid "Password successfully reset." msgstr "Passord tilbakestilt." +msgid "Project successfully uploaded" +msgstr "" + +msgid "Invalid JSON" +msgstr "" + #, fuzzy msgid "Project successfully deleted" msgstr "Prosjekt slettet" @@ -179,8 +206,8 @@ msgid "Your invitations have been sent" msgstr "Invitasjonene dine har blitt sendt" #, python-format -msgid "%(member)s had been added" -msgstr "%(member)s lagt til" +msgid "%(member)s has been added" +msgstr "" #, python-format msgid "%(name)s is part of this project again" @@ -237,9 +264,6 @@ msgstr "?" msgid "Create a new project" msgstr "Opprett et nytt prosjekt" -msgid "Project" -msgstr "Prosjekt" - msgid "Number of members" msgstr "Antall medlemmer" @@ -261,6 +285,10 @@ msgstr "rediger" msgid "delete" msgstr "slett" +#, fuzzy +msgid "see" +msgstr "se" + msgid "The Dashboard is currently deactivated." msgstr "Oversikten er for tiden avskrudd." @@ -270,6 +298,12 @@ msgstr "er du sikker?" msgid "Edit project" msgstr "Rediger prosjekt" +msgid "Import JSON" +msgstr "" + +msgid "Choose file" +msgstr "" + msgid "Download project's data" msgstr "Last ned prosjektets data" @@ -294,6 +328,9 @@ msgstr "Husker du ikke passordet?" msgid "Cancel" msgstr "Avbryt" +msgid "Privacy Settings" +msgstr "" + msgid "Edit the project" msgstr "Rediger prosjektet" @@ -325,6 +362,178 @@ msgstr "Send ut invitasjonene" msgid "Download" msgstr "Last nd" +msgid "Disabled Project History" +msgstr "" + +msgid "Disabled Project History & IP Address Recording" +msgstr "" + +msgid "Enabled Project History" +msgstr "" + +msgid "Disabled IP Address Recording" +msgstr "" + +msgid "Enabled Project History & IP Address Recording" +msgstr "" + +msgid "Enabled IP Address Recording" +msgstr "" + +msgid "History Settings Changed" +msgstr "" + +msgid "changed" +msgstr "" + +msgid "from" +msgstr "" + +msgid "to" +msgstr "" + +msgid "Confirm Remove IP Adresses" +msgstr "" + +msgid "" +"Are you sure you want to delete all recorded IP addresses from this " +"project?\n" +" The rest of the project history will be unaffected. This " +"action cannot be undone." +msgstr "" + +msgid "Close" +msgstr "" + +msgid "Confirm Delete" +msgstr "" + +msgid "Delete Confirmation" +msgstr "" + +msgid "" +"Are you sure you want to erase all history for this project? This action " +"cannot be undone." +msgstr "" + +msgid "Added" +msgstr "" + +msgid "Removed" +msgstr "" + +msgid "and" +msgstr "" + +msgid "owers list" +msgstr "" + +msgid "Who?" +msgstr "Hvem?" + +#, fuzzy +msgid "Balance" +msgstr "Kontobalanse" + +#, python-format +msgid "" +"\n" +" This project has history disabled. New actions won't " +"appear below. You can enable history on the\n" +" settings page\n" +" " +msgstr "" + +msgid "" +"\n" +" The table below reflects actions recorded prior to " +"disabling project history. You can\n" +" clear project history to remove " +"them.

\n" +" " +msgstr "" + +msgid "" +"Some entries below contain IP addresses, even though this project has IP " +"recording disabled. " +msgstr "" + +msgid "Delete stored IP addresses" +msgstr "" + +msgid "No history to erase" +msgstr "" + +msgid "Clear Project History" +msgstr "" + +msgid "No IP Addresses to erase" +msgstr "" + +msgid "Delete Stored IP Addresses" +msgstr "" + +msgid "Time" +msgstr "" + +msgid "Event" +msgstr "" + +msgid "IP address recording can be enabled on the settings page" +msgstr "" + +msgid "IP address recording can be disabled on the settings page" +msgstr "" + +msgid "From IP" +msgstr "" + +msgid "added" +msgstr "" + +msgid "Project private code changed" +msgstr "" + +msgid "Project renamed to" +msgstr "" + +msgid "Project contact email changed to" +msgstr "" + +msgid "Project settings modified" +msgstr "" + +msgid "deactivated" +msgstr "" + +msgid "reactivated" +msgstr "" + +msgid "renamed to" +msgstr "" + +msgid "External link changed to" +msgstr "" + +msgid "Amount" +msgstr "" + +msgid "modified" +msgstr "" + +msgid "removed" +msgstr "" + +msgid "changed in a unknown way" +msgstr "" + +msgid "Nothing to list" +msgstr "" + +msgid "Someone probably cleared the project history." +msgstr "" + msgid "Manage your shared
expenses, easily" msgstr "Håndter delte
utgifter, enkelt" @@ -376,6 +585,9 @@ msgstr "Gjør opp" msgid "Statistics" msgstr "Statistikk" +msgid "History" +msgstr "" + msgid "Settings" msgstr "Innstillinger" @@ -436,6 +648,12 @@ msgstr "Du kan starte ved å legge til deltagere" msgid "Add a new bill" msgstr "Legg til en ny regning" +msgid "Newer bills" +msgstr "" + +msgid "Older bills" +msgstr "" + msgid "When?" msgstr "Når?" @@ -462,10 +680,6 @@ msgstr "Alle, unntagen %(excluded)s" msgid "each" msgstr "hver" -#, fuzzy -msgid "see" -msgstr "se" - msgid "No bills" msgstr "Ingen regninger" @@ -546,18 +760,17 @@ msgstr "Hvem betaler?" msgid "To whom?" msgstr "Til hvem?" -msgid "Who?" -msgstr "Hvem?" - msgid "Paid" msgstr "Betalt" msgid "Spent" msgstr "Forbrukt" -#, fuzzy -msgid "Balance" -msgstr "Kontobalanse" +msgid "Expenses by Month" +msgstr "" + +msgid "Period" +msgstr "" #~ msgid "" #~ "The project identifier is used to " @@ -672,3 +885,6 @@ msgstr "Kontobalanse" #~ "En lenke for å tilbakestille passordet" #~ " har blitt sent til deg per " #~ "e-post." + +#~ msgid "%(member)s had been added" +#~ msgstr "%(member)s lagt til" diff --git a/ihatemoney/translations/nl/LC_MESSAGES/messages.mo b/ihatemoney/translations/nl/LC_MESSAGES/messages.mo index 70d78297..7ce4bd35 100644 Binary files a/ihatemoney/translations/nl/LC_MESSAGES/messages.mo and b/ihatemoney/translations/nl/LC_MESSAGES/messages.mo differ diff --git a/ihatemoney/translations/nl/LC_MESSAGES/messages.po b/ihatemoney/translations/nl/LC_MESSAGES/messages.po index d93204e8..63cec532 100644 --- a/ihatemoney/translations/nl/LC_MESSAGES/messages.po +++ b/ihatemoney/translations/nl/LC_MESSAGES/messages.po @@ -1,19 +1,19 @@ + msgid "" msgstr "" -"Project-Id-Version: \n" +"Project-Id-Version: \n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2019-09-30 23:53+0200\n" +"POT-Creation-Date: 2020-04-25 13:02+0200\n" "PO-Revision-Date: 2019-10-07 22:56+0000\n" "Last-Translator: Heimen Stoffels \n" -"Language-Team: Dutch \n" "Language: nl\n" +"Language-Team: Dutch \n" +"Plural-Forms: nplurals=2; plural=n != 1\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 3.9-dev\n" -"Generated-By: Babel 2.7.0\n" +"Generated-By: Babel 2.8.0\n" msgid "" "Not a valid amount or expression. Only numbers and + - * / operators are " @@ -29,6 +29,18 @@ msgstr "Privécode" msgid "Email" msgstr "E-mailadres" +msgid "Enable project history" +msgstr "" + +msgid "Use IP tracking for project history" +msgstr "" + +msgid "Import previously exported JSON file" +msgstr "" + +msgid "Import" +msgstr "" + msgid "Project identifier" msgstr "Project-id" @@ -123,6 +135,15 @@ msgstr "Uitnodigingen versturen" msgid "The email %(email)s is not valid" msgstr "Het e-mailadres '%(email)s' is onjuist" +msgid "Participant" +msgstr "" + +msgid "Bill" +msgstr "" + +msgid "Project" +msgstr "Project" + msgid "Too many failed login attempts, please retry later." msgstr "Te vaak onjuist ingelogd. Probeer het later opnieuw." @@ -158,6 +179,12 @@ msgstr "Onbekend project" msgid "Password successfully reset." msgstr "Wachtwoord is hersteld." +msgid "Project successfully uploaded" +msgstr "" + +msgid "Invalid JSON" +msgstr "" + msgid "Project successfully deleted" msgstr "Project is verwijderd" @@ -169,8 +196,8 @@ msgid "Your invitations have been sent" msgstr "Je uitnodigingen zijn verstuurd" #, python-format -msgid "%(member)s had been added" -msgstr "%(member)s is toegevoegd" +msgid "%(member)s has been added" +msgstr "" #, python-format msgid "%(name)s is part of this project again" @@ -225,9 +252,6 @@ msgstr "?" msgid "Create a new project" msgstr "Nieuw project aanmaken" -msgid "Project" -msgstr "Project" - msgid "Number of members" msgstr "Aantal deelnemers" @@ -249,6 +273,9 @@ msgstr "bewerken" msgid "delete" msgstr "verwijderen" +msgid "see" +msgstr "bekijk" + msgid "The Dashboard is currently deactivated." msgstr "De overzichtspagina is momenteel uitgeschakeld." @@ -258,6 +285,12 @@ msgstr "weet je het zeker?" msgid "Edit project" msgstr "Project aanpassen" +msgid "Import JSON" +msgstr "" + +msgid "Choose file" +msgstr "" + msgid "Download project's data" msgstr "Projectgegevens downloaden" @@ -281,6 +314,9 @@ msgstr "Ben je je wachtwoord vergeten?" msgid "Cancel" msgstr "Annuleren" +msgid "Privacy Settings" +msgstr "" + msgid "Edit the project" msgstr "Project bewerken" @@ -311,6 +347,177 @@ msgstr "Uitnodigingen versturen" msgid "Download" msgstr "Downloaden" +msgid "Disabled Project History" +msgstr "" + +msgid "Disabled Project History & IP Address Recording" +msgstr "" + +msgid "Enabled Project History" +msgstr "" + +msgid "Disabled IP Address Recording" +msgstr "" + +msgid "Enabled Project History & IP Address Recording" +msgstr "" + +msgid "Enabled IP Address Recording" +msgstr "" + +msgid "History Settings Changed" +msgstr "" + +msgid "changed" +msgstr "" + +msgid "from" +msgstr "" + +msgid "to" +msgstr "" + +msgid "Confirm Remove IP Adresses" +msgstr "" + +msgid "" +"Are you sure you want to delete all recorded IP addresses from this " +"project?\n" +" The rest of the project history will be unaffected. This " +"action cannot be undone." +msgstr "" + +msgid "Close" +msgstr "" + +msgid "Confirm Delete" +msgstr "" + +msgid "Delete Confirmation" +msgstr "" + +msgid "" +"Are you sure you want to erase all history for this project? This action " +"cannot be undone." +msgstr "" + +msgid "Added" +msgstr "" + +msgid "Removed" +msgstr "" + +msgid "and" +msgstr "" + +msgid "owers list" +msgstr "" + +msgid "Who?" +msgstr "Wie?" + +msgid "Balance" +msgstr "Saldo" + +#, python-format +msgid "" +"\n" +" This project has history disabled. New actions won't " +"appear below. You can enable history on the\n" +" settings page\n" +" " +msgstr "" + +msgid "" +"\n" +" The table below reflects actions recorded prior to " +"disabling project history. You can\n" +" clear project history to remove " +"them.

\n" +" " +msgstr "" + +msgid "" +"Some entries below contain IP addresses, even though this project has IP " +"recording disabled. " +msgstr "" + +msgid "Delete stored IP addresses" +msgstr "" + +msgid "No history to erase" +msgstr "" + +msgid "Clear Project History" +msgstr "" + +msgid "No IP Addresses to erase" +msgstr "" + +msgid "Delete Stored IP Addresses" +msgstr "" + +msgid "Time" +msgstr "" + +msgid "Event" +msgstr "" + +msgid "IP address recording can be enabled on the settings page" +msgstr "" + +msgid "IP address recording can be disabled on the settings page" +msgstr "" + +msgid "From IP" +msgstr "" + +msgid "added" +msgstr "" + +msgid "Project private code changed" +msgstr "" + +msgid "Project renamed to" +msgstr "" + +msgid "Project contact email changed to" +msgstr "" + +msgid "Project settings modified" +msgstr "" + +msgid "deactivated" +msgstr "" + +msgid "reactivated" +msgstr "" + +msgid "renamed to" +msgstr "" + +msgid "External link changed to" +msgstr "" + +msgid "Amount" +msgstr "" + +msgid "modified" +msgstr "" + +msgid "removed" +msgstr "" + +msgid "changed in a unknown way" +msgstr "" + +msgid "Nothing to list" +msgstr "" + +msgid "Someone probably cleared the project history." +msgstr "" + msgid "Manage your shared
expenses, easily" msgstr "Beheer eenvoudig je
gedeelde uitgaven" @@ -360,6 +567,9 @@ msgstr "Schikken" msgid "Statistics" msgstr "Statistieken" +msgid "History" +msgstr "" + msgid "Settings" msgstr "Instellingen" @@ -417,6 +627,12 @@ msgstr "Begin met het toevoegen van deelnemers" msgid "Add a new bill" msgstr "Nieuwe rekening toevoegen" +msgid "Newer bills" +msgstr "" + +msgid "Older bills" +msgstr "" + msgid "When?" msgstr "Wanneer?" @@ -443,9 +659,6 @@ msgstr "Iedereen, behalve %(excluded)s" msgid "each" msgstr "per persoon" -msgid "see" -msgstr "bekijk" - msgid "No bills" msgstr "Geen rekeningen" @@ -522,128 +735,14 @@ msgstr "Wie betaalt?" msgid "To whom?" msgstr "Aan wie?" -msgid "Who?" -msgstr "Wie?" - msgid "Paid" msgstr "Betaald" msgid "Spent" msgstr "Uitgegeven" -msgid "Balance" -msgstr "Saldo" +msgid "Expenses by Month" +msgstr "" -#~ msgid "" -#~ "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" -#~ msgstr "" -#~ "De project-id wordt gebruikt om in" -#~ " te loggen en als url van het" -#~ " project. We hebben geprobeerd om een" -#~ " id voor je te genereren, maar " -#~ "er is al een project met deze " -#~ "id. Creëer een nieuwe id die je" -#~ " makkelijk kunt onthouden." - -#~ msgid "" -#~ "Not a valid amount or expression.Only" -#~ " numbers and + - * / " -#~ "operatorsare accepted." -#~ msgstr "" -#~ "Geen geldig bedrag of geldige expressie." -#~ " Alleen getallen en + - * / " -#~ "zijn toegestaan." - -#~ msgid "What do you want to download ?" -#~ msgstr "Wat wil je downloaden?" - -#~ msgid "bills" -#~ msgstr "rekeningen" - -#~ msgid "transactions" -#~ msgstr "transacties" - -#~ msgid "Export file format" -#~ msgstr "Bestandsformaat voor exporteren" - -#~ msgid "Edit this project" -#~ msgstr "Dit project bewerken" - -#~ msgid "Download this project's data" -#~ msgstr "Projectgegevens downloaden" - -#~ msgid "Type user name here" -#~ msgstr "Typ hier de gebruikersnaam" - -#~ msgid "No, thanks" -#~ msgstr "Nee, bedankt" - -#~ msgid "Manage your shared
expenses, easily" -#~ msgstr "Beheer eenvoudig je gedeelde
uitgaven" - -#~ msgid "Log to an existing project" -#~ msgstr "Log in op een bestaand project" - -#~ msgid "log in" -#~ msgstr "inloggen" - -#~ msgid "or create a new one" -#~ msgstr "of creëer een nieuwe" - -#~ msgid "let's get started" -#~ msgstr "aan de slag" - -#~ msgid "options" -#~ msgstr "opties" - -#~ msgid "Project settings" -#~ msgstr "Projectinstellingen" - -#~ msgid "This is a free software" -#~ msgstr "Dit is vrije software" - -#~ msgid "Invite people to join this project!" -#~ msgstr "Nodig mensen uit voor dit project!" - -#~ msgid "Added on" -#~ msgstr "Toegevoegd op" - -#~ msgid "Nothing to list yet. You probably want to" -#~ msgstr "Er kan nog geen opsomming worden gemaakt. Voeg" - -#~ msgid "" -#~ "Specify a (comma separated) list of " -#~ "email adresses you want to notify " -#~ "about the\n" -#~ "creation of this budget management " -#~ "project and we will send them an" -#~ " email for you." -#~ msgstr "" -#~ "Geef een kommagescheiden lijst van " -#~ "e-mailadressen op. Deze mensen worden op" -#~ " de\n" -#~ "hoogte gebracht van het bestaan van " -#~ "dit project en wij sturen hen een" -#~ " e-mail." - -#~ msgid "" -#~ "If you prefer, you can share the project identifier and the shared\n" -#~ "password by other communication means. " -#~ "Or even directly share the following " -#~ "link:" -#~ msgstr "" -#~ "Als je wilt, dan kun je de project-id en het gedeelde wachtwoord\n" -#~ "delen via andere kanalen. Of deel gewoon de volgende link:" - -#~ msgid "A link to reset your password has been sent to your email." -#~ msgstr "" -#~ "Er is een link met " -#~ "wachtwoordherstelinstructies naar je e-mailadres " -#~ "verstuurd." +msgid "Period" +msgstr "" diff --git a/ihatemoney/translations/ru/LC_MESSAGES/messages.po b/ihatemoney/translations/ru/LC_MESSAGES/messages.po new file mode 100644 index 00000000..75d24e31 --- /dev/null +++ b/ihatemoney/translations/ru/LC_MESSAGES/messages.po @@ -0,0 +1,768 @@ +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-04-26 19:50+0200\n" +"PO-Revision-Date: 2020-04-28 07:11+0000\n" +"Last-Translator: Vsevolod \n" +"Language-Team: Russian \n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=" +"4 && (n%100<10 || n%100>=20) ? 1 : 2;\n" +"X-Generator: Weblate 4.0.2\n" + +msgid "" +"Not a valid amount or expression. Only numbers and + - * / operators are " +"accepted." +msgstr "" +"Недопустимая сумма выражения. Принимаются только цифры и операторы + - * / ." + +msgid "Project name" +msgstr "Имя проекта" + +msgid "Private code" +msgstr "Приватный код" + +msgid "Email" +msgstr "Email" + +msgid "Enable project history" +msgstr "Включить историю проекта" + +msgid "Use IP tracking for project history" +msgstr "Использовать отслеживание по IP для истории проекта" + +msgid "Import previously exported JSON file" +msgstr "Импортировать ранее экспортированный JSON файл" + +msgid "Import" +msgstr "Импортировать" + +msgid "Project identifier" +msgstr "Идентификатор проекта" + +msgid "Create the project" +msgstr "Создать проект" + +#, python-format +msgid "" +"A project with this identifier (\"%(project)s\") already exists. Please " +"choose a new identifier" +msgstr "" +"Проект с идентификатором (\"%(project)s\") уже существует. Пожалуйста, " +"выберете новый идентификатор" + +msgid "Get in" +msgstr "Войти" + +msgid "Admin password" +msgstr "Пароль администратора" + +msgid "Send me the code by email" +msgstr "Отправить код мне на Email" + +msgid "This project does not exists" +msgstr "Такой проект не существует" + +msgid "Password mismatch" +msgstr "Пароли не совпадают" + +msgid "Password" +msgstr "Пароль" + +msgid "Password confirmation" +msgstr "Подтвердите пароль" + +msgid "Reset password" +msgstr "Восстановить пароль" + +msgid "Date" +msgstr "Дата" + +msgid "What?" +msgstr "Что?" + +msgid "Payer" +msgstr "Плательщик" + +msgid "Amount paid" +msgstr "Уплаченная сумма" + +msgid "External link" +msgstr "Внешняя ссылка" + +msgid "A link to an external document, related to this bill" +msgstr "Ссылка на внешний документ, относящийся к этому счёту" + +msgid "For whom?" +msgstr "Кому?" + +msgid "Submit" +msgstr "Отправить" + +msgid "Submit and add a new one" +msgstr "Отправить и добавить новый" + +msgid "Bills can't be null" +msgstr "Счета не могут быть нулевыми" + +msgid "Name" +msgstr "Имя" + +msgid "Weights should be positive" +msgstr "Вес должен быть положительным" + +msgid "Weight" +msgstr "Вес" + +msgid "Add" +msgstr "Добавить" + +msgid "User name incorrect" +msgstr "Неправильное имя пользователя" + +msgid "This project already have this member" +msgstr "В этом проекте уже есть такой участник" + +msgid "People to notify" +msgstr "Люди для уведомления" + +msgid "Send invites" +msgstr "Отправить приглашения" + +#, python-format +msgid "The email %(email)s is not valid" +msgstr "Email %(email)s не правильный" + +msgid "Participant" +msgstr "Участник" + +msgid "Bill" +msgstr "Счёт" + +msgid "Project" +msgstr "Проект" + +msgid "Too many failed login attempts, please retry later." +msgstr "Слишком много неудачных попыток входа, попробуйте позже." + +#, python-format +msgid "This admin password is not the right one. Only %(num)d attempts left." +msgstr "" +"Этот пароль администратора неправильный. Осталось только %(num)d попыток." + +msgid "You either provided a bad token or no project identifier." +msgstr "" +"Вы либо предоставили неверный токен, либо не указали идентификатор проекта." + +msgid "This private code is not the right one" +msgstr "Этот приватный код не подходит" + +#, python-format +msgid "You have just created '%(project)s' to share your expenses" +msgstr "Вы только что создали '%(project)s' , чтобы разделить расходы" + +#, python-format +msgid "%(msg_compl)sThe project identifier is %(project)s" +msgstr "%(msg_compl)sИдентификатор проекта это %(project)s" + +msgid "No token provided" +msgstr "Не предоставлен токен" + +msgid "Invalid token" +msgstr "Неправильный токен" + +msgid "Unknown project" +msgstr "Неизвестный проект" + +msgid "Password successfully reset." +msgstr "Пароль успешно восстановлен." + +msgid "Project successfully uploaded" +msgstr "Проект успешно загружен" + +msgid "Invalid JSON" +msgstr "Неправильный JSON" + +msgid "Project successfully deleted" +msgstr "Проект удалён" + +#, python-format +msgid "You have been invited to share your expenses for %(project)s" +msgstr "Вас пригласили разделить расходы в проект %(project)s" + +msgid "Your invitations have been sent" +msgstr "Ваш код приглашения был отправлен" + +#, python-format +msgid "%(member)s has been added" +msgstr "%(member)s был добавлен" + +#, python-format +msgid "%(name)s is part of this project again" +msgstr "%(name)s снова часть этого проекта" + +#, python-format +msgid "" +"User '%(name)s' has been deactivated. It will still appear in the users " +"list until its balance becomes zero." +msgstr "" +"Пользователь '%(name)s' был деактивирован. Он будет отображаться в списке " +"пользователей до тех пор, пока его баланс не станет равным нулю." + +#, python-format +msgid "User '%(name)s' has been removed" +msgstr "Пользователь '%(name)s' был удалён" + +#, python-format +msgid "User '%(name)s' has been edited" +msgstr "Пользователь '%(name)s' был изменён" + +msgid "The bill has been added" +msgstr "Счёт был добавлен" + +msgid "The bill has been deleted" +msgstr "Счёт был удалён" + +msgid "The bill has been modified" +msgstr "Счёт был изменён" + +msgid "Sorry, we were unable to find the page you've asked for." +msgstr "К сожалению, нам не удалось найти страницу, которую вы запросили." + +msgid "The best thing to do is probably to get back to the main page." +msgstr "Лучше всего вернуться на главную страницу." + +msgid "Back to the list" +msgstr "Вернутся к списку" + +msgid "Administration tasks are currently disabled." +msgstr "Задачи администратора в данный момент отключены." + +msgid "The project you are trying to access do not exist, do you want to" +msgstr "" +"Проект, к которому вы пытаетесь получить доступ, не существует, вы хотите" + +msgid "create it" +msgstr "создать его" + +msgid "?" +msgstr "?" + +msgid "Create a new project" +msgstr "Создать новый проект" + +msgid "Number of members" +msgstr "Число участников" + +msgid "Number of bills" +msgstr "Число счетов" + +msgid "Newest bill" +msgstr "Новейший счёт" + +msgid "Oldest bill" +msgstr "Старейший счёт" + +msgid "Actions" +msgstr "Действия" + +msgid "edit" +msgstr "изменить" + +msgid "delete" +msgstr "удалить" + +msgid "see" +msgstr "просмотреть" + +msgid "The Dashboard is currently deactivated." +msgstr "Панель инструментов в данный момент отключена." + +msgid "you sure?" +msgstr "вы уверены?" + +msgid "Edit project" +msgstr "Изменить проект" + +msgid "Import JSON" +msgstr "Импортировать JSON" + +msgid "Choose file" +msgstr "Выбрать файл" + +msgid "Download project's data" +msgstr "Скачать данные проекта" + +msgid "Bill items" +msgstr "Пункты счета" + +msgid "Download the list of bills with owner, amount, reason,... " +msgstr "Скачать список счетов с владельцем, суммой, причиной, .. " + +msgid "Settle plans" +msgstr "Урегулировать планы" + +msgid "Download the list of transactions needed to settle the current bills." +msgstr "Скачать список переводов нужных, чтобы урегулировать данные счета." + +msgid "Can't remember the password?" +msgstr "Не помните пароль?" + +msgid "Cancel" +msgstr "Отменить" + +msgid "Privacy Settings" +msgstr "Настройки приватности" + +msgid "Edit the project" +msgstr "Изменить проект" + +msgid "Edit this bill" +msgstr "Изменить счёт" + +msgid "Add a bill" +msgstr "Добавить счёт" + +msgid "Select all" +msgstr "Выбрать всё" + +msgid "Select none" +msgstr "Отменить выбор" + +msgid "Add participant" +msgstr "Добавить участника" + +msgid "Edit this member" +msgstr "Изменить этого участника" + +msgid "john.doe@example.com, mary.moe@site.com" +msgstr "john.doe@example.com, mary.moe@site.com" + +msgid "Send the invitations" +msgstr "Отправить приглашения" + +msgid "Download" +msgstr "Скачать" + +msgid "Disabled Project History" +msgstr "История отключенных проектов" + +msgid "Disabled Project History & IP Address Recording" +msgstr "Отключенная история проекта и запись IP-адреса" + +msgid "Enabled Project History" +msgstr "Включить историю проекта" + +msgid "Disabled IP Address Recording" +msgstr "Выключить запись IP-адрессов" + +msgid "Enabled Project History & IP Address Recording" +msgstr "Включить историю проекта и запись IP адрессов" + +msgid "Enabled IP Address Recording" +msgstr "Включить запись IP адрессов" + +msgid "History Settings Changed" +msgstr "Настройки истории изменены" + +msgid "changed" +msgstr "изменены" + +msgid "from" +msgstr "от" + +msgid "to" +msgstr "кому" + +msgid "Confirm Remove IP Adresses" +msgstr "Подтвердите удаление IP-адресов" + +msgid "" +"Are you sure you want to delete all recorded IP addresses from this " +"project?\n" +" The rest of the project history will be unaffected. This " +"action cannot be undone." +msgstr "" +"Вы уверены, что хотите удалить все записанные IP-адреса из этого проекта?\n" +" Остальная часть истории проекта не будет затронута. Это " +"действие не может быть отменено." + +msgid "Close" +msgstr "Закрыть" + +msgid "Confirm Delete" +msgstr "Подтвердить удаление" + +msgid "Delete Confirmation" +msgstr "Подтверждение удаления" + +msgid "" +"Are you sure you want to erase all history for this project? This action " +"cannot be undone." +msgstr "" +"Вы уверены, что хотите стереть историю проекта? Это действие нельзя отменить." + +msgid "Added" +msgstr "Добавлен" + +msgid "Removed" +msgstr "Удалён" + +msgid "and" +msgstr "и" + +msgid "owers list" +msgstr "список владельцев" + +msgid "Who?" +msgstr "Кто?" + +msgid "Balance" +msgstr "Баланс" + +#, python-format +msgid "" +"\n" +" This project has history disabled. New actions won't " +"appear below. You can enable history on the\n" +" settings page\n" +" " +msgstr "" +"\n" +" У этого проекта история отключена. Новые действия не появятся " +"ниже. Вы можете включить историю в\n" +" настройках\n" +" " + +msgid "" +"\n" +" The table below reflects actions recorded prior to " +"disabling project history. You can\n" +" clear project history to remove " +"them.

\n" +" " +msgstr "" +"\n" +" В таблице ниже отражены действия, записанные до отключения " +"истории проекта. Вы можете\n" +" очистить историю проекта to remove " +"them.

\n" +" " + +msgid "" +"Some entries below contain IP addresses, even though this project has IP " +"recording disabled. " +msgstr "" +"Некоторые записи ниже содержат IP-адреса, хотя в этом проекте запись IP " +"отключена. " + +msgid "Delete stored IP addresses" +msgstr "Удалить сохраненные IP-адреса" + +msgid "No history to erase" +msgstr "Нечего стирать" + +msgid "Clear Project History" +msgstr "Стереть историю проекта" + +msgid "No IP Addresses to erase" +msgstr "Нечего стирать" + +msgid "Delete Stored IP Addresses" +msgstr "Удалить сохраненные IP-адреса" + +msgid "Time" +msgstr "Время" + +msgid "Event" +msgstr "Событие" + +msgid "IP address recording can be enabled on the settings page" +msgstr "Запись IP-адреса может быть включена на странице настроек" + +msgid "IP address recording can be disabled on the settings page" +msgstr "Запись IP-адреса может быть отключена на странице настроек" + +msgid "From IP" +msgstr "От IP" + +msgid "added" +msgstr "добавлен" + +msgid "Project private code changed" +msgstr "Приватный код проекта изменен" + +msgid "Project renamed to" +msgstr "Проект переименован в" + +msgid "Project contact email changed to" +msgstr "Контактная почта проекта изменена на" + +msgid "Project settings modified" +msgstr "Настройки проекта изменены" + +msgid "deactivated" +msgstr "отключено" + +msgid "reactivated" +msgstr "реактивирован" + +msgid "renamed to" +msgstr "переименован в" + +msgid "External link changed to" +msgstr "Внешняя ссылка изменена на" + +msgid "Amount" +msgstr "Количество" + +msgid "modified" +msgstr "изменено" + +msgid "removed" +msgstr "удалено" + +msgid "changed in a unknown way" +msgstr "изменилось неизвестным образом" + +msgid "Nothing to list" +msgstr "Нечего перечислять" + +msgid "Someone probably cleared the project history." +msgstr "Кто-то скорее всего стёр историю проекта." + +msgid "Manage your shared
expenses, easily" +msgstr "Управляйте своими общими
расходами проще" + +msgid "Try out the demo" +msgstr "Попробуйте" + +msgid "You're sharing a house?" +msgstr "Вы живете в одном доме с другими людьми?" + +msgid "Going on holidays with friends?" +msgstr "Собираетесь в отпуск с друзьями?" + +msgid "Simply sharing money with others?" +msgstr "Просто делиться деньгами с другими?" + +msgid "We can help!" +msgstr "Мы поможем!" + +msgid "Log in to an existing project" +msgstr "Войти в существующий проект" + +msgid "Log in" +msgstr "Войти" + +msgid "can't remember your password?" +msgstr "не помните пароль?" + +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!" +msgstr "" +"Этот код доступа будет отправлен вашим друзьям. Он хранится на сервере как " +"есть, поэтому не используйте личный пароль!" + +msgid "Account manager" +msgstr "Менеджер аккаунтов" + +msgid "Bills" +msgstr "Счета" + +msgid "Settle" +msgstr "Отрегулировать" + +msgid "Statistics" +msgstr "Статистика" + +msgid "History" +msgstr "История" + +msgid "Settings" +msgstr "Настройки" + +msgid "Languages" +msgstr "Языки" + +msgid "Projects" +msgstr "Проекты" + +msgid "Start a new project" +msgstr "Начать новый проект" + +msgid "Other projects :" +msgstr "Остальные проекты :" + +msgid "switch to" +msgstr "сменён на" + +msgid "Dashboard" +msgstr "Панель инструментов" + +msgid "Logout" +msgstr "Выйти" + +msgid "Code" +msgstr "Код" + +msgid "Mobile Application" +msgstr "Мобильное приложение" + +msgid "Documentation" +msgstr "Документация" + +msgid "Administation Dashboard" +msgstr "Панель инструментов администратора" + +msgid "\"I hate money\" is a free software" +msgstr "\" I hate money \" - бесплатная программа" + +msgid "you can contribute and improve it!" +msgstr "вы можете способствовать развитию и улучшать её!" + +msgid "deactivate" +msgstr "отключить" + +msgid "reactivate" +msgstr "включить" + +msgid "Invite people" +msgstr "Пригласить людей" + +msgid "You should start by adding participants" +msgstr "Вам стоит начать с добавления пользователей" + +msgid "Add a new bill" +msgstr "Добавить новый счёт" + +msgid "Newer bills" +msgstr "Новые счета" + +msgid "Older bills" +msgstr "Старые счета" + +msgid "When?" +msgstr "Когда?" + +msgid "Who paid?" +msgstr "Кто заплатил?" + +msgid "For what?" +msgstr "За что?" + +msgid "How much?" +msgstr "Сколько?" + +#, python-format +msgid "Added on %(date)s" +msgstr "Добавлено %(date)s" + +msgid "Everyone" +msgstr "Каждый" + +#, python-format +msgid "Everyone but %(excluded)s" +msgstr "Каждый, кроме %(excluded)s" + +msgid "each" +msgstr "каждый" + +msgid "No bills" +msgstr "Нет счетов" + +msgid "Nothing to list yet." +msgstr "Нечего перечислять еще." + +msgid "You probably want to" +msgstr "Возможно вы хотите" + +msgid "add a bill" +msgstr "добавить счёт" + +msgid "add participants" +msgstr "добавить пользователя" + +msgid "Password reminder" +msgstr "Напоминание пароля" + +msgid "" +"A link to reset your password has been sent to you, please check your " +"emails." +msgstr "" +"Ссылка для восстановления пароля отправлена, пожалуйста, проверьте Email." + +msgid "Return to home page" +msgstr "Вернуться на главную страницу" + +msgid "Your projects" +msgstr "Ваши проекты" + +msgid "Reset your password" +msgstr "Восстановить пароль" + +msgid "Invite people to join this project" +msgstr "Пригласить людей присоединиться к этому проекту" + +msgid "Share Identifier & code" +msgstr "Поделиться идентификатором и кодом" + +msgid "" +"You can share the project identifier and the private code by any " +"communication means." +msgstr "" +"Вы можете поделиться идентификатором проекта и личным кодом любым способом " +"связи." + +msgid "Identifier:" +msgstr "Идентификатор:" + +msgid "Share the Link" +msgstr "Поделиться ссылкой" + +msgid "You can directly share the following link via your prefered medium" +msgstr "" +"Вы можете напрямую поделиться следующей ссылкой через любой способ связи" + +msgid "Send via Emails" +msgstr "Отправить по почте" + +msgid "" +"Specify a (comma separated) list of email adresses you want to notify " +"about the\n" +" creation of this budget management project and we will " +"send them an email for you." +msgstr "" +"Укажите (разделенный запятыми) список адресов электронной почты, которые вы " +"хотите уведомить о\n" +" создание этого проекта управления бюджетом, и мы вышлем им " +"письмо." + +msgid "Who pays?" +msgstr "Кто платит?" + +msgid "To whom?" +msgstr "Кому?" + +msgid "Paid" +msgstr "Оплачено" + +msgid "Spent" +msgstr "Потрачено" + +msgid "Expenses by Month" +msgstr "Расходы по месяцам" + +msgid "Period" +msgstr "Период" diff --git a/ihatemoney/translations/tr/LC_MESSAGES/messages.mo b/ihatemoney/translations/tr/LC_MESSAGES/messages.mo index 94321980..b8383048 100644 Binary files a/ihatemoney/translations/tr/LC_MESSAGES/messages.mo and b/ihatemoney/translations/tr/LC_MESSAGES/messages.mo differ diff --git a/ihatemoney/translations/tr/LC_MESSAGES/messages.po b/ihatemoney/translations/tr/LC_MESSAGES/messages.po index c072285c..7e2d6a36 100644 --- a/ihatemoney/translations/tr/LC_MESSAGES/messages.po +++ b/ihatemoney/translations/tr/LC_MESSAGES/messages.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2019-10-01 21:48+0200\n" +"POT-Creation-Date: 2020-04-25 13:02+0200\n" "PO-Revision-Date: 2019-08-07 13:24+0000\n" "Last-Translator: Mesut Akcan \n" "Language: tr\n" @@ -13,7 +13,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.7.0\n" +"Generated-By: Babel 2.8.0\n" msgid "" "Not a valid amount or expression. Only numbers and + - * / operators are " @@ -31,6 +31,18 @@ msgstr "Özel kod" msgid "Email" msgstr "E-posta" +msgid "Enable project history" +msgstr "" + +msgid "Use IP tracking for project history" +msgstr "" + +msgid "Import previously exported JSON file" +msgstr "" + +msgid "Import" +msgstr "" + msgid "Project identifier" msgstr "Proje tanımlayıcısı" @@ -127,6 +139,15 @@ msgstr "" msgid "The email %(email)s is not valid" msgstr "" +msgid "Participant" +msgstr "" + +msgid "Bill" +msgstr "" + +msgid "Project" +msgstr "" + msgid "Too many failed login attempts, please retry later." msgstr "" @@ -160,6 +181,12 @@ msgstr "" msgid "Password successfully reset." msgstr "" +msgid "Project successfully uploaded" +msgstr "" + +msgid "Invalid JSON" +msgstr "" + msgid "Project successfully deleted" msgstr "" @@ -171,7 +198,7 @@ msgid "Your invitations have been sent" msgstr "" #, python-format -msgid "%(member)s had been added" +msgid "%(member)s has been added" msgstr "" #, python-format @@ -225,9 +252,6 @@ msgstr "" msgid "Create a new project" msgstr "" -msgid "Project" -msgstr "" - msgid "Number of members" msgstr "" @@ -249,6 +273,9 @@ msgstr "" msgid "delete" msgstr "" +msgid "see" +msgstr "" + msgid "The Dashboard is currently deactivated." msgstr "" @@ -258,6 +285,12 @@ msgstr "" msgid "Edit project" msgstr "" +msgid "Import JSON" +msgstr "" + +msgid "Choose file" +msgstr "" + msgid "Download project's data" msgstr "" @@ -279,6 +312,9 @@ msgstr "" msgid "Cancel" msgstr "" +msgid "Privacy Settings" +msgstr "" + msgid "Edit the project" msgstr "" @@ -309,6 +345,177 @@ msgstr "" msgid "Download" msgstr "" +msgid "Disabled Project History" +msgstr "" + +msgid "Disabled Project History & IP Address Recording" +msgstr "" + +msgid "Enabled Project History" +msgstr "" + +msgid "Disabled IP Address Recording" +msgstr "" + +msgid "Enabled Project History & IP Address Recording" +msgstr "" + +msgid "Enabled IP Address Recording" +msgstr "" + +msgid "History Settings Changed" +msgstr "" + +msgid "changed" +msgstr "" + +msgid "from" +msgstr "" + +msgid "to" +msgstr "" + +msgid "Confirm Remove IP Adresses" +msgstr "" + +msgid "" +"Are you sure you want to delete all recorded IP addresses from this " +"project?\n" +" The rest of the project history will be unaffected. This " +"action cannot be undone." +msgstr "" + +msgid "Close" +msgstr "" + +msgid "Confirm Delete" +msgstr "" + +msgid "Delete Confirmation" +msgstr "" + +msgid "" +"Are you sure you want to erase all history for this project? This action " +"cannot be undone." +msgstr "" + +msgid "Added" +msgstr "" + +msgid "Removed" +msgstr "" + +msgid "and" +msgstr "" + +msgid "owers list" +msgstr "" + +msgid "Who?" +msgstr "" + +msgid "Balance" +msgstr "" + +#, python-format +msgid "" +"\n" +" This project has history disabled. New actions won't " +"appear below. You can enable history on the\n" +" settings page\n" +" " +msgstr "" + +msgid "" +"\n" +" The table below reflects actions recorded prior to " +"disabling project history. You can\n" +" clear project history to remove " +"them.

\n" +" " +msgstr "" + +msgid "" +"Some entries below contain IP addresses, even though this project has IP " +"recording disabled. " +msgstr "" + +msgid "Delete stored IP addresses" +msgstr "" + +msgid "No history to erase" +msgstr "" + +msgid "Clear Project History" +msgstr "" + +msgid "No IP Addresses to erase" +msgstr "" + +msgid "Delete Stored IP Addresses" +msgstr "" + +msgid "Time" +msgstr "" + +msgid "Event" +msgstr "" + +msgid "IP address recording can be enabled on the settings page" +msgstr "" + +msgid "IP address recording can be disabled on the settings page" +msgstr "" + +msgid "From IP" +msgstr "" + +msgid "added" +msgstr "" + +msgid "Project private code changed" +msgstr "" + +msgid "Project renamed to" +msgstr "" + +msgid "Project contact email changed to" +msgstr "" + +msgid "Project settings modified" +msgstr "" + +msgid "deactivated" +msgstr "" + +msgid "reactivated" +msgstr "" + +msgid "renamed to" +msgstr "" + +msgid "External link changed to" +msgstr "" + +msgid "Amount" +msgstr "" + +msgid "modified" +msgstr "" + +msgid "removed" +msgstr "" + +msgid "changed in a unknown way" +msgstr "" + +msgid "Nothing to list" +msgstr "" + +msgid "Someone probably cleared the project history." +msgstr "" + msgid "Manage your shared
expenses, easily" msgstr "" @@ -356,6 +563,9 @@ msgstr "" msgid "Statistics" msgstr "" +msgid "History" +msgstr "" + msgid "Settings" msgstr "" @@ -413,6 +623,12 @@ msgstr "" msgid "Add a new bill" msgstr "" +msgid "Newer bills" +msgstr "" + +msgid "Older bills" +msgstr "" + msgid "When?" msgstr "" @@ -439,9 +655,6 @@ msgstr "" msgid "each" msgstr "" -msgid "see" -msgstr "" - msgid "No bills" msgstr "" @@ -510,91 +723,14 @@ msgstr "" msgid "To whom?" msgstr "" -msgid "Who?" -msgstr "" - msgid "Paid" msgstr "" msgid "Spent" msgstr "" -msgid "Balance" +msgid "Expenses by Month" msgstr "" -#~ msgid "What do you want to download ?" -#~ msgstr "" - -#~ msgid "bills" -#~ msgstr "" - -#~ msgid "transactions" -#~ msgstr "" - -#~ msgid "Export file format" -#~ msgstr "" - -#~ msgid "Edit this project" -#~ msgstr "" - -#~ msgid "Download this project's data" -#~ msgstr "" - -#~ msgid "Type user name here" -#~ msgstr "" - -#~ msgid "No, thanks" -#~ msgstr "" - -#~ msgid "Manage your shared
expenses, easily" -#~ msgstr "" - -#~ msgid "Log to an existing project" -#~ msgstr "" - -#~ msgid "log in" -#~ msgstr "" - -#~ msgid "or create a new one" -#~ msgstr "" - -#~ msgid "let's get started" -#~ msgstr "" - -#~ msgid "options" -#~ msgstr "" - -#~ msgid "Project settings" -#~ msgstr "" - -#~ msgid "This is a free software" -#~ msgstr "" - -#~ msgid "Invite people to join this project!" -#~ msgstr "" - -#~ msgid "Added on" -#~ msgstr "" - -#~ msgid "Nothing to list yet. You probably want to" -#~ msgstr "" - -#~ msgid "" -#~ "Specify a (comma separated) list of " -#~ "email adresses you want to notify " -#~ "about the\n" -#~ "creation of this budget management " -#~ "project and we will send them an" -#~ " email for you." -#~ msgstr "" - -#~ msgid "" -#~ "If you prefer, you can share the project identifier and the shared\n" -#~ "password by other communication means. " -#~ "Or even directly share the following " -#~ "link:" -#~ msgstr "" - -#~ msgid "A link to reset your password has been sent to your email." -#~ msgstr "" - +msgid "Period" +msgstr "" diff --git a/ihatemoney/translations/uk/LC_MESSAGES/messages.mo b/ihatemoney/translations/uk/LC_MESSAGES/messages.mo new file mode 100644 index 00000000..699828d9 Binary files /dev/null and b/ihatemoney/translations/uk/LC_MESSAGES/messages.mo differ diff --git a/ihatemoney/translations/uk/LC_MESSAGES/messages.po b/ihatemoney/translations/uk/LC_MESSAGES/messages.po index 27894755..3fa6e99d 100644 --- a/ihatemoney/translations/uk/LC_MESSAGES/messages.po +++ b/ihatemoney/translations/uk/LC_MESSAGES/messages.po @@ -1,19 +1,20 @@ + msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2019-12-05 15:35+0200\n" +"POT-Creation-Date: 2020-04-25 13:02+0200\n" "PO-Revision-Date: 2019-12-08 16:26+0000\n" "Last-Translator: Tymofij Lytvynenko \n" -"Language-Team: Ukrainian \n" "Language: uk\n" +"Language-Team: Ukrainian \n" +"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2\n" "MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" +"Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=" -"4 && (n%100<10 || n%100>=20) ? 1 : 2;\n" -"X-Generator: Weblate 3.10-dev\n" +"Generated-By: Babel 2.8.0\n" msgid "" "Not a valid amount or expression. Only numbers and + - * / operators are " @@ -29,6 +30,18 @@ msgstr "Приватний код" msgid "Email" msgstr "Е-пошта" +msgid "Enable project history" +msgstr "" + +msgid "Use IP tracking for project history" +msgstr "" + +msgid "Import previously exported JSON file" +msgstr "" + +msgid "Import" +msgstr "" + msgid "Project identifier" msgstr "Ідентифікатор проєкту" @@ -123,6 +136,15 @@ msgstr "" msgid "The email %(email)s is not valid" msgstr "" +msgid "Participant" +msgstr "" + +msgid "Bill" +msgstr "" + +msgid "Project" +msgstr "" + msgid "Too many failed login attempts, please retry later." msgstr "" @@ -156,6 +178,12 @@ msgstr "" msgid "Password successfully reset." msgstr "" +msgid "Project successfully uploaded" +msgstr "" + +msgid "Invalid JSON" +msgstr "" + msgid "Project successfully deleted" msgstr "" @@ -167,7 +195,7 @@ msgid "Your invitations have been sent" msgstr "" #, python-format -msgid "%(member)s had been added" +msgid "%(member)s has been added" msgstr "" #, python-format @@ -221,9 +249,6 @@ msgstr "" msgid "Create a new project" msgstr "" -msgid "Project" -msgstr "" - msgid "Number of members" msgstr "" @@ -245,6 +270,9 @@ msgstr "" msgid "delete" msgstr "" +msgid "see" +msgstr "" + msgid "The Dashboard is currently deactivated." msgstr "" @@ -254,6 +282,12 @@ msgstr "" msgid "Edit project" msgstr "" +msgid "Import JSON" +msgstr "" + +msgid "Choose file" +msgstr "" + msgid "Download project's data" msgstr "" @@ -275,6 +309,9 @@ msgstr "" msgid "Cancel" msgstr "" +msgid "Privacy Settings" +msgstr "" + msgid "Edit the project" msgstr "" @@ -305,6 +342,177 @@ msgstr "" msgid "Download" msgstr "" +msgid "Disabled Project History" +msgstr "" + +msgid "Disabled Project History & IP Address Recording" +msgstr "" + +msgid "Enabled Project History" +msgstr "" + +msgid "Disabled IP Address Recording" +msgstr "" + +msgid "Enabled Project History & IP Address Recording" +msgstr "" + +msgid "Enabled IP Address Recording" +msgstr "" + +msgid "History Settings Changed" +msgstr "" + +msgid "changed" +msgstr "" + +msgid "from" +msgstr "" + +msgid "to" +msgstr "" + +msgid "Confirm Remove IP Adresses" +msgstr "" + +msgid "" +"Are you sure you want to delete all recorded IP addresses from this " +"project?\n" +" The rest of the project history will be unaffected. This " +"action cannot be undone." +msgstr "" + +msgid "Close" +msgstr "" + +msgid "Confirm Delete" +msgstr "" + +msgid "Delete Confirmation" +msgstr "" + +msgid "" +"Are you sure you want to erase all history for this project? This action " +"cannot be undone." +msgstr "" + +msgid "Added" +msgstr "" + +msgid "Removed" +msgstr "" + +msgid "and" +msgstr "" + +msgid "owers list" +msgstr "" + +msgid "Who?" +msgstr "" + +msgid "Balance" +msgstr "" + +#, python-format +msgid "" +"\n" +" This project has history disabled. New actions won't " +"appear below. You can enable history on the\n" +" settings page\n" +" " +msgstr "" + +msgid "" +"\n" +" The table below reflects actions recorded prior to " +"disabling project history. You can\n" +" clear project history to remove " +"them.

\n" +" " +msgstr "" + +msgid "" +"Some entries below contain IP addresses, even though this project has IP " +"recording disabled. " +msgstr "" + +msgid "Delete stored IP addresses" +msgstr "" + +msgid "No history to erase" +msgstr "" + +msgid "Clear Project History" +msgstr "" + +msgid "No IP Addresses to erase" +msgstr "" + +msgid "Delete Stored IP Addresses" +msgstr "" + +msgid "Time" +msgstr "" + +msgid "Event" +msgstr "" + +msgid "IP address recording can be enabled on the settings page" +msgstr "" + +msgid "IP address recording can be disabled on the settings page" +msgstr "" + +msgid "From IP" +msgstr "" + +msgid "added" +msgstr "" + +msgid "Project private code changed" +msgstr "" + +msgid "Project renamed to" +msgstr "" + +msgid "Project contact email changed to" +msgstr "" + +msgid "Project settings modified" +msgstr "" + +msgid "deactivated" +msgstr "" + +msgid "reactivated" +msgstr "" + +msgid "renamed to" +msgstr "" + +msgid "External link changed to" +msgstr "" + +msgid "Amount" +msgstr "" + +msgid "modified" +msgstr "" + +msgid "removed" +msgstr "" + +msgid "changed in a unknown way" +msgstr "" + +msgid "Nothing to list" +msgstr "" + +msgid "Someone probably cleared the project history." +msgstr "" + msgid "Manage your shared
expenses, easily" msgstr "" @@ -352,6 +560,9 @@ msgstr "" msgid "Statistics" msgstr "" +msgid "History" +msgstr "" + msgid "Settings" msgstr "" @@ -409,6 +620,12 @@ msgstr "" msgid "Add a new bill" msgstr "" +msgid "Newer bills" +msgstr "" + +msgid "Older bills" +msgstr "" + msgid "When?" msgstr "" @@ -435,9 +652,6 @@ msgstr "" msgid "each" msgstr "" -msgid "see" -msgstr "" - msgid "No bills" msgstr "" @@ -506,14 +720,14 @@ msgstr "" msgid "To whom?" msgstr "" -msgid "Who?" -msgstr "" - msgid "Paid" msgstr "" msgid "Spent" msgstr "" -msgid "Balance" +msgid "Expenses by Month" +msgstr "" + +msgid "Period" msgstr "" diff --git a/ihatemoney/translations/zh_HANS-CN/LC_MESSAGES/messages.mo b/ihatemoney/translations/zh_HANS-CN/LC_MESSAGES/messages.mo index 603b28c8..89f8f302 100644 Binary files a/ihatemoney/translations/zh_HANS-CN/LC_MESSAGES/messages.mo and b/ihatemoney/translations/zh_HANS-CN/LC_MESSAGES/messages.mo differ diff --git a/ihatemoney/translations/zh_HANS-CN/LC_MESSAGES/messages.po b/ihatemoney/translations/zh_HANS-CN/LC_MESSAGES/messages.po index 64251ec9..ba1d920d 100644 --- a/ihatemoney/translations/zh_HANS-CN/LC_MESSAGES/messages.po +++ b/ihatemoney/translations/zh_HANS-CN/LC_MESSAGES/messages.po @@ -1,19 +1,20 @@ + msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2019-10-01 21:48+0200\n" +"POT-Creation-Date: 2020-04-25 13:02+0200\n" "PO-Revision-Date: 2020-02-09 12:01+0000\n" "Last-Translator: Muge Niu \n" -"Language-Team: Chinese (Simplified) \n" -"Language: zh_HANS-CN\n" +"Language: zh_HANS_CN\n" +"Language-Team: Chinese (Simplified) " +"" +"\n" +"Plural-Forms: nplurals=1; plural=0\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=1; plural=0;\n" -"X-Generator: Weblate 3.11-dev\n" -"Generated-By: Babel 2.7.0\n" +"Generated-By: Babel 2.8.0\n" msgid "" "Not a valid amount or expression. Only numbers and + - * / operators are " @@ -29,6 +30,18 @@ msgstr "共享密钥" msgid "Email" msgstr "邮箱" +msgid "Enable project history" +msgstr "" + +msgid "Use IP tracking for project history" +msgstr "" + +msgid "Import previously exported JSON file" +msgstr "" + +msgid "Import" +msgstr "" + msgid "Project identifier" msgstr "账目名称" @@ -123,6 +136,15 @@ msgstr "" msgid "The email %(email)s is not valid" msgstr "" +msgid "Participant" +msgstr "" + +msgid "Bill" +msgstr "" + +msgid "Project" +msgstr "" + msgid "Too many failed login attempts, please retry later." msgstr "" @@ -156,6 +178,12 @@ msgstr "" msgid "Password successfully reset." msgstr "" +msgid "Project successfully uploaded" +msgstr "" + +msgid "Invalid JSON" +msgstr "" + msgid "Project successfully deleted" msgstr "" @@ -167,7 +195,7 @@ msgid "Your invitations have been sent" msgstr "" #, python-format -msgid "%(member)s had been added" +msgid "%(member)s has been added" msgstr "" #, python-format @@ -221,9 +249,6 @@ msgstr "?" msgid "Create a new project" msgstr "" -msgid "Project" -msgstr "" - msgid "Number of members" msgstr "" @@ -245,6 +270,9 @@ msgstr "" msgid "delete" msgstr "" +msgid "see" +msgstr "" + msgid "The Dashboard is currently deactivated." msgstr "" @@ -254,6 +282,12 @@ msgstr "" msgid "Edit project" msgstr "" +msgid "Import JSON" +msgstr "" + +msgid "Choose file" +msgstr "" + msgid "Download project's data" msgstr "" @@ -275,6 +309,9 @@ msgstr "" msgid "Cancel" msgstr "取消" +msgid "Privacy Settings" +msgstr "" + msgid "Edit the project" msgstr "" @@ -305,6 +342,177 @@ msgstr "" msgid "Download" msgstr "下载" +msgid "Disabled Project History" +msgstr "" + +msgid "Disabled Project History & IP Address Recording" +msgstr "" + +msgid "Enabled Project History" +msgstr "" + +msgid "Disabled IP Address Recording" +msgstr "" + +msgid "Enabled Project History & IP Address Recording" +msgstr "" + +msgid "Enabled IP Address Recording" +msgstr "" + +msgid "History Settings Changed" +msgstr "" + +msgid "changed" +msgstr "" + +msgid "from" +msgstr "" + +msgid "to" +msgstr "" + +msgid "Confirm Remove IP Adresses" +msgstr "" + +msgid "" +"Are you sure you want to delete all recorded IP addresses from this " +"project?\n" +" The rest of the project history will be unaffected. This " +"action cannot be undone." +msgstr "" + +msgid "Close" +msgstr "" + +msgid "Confirm Delete" +msgstr "" + +msgid "Delete Confirmation" +msgstr "" + +msgid "" +"Are you sure you want to erase all history for this project? This action " +"cannot be undone." +msgstr "" + +msgid "Added" +msgstr "" + +msgid "Removed" +msgstr "" + +msgid "and" +msgstr "" + +msgid "owers list" +msgstr "" + +msgid "Who?" +msgstr "谁?" + +msgid "Balance" +msgstr "" + +#, python-format +msgid "" +"\n" +" This project has history disabled. New actions won't " +"appear below. You can enable history on the\n" +" settings page\n" +" " +msgstr "" + +msgid "" +"\n" +" The table below reflects actions recorded prior to " +"disabling project history. You can\n" +" clear project history to remove " +"them.

\n" +" " +msgstr "" + +msgid "" +"Some entries below contain IP addresses, even though this project has IP " +"recording disabled. " +msgstr "" + +msgid "Delete stored IP addresses" +msgstr "" + +msgid "No history to erase" +msgstr "" + +msgid "Clear Project History" +msgstr "" + +msgid "No IP Addresses to erase" +msgstr "" + +msgid "Delete Stored IP Addresses" +msgstr "" + +msgid "Time" +msgstr "" + +msgid "Event" +msgstr "" + +msgid "IP address recording can be enabled on the settings page" +msgstr "" + +msgid "IP address recording can be disabled on the settings page" +msgstr "" + +msgid "From IP" +msgstr "" + +msgid "added" +msgstr "" + +msgid "Project private code changed" +msgstr "" + +msgid "Project renamed to" +msgstr "" + +msgid "Project contact email changed to" +msgstr "" + +msgid "Project settings modified" +msgstr "" + +msgid "deactivated" +msgstr "" + +msgid "reactivated" +msgstr "" + +msgid "renamed to" +msgstr "" + +msgid "External link changed to" +msgstr "" + +msgid "Amount" +msgstr "" + +msgid "modified" +msgstr "" + +msgid "removed" +msgstr "" + +msgid "changed in a unknown way" +msgstr "" + +msgid "Nothing to list" +msgstr "" + +msgid "Someone probably cleared the project history." +msgstr "" + msgid "Manage your shared
expenses, easily" msgstr "" @@ -352,6 +560,9 @@ msgstr "" msgid "Statistics" msgstr "" +msgid "History" +msgstr "" + msgid "Settings" msgstr "" @@ -409,6 +620,12 @@ msgstr "" msgid "Add a new bill" msgstr "" +msgid "Newer bills" +msgstr "" + +msgid "Older bills" +msgstr "" + msgid "When?" msgstr "什么时候?" @@ -435,9 +652,6 @@ msgstr "" msgid "each" msgstr "" -msgid "see" -msgstr "" - msgid "No bills" msgstr "" @@ -506,107 +720,14 @@ msgstr "" msgid "To whom?" msgstr "" -msgid "Who?" -msgstr "谁?" - msgid "Paid" msgstr "" msgid "Spent" msgstr "" -msgid "Balance" +msgid "Expenses by Month" msgstr "" -#~ msgid "" -#~ "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" -#~ msgstr "" - -#~ msgid "" -#~ "Not a valid amount or expression.Only" -#~ " numbers and + - * / " -#~ "operatorsare accepted." -#~ msgstr "" - -#~ msgid "What do you want to download ?" -#~ msgstr "你想下载什么?" - -#~ msgid "bills" -#~ msgstr "" - -#~ msgid "transactions" -#~ msgstr "" - -#~ msgid "Export file format" -#~ msgstr "" - -#~ msgid "Edit this project" -#~ msgstr "" - -#~ msgid "Download this project's data" -#~ msgstr "" - -#~ msgid "Type user name here" -#~ msgstr "" - -#~ msgid "No, thanks" -#~ msgstr "" - -#~ msgid "Manage your shared
expenses, easily" -#~ msgstr "" - -#~ msgid "Log to an existing project" -#~ msgstr "" - -#~ msgid "log in" -#~ msgstr "" - -#~ msgid "or create a new one" -#~ msgstr "" - -#~ msgid "let's get started" -#~ msgstr "" - -#~ msgid "options" -#~ msgstr "" - -#~ msgid "Project settings" -#~ msgstr "" - -#~ msgid "This is a free software" -#~ msgstr "" - -#~ msgid "Invite people to join this project!" -#~ msgstr "" - -#~ msgid "Added on" -#~ msgstr "" - -#~ msgid "Nothing to list yet. You probably want to" -#~ msgstr "" - -#~ msgid "" -#~ "Specify a (comma separated) list of " -#~ "email adresses you want to notify " -#~ "about the\n" -#~ "creation of this budget management " -#~ "project and we will send them an" -#~ " email for you." -#~ msgstr "" - -#~ msgid "" -#~ "If you prefer, you can share the project identifier and the shared\n" -#~ "password by other communication means. " -#~ "Or even directly share the following " -#~ "link:" -#~ msgstr "" - -#~ msgid "A link to reset your password has been sent to your email." -#~ msgstr "" +msgid "Period" +msgstr "" diff --git a/ihatemoney/utils.py b/ihatemoney/utils.py index 126b9dee..7fdad61a 100644 --- a/ihatemoney/utils.py +++ b/ihatemoney/utils.py @@ -1,19 +1,18 @@ -import re -import os import ast -import operator - -from io import BytesIO, StringIO - -import jinja2 -from json import dumps, JSONEncoder -from flask import redirect, current_app -from babel import Locale -from werkzeug.routing import HTTPException, RoutingException -from datetime import datetime, timedelta - - import csv +from datetime import datetime, timedelta +from enum import Enum +from io import BytesIO, StringIO +from json import JSONEncoder, dumps +import operator +import os +import re + +from babel import Locale +from flask import current_app, redirect, render_template +from flask_babel import get_locale +import jinja2 +from werkzeug.routing import HTTPException, RoutingException def slugify(value): @@ -99,7 +98,7 @@ def static_include(filename): def locale_from_iso(iso_code): - return Locale(iso_code) + return Locale.parse(iso_code) def list_of_dicts2json(dict_to_convert): @@ -257,3 +256,40 @@ def same_bill(bill1, bill2): if bill1[a] != bill2[a]: return False return True + + +class FormEnum(Enum): + """Extend builtin Enum class to be seamlessly compatible with WTForms""" + + @classmethod + def choices(cls): + return [(choice, choice.name) for choice in cls] + + @classmethod + def coerce(cls, item): + """Coerce a str or int representation into an Enum object""" + if isinstance(item, cls): + return item + + # If item is not already a Enum object then it must be + # a string or int corresponding to an ID (e.g. '0' or 1) + # Either int() or cls() will correctly throw a TypeError if this + # is not the case + return cls(int(item)) + + def __str__(self): + return str(self.value) + + +def render_localized_template(template_name_prefix, **context): + """Like render_template(), but selects the right template according to the + current user language. Fallback to English if a template for the + current language does not exist. + """ + fallback = "en" + templates = [ + f"{template_name_prefix}.{lang}.j2" + for lang in (get_locale().language, fallback) + ] + # render_template() supports a list of templates to try in order + return render_template(templates, **context) diff --git a/ihatemoney/versioning.py b/ihatemoney/versioning.py new file mode 100644 index 00000000..50ad6ec8 --- /dev/null +++ b/ihatemoney/versioning.py @@ -0,0 +1,94 @@ +from flask import g +from sqlalchemy.orm.attributes import get_history +from sqlalchemy_continuum import VersioningManager +from sqlalchemy_continuum.plugins.flask import fetch_remote_addr + +from ihatemoney.utils import FormEnum + + +class LoggingMode(FormEnum): + """Represents a project's history preferences.""" + + DISABLED = 0 + ENABLED = 1 + RECORD_IP = 2 + + @classmethod + def default(cls): + return cls.ENABLED + + +class ConditionalVersioningManager(VersioningManager): + """Conditionally enable version tracking based on the given predicate.""" + + def __init__(self, tracking_predicate, *args, **kwargs): + """Create version entry iff tracking_predicate() returns True.""" + super().__init__(*args, **kwargs) + self.tracking_predicate = tracking_predicate + + def before_flush(self, session, flush_context, instances): + if self.tracking_predicate(): + return super().before_flush(session, flush_context, instances) + else: + # At least one call to unit_of_work() needs to be made against the + # session object to prevent a KeyError later. This doesn't create + # a version or transaction entry + self.unit_of_work(session) + + def after_flush(self, session, flush_context): + if self.tracking_predicate(): + return super().after_flush(session, flush_context) + else: + # At least one call to unit_of_work() needs to be made against the + # session object to prevent a KeyError later. This doesn't create + # a version or transaction entry + self.unit_of_work(session) + + +def version_privacy_predicate(): + """Evaluate if the project of the current session has enabled logging.""" + logging_enabled = False + try: + if g.project.logging_preference != LoggingMode.DISABLED: + logging_enabled = True + + # If logging WAS enabled prior to this transaction, + # we log this one last transaction + old_logging_mode = get_history(g.project, "logging_preference")[2] + if old_logging_mode and old_logging_mode[0] != LoggingMode.DISABLED: + logging_enabled = True + except AttributeError: + # g.project doesn't exist, it's being created or this action is outside + # the scope of a project. Use the default logging mode to decide + if LoggingMode.default() != LoggingMode.DISABLED: + logging_enabled = True + return logging_enabled + + +def get_ip_if_allowed(): + """ + Get the remote address (IP address) of the current Flask context, if the + project's privacy settings allow it. Behind the scenes, this calls back to + the FlaskPlugin from SQLAlchemy-Continuum in order to maintain forward + compatibility + """ + ip_logging_allowed = False + try: + if g.project.logging_preference == LoggingMode.RECORD_IP: + ip_logging_allowed = True + + # If ip recording WAS enabled prior to this transaction, + # we record the IP for this one last transaction + old_logging_mode = get_history(g.project, "logging_preference")[2] + if old_logging_mode and old_logging_mode[0] == LoggingMode.RECORD_IP: + ip_logging_allowed = True + except AttributeError: + # g.project doesn't exist, it's being created or this action is outside + # the scope of a project. Use the default logging mode to decide + if LoggingMode.default() == LoggingMode.RECORD_IP: + ip_logging_allowed = True + + if ip_logging_allowed: + return fetch_remote_addr() + else: + return None diff --git a/ihatemoney/web.py b/ihatemoney/web.py index 1b80ab62..bbc98c4d 100644 --- a/ihatemoney/web.py +++ b/ihatemoney/web.py @@ -8,55 +8,59 @@ Basically, this blueprint takes care of the authentication and provides some shortcuts to make your life better when coding (see `pull_project` and `add_project_id` for a quick overview) """ +from datetime import datetime +from functools import wraps import json import os -from functools import wraps from smtplib import SMTPRecipientsRefused from dateutil.parser import parse +from dateutil.relativedelta import relativedelta from flask import ( - abort, Blueprint, + abort, current_app, flash, g, redirect, render_template, request, - session, - url_for, send_file, send_from_directory, + session, + url_for, ) -from flask_babel import get_locale, gettext as _ +from flask_babel import gettext as _ from flask_mail import Message from sqlalchemy import orm +from sqlalchemy_continuum import Operation from werkzeug.exceptions import NotFound from werkzeug.security import check_password_hash, generate_password_hash +from ihatemoney.currency_convertor import CurrencyConverter from ihatemoney.forms import ( AdminAuthenticationForm, AuthenticationForm, - EditProjectForm, InviteForm, MemberForm, PasswordReminder, - ResetPasswordForm, ProjectForm, - get_billform_for, + ResetPasswordForm, UploadForm, + get_billform_for, + get_editprojectform_for, ) -from ihatemoney.models import db, Project, Person, Bill +from ihatemoney.history import get_history, get_history_queries +from ihatemoney.models import Bill, LoggingMode, Person, Project, db from ihatemoney.utils import ( - Redirect303, - list_of_dicts2json, - list_of_dicts2csv, LoginThrottler, + Redirect303, get_members, + list_of_dicts2csv, + list_of_dicts2json, + render_localized_template, same_bill, ) -from datetime import datetime -from dateutil.relativedelta import relativedelta main = Blueprint("main", __name__) @@ -235,6 +239,8 @@ def authenticate(project_id=None): # add the project on the top of the list session["projects"].insert(0, (project_id, project.name)) session[project_id] = True + # Set session to permanent to make language choice persist + session.permanent = True session.update() setattr(g, "project", project) return redirect(url_for(".list_bills")) @@ -297,9 +303,7 @@ def create_project(): project=g.project.name, ) - message_body = render_template( - "reminder_mail.%s.j2" % get_locale().language - ) + message_body = render_localized_template("reminder_mail") msg = Message( message_title, body=message_body, recipients=[project.contact_email] @@ -307,19 +311,10 @@ def create_project(): try: current_app.mail.send(msg) except SMTPRecipientsRefused: - msg_compl = "Problem sending mail. " - # TODO: destroy the project and cancel instead? - else: - msg_compl = "" + flash(_("Error while sending reminder email"), category="danger") # redirect the user to the next step (invite) - flash( - _( - "%(msg_compl)sThe project identifier is %(project)s", - msg_compl=msg_compl, - project=project.id, - ) - ) + flash(_("The project identifier is %(project)s", project=project.id)) return redirect(url_for(".list_bills", project_id=project.id)) return render_template("create_project.html", form=form) @@ -333,11 +328,12 @@ def remind_password(): # get the project project = Project.query.get(form.id.data) # send a link to reset the password - password_reminder = "password_reminder.%s.j2" % get_locale().language current_app.mail.send( Message( "password recovery", - body=render_template(password_reminder, project=project), + body=render_localized_template( + "password_reminder", project=project + ), recipients=[project.contact_email], ) ) @@ -381,37 +377,55 @@ def reset_password(): @main.route("//edit", methods=["GET", "POST"]) def edit_project(): - edit_form = EditProjectForm() - if request.method == "POST": - if edit_form.validate(): - project = edit_form.update(g.project) - db.session.add(project) - db.session.commit() + edit_form = get_editprojectform_for(g.project) + import_form = UploadForm() + # Import form + if import_form.validate_on_submit(): + try: + import_project(import_form.file.data.stream, g.project) + flash(_("Project successfully uploaded")) - return redirect(url_for(".list_bills")) + return redirect(url_for("main.list_bills")) + except ValueError: + flash(_("Invalid JSON"), category="danger") + + # Edit form + if edit_form.validate_on_submit(): + project = edit_form.update(g.project) + # Update converted currency + if project.default_currency != CurrencyConverter.default: + for bill in project.get_bills(): + + if bill.original_currency == CurrencyConverter.default: + bill.original_currency = project.default_currency + + bill.converted_amount = CurrencyConverter().exchange_currency( + bill.amount, bill.original_currency, project.default_currency + ) + db.session.add(bill) + + db.session.add(project) + db.session.commit() + + return redirect(url_for("main.list_bills")) else: edit_form.name.data = g.project.name + + if g.project.logging_preference != LoggingMode.DISABLED: + edit_form.project_history.data = True + if g.project.logging_preference == LoggingMode.RECORD_IP: + edit_form.ip_recording.data = True + edit_form.contact_email.data = g.project.contact_email return render_template( - "edit_project.html", edit_form=edit_form, current_view="edit_project" + "edit_project.html", + edit_form=edit_form, + import_form=import_form, + current_view="edit_project", ) -@main.route("//upload_json", methods=["GET", "POST"]) -def upload_json(): - form = UploadForm() - if form.validate_on_submit(): - try: - import_project(form.file.data.stream, g.project) - flash(_("Project successfully uploaded")) - except ValueError: - flash(_("Invalid JSON"), category="error") - return redirect(url_for("main.list_bills")) - - return render_template("upload_json.html", form=form) - - def import_project(file, project): json_file = json.load(file) @@ -477,6 +491,7 @@ def import_project(file, project): form.date = parse(b["date"]) form.payer = id_dict[b["payer_name"]] form.payed_for = owers_id + form.original_currency = b.get("original_currency") db.session.add(form.fake_form(bill, project)) @@ -510,7 +525,7 @@ def export_project(file, format): return send_file( file2export, - attachment_filename="%s-%s.%s" % (g.project.id, file, format), + attachment_filename=f"{g.project.id}-{file}.{format}", as_attachment=True, ) @@ -542,6 +557,7 @@ def demo(): name="demonstration", password=generate_password_hash("demo"), contact_email="demo@notmyidea.org", + default_currency="EUR", ) db.session.add(project) db.session.commit() @@ -558,11 +574,7 @@ def invite(): if request.method == "POST": if form.validate(): # send the email - - message_body = render_template( - "invitation_mail.%s.j2" % get_locale().language - ) - + message_body = render_localized_template("invitation_mail") message_title = _( "You have been invited to share your " "expenses for %(project)s", project=g.project.name, @@ -610,7 +622,7 @@ def add_member(): if form.validate(): member = form.save(g.project, Person()) db.session.commit() - flash(_("%(member)s had been added", member=member.name)) + flash(_("%(member)s has been added", member=member.name)) return redirect(url_for(".list_bills")) return render_template("add_member.html", form=form) @@ -740,6 +752,45 @@ def settle_bill(): return render_template("settle_bills.html", bills=bills, current_view="settle_bill") +@main.route("//history") +def history(): + """Query for the version entries associated with this project.""" + history = get_history(g.project, human_readable_names=True) + + any_ip_addresses = any(event["ip"] for event in history) + + return render_template( + "history.html", + current_view="history", + history=history, + any_ip_addresses=any_ip_addresses, + LoggingMode=LoggingMode, + OperationType=Operation, + current_log_pref=g.project.logging_preference, + ) + + +@main.route("//erase_history", methods=["POST"]) +def erase_history(): + """Erase all history entries associated with this project.""" + for query in get_history_queries(g.project): + query.delete(synchronize_session="fetch") + + db.session.commit() + return redirect(url_for(".history")) + + +@main.route("//strip_ip_addresses", methods=["POST"]) +def strip_ip_addresses(): + """Strip ip addresses from history entries associated with this project.""" + for query in get_history_queries(g.project): + for version_object in query.all(): + version_object.transaction.remote_addr = None + + db.session.commit() + return redirect(url_for(".history")) + + @main.route("//statistics") def statistics(): """Compute what each member has paid and spent and display it""" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 27280428..00000000 --- a/requirements.txt +++ /dev/null @@ -1,27 +0,0 @@ -alembic==1.2.0 -aniso8601==8.0.0 -Babel==2.7.0 -blinker==1.4 -Click==7.0 -debts==0.4 -dnspython==1.16.0 -email-validator==1.0.4 -Flask==1.1.1 -Flask-Babel==0.12.2 -Flask-Cors==3.0.8 -Flask-Mail==0.9.1 -Flask-Migrate==2.5.2 -Flask-RESTful==0.3.7 -Flask-Script==2.0.6 -Flask-SQLAlchemy==2.4.1 -Flask-WTF==0.14.2 -idna==2.8 -itsdangerous==1.1.0 -Jinja2==2.10.1 -Mako==1.1.0 -MarkupSafe==1.1.1 -python-dateutil==2.8.0 -pytz==2019.2 -SQLAlchemy==1.3.8 -Werkzeug==0.16.0 -WTForms==2.2.1 diff --git a/setup.cfg b/setup.cfg index b62a3f57..d632d515 100644 --- a/setup.cfg +++ b/setup.cfg @@ -11,9 +11,9 @@ license = Custom BSD Beerware classifiers = Programming Language :: Python Programming Language :: Python :: 3 - Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 Topic :: Internet :: WWW/HTTP Topic :: Internet :: WWW/HTTP :: WSGI :: Application @@ -22,28 +22,34 @@ packages = find: include_package_data = True zip_safe = False install_requires = - flask - flask-wtf - flask-sqlalchemy<3.0 - flask-mail - Flask-Migrate - Flask-script - flask-babel - flask-restful - jinja2 - blinker - flask-cors - itsdangerous - email_validator - debts + blinker==1.4 + cachetools==4.1.0 + debts==0.5 + email_validator==1.0.5 + Flask-Babel==1.0.0 + Flask-Cors==3.0.8 + Flask-Mail==0.9.1 + Flask-Migrate==2.5.3 + Flask-RESTful==0.3.8 + Flask-Script==2.0.6 + Flask-SQLAlchemy==2.4.1 + Flask-WTF==0.14.3 + WTForms==2.2.1 + Flask==1.1.2 + itsdangerous==1.1.0 + Jinja2==2.11.2 + requests==2.22.0 + SQLAlchemy-Continuum==1.3.9 [options.extras_require] dev = - zest.releaser - tox - pytest - flake8 - Flask-Testing + black==19.10b0 ; python_version >= '3.6' + flake8==3.7.9 + Flask-Testing==0.8.0 + isort==4.3.21 + pytest==5.4.1 + tox==3.14.6 + zest.releaser==6.20.1 [options.entry_points] console_scripts = diff --git a/tox.ini b/tox.ini index 5e6d5e42..372f60f9 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py37,py36,py35,docs,flake8,black +envlist = py38,py37,py36,docs,flake8,black skip_missing_interpreters = True [testenv] @@ -9,8 +9,7 @@ commands = py.test --pyargs ihatemoney.tests.tests deps = - -rdev-requirements.txt - -rrequirements.txt + -e.[dev] # To be sure we are importing ihatemoney pkg from pip-installed version changedir = /tmp @@ -22,15 +21,13 @@ deps = changedir = {toxinidir} [testenv:black] -commands = black --check --target-version=py34 . -deps = - -rdev-requirements.txt +commands = + black --check --target-version=py34 . + isort -c -rc . changedir = {toxinidir} [testenv:flake8] commands = flake8 ihatemoney -deps = - -rdev-requirements.txt changedir = {toxinidir} [flake8] @@ -42,6 +39,6 @@ extend-ignore = [travis] python = - 3.5: py35 - 3.6: py36, docs, black, flake8 + 3.6: py36 3.7: py37 + 3.8: py38, docs, black, flake8
{{ _("Who?") }}{{ _("Paid") }}{{ _("Spent") }}
{{ stat.member.name }} {{ "%0.2f"|format(stat.paid) }}' - indent = "\n " - self.assertIn( - first_cell - + "alexis20.0031.67{}{}{}20.005.830.002.500.000.00{}{} -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --