Merge branch 'master' into Glandos-patch-1

This commit is contained in:
Glandos 2021-10-14 21:58:07 +02:00 committed by GitHub
commit cea50022c2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 3140 additions and 381 deletions

52
.github/workflows/dockerhub.yml vendored Normal file
View file

@ -0,0 +1,52 @@
name: CI to Docker Hub
on:
push:
tags:
- '*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Cache Docker layers
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v3
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPOSITORY }}
- uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
with:
context: ./
file: ./Dockerfile
builder: ${{ steps.buildx.outputs.name }}
push: true
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}

View file

@ -9,11 +9,12 @@ This document describes changes between each past release.
Breaking changes Breaking changes
---------------- ----------------
- Include project code into project authentication token. This invalidates all existing API tokens and invitation links from previous versions (#802)
- Drop support for Python 2 (#483) - Drop support for Python 2 (#483)
- Drop support for Python 3.5 (#571) - Drop support for Python 3.5 (#571)
- Drop support for MySQL (#743) - Drop support for MySQL (#743)
- Require MariaDB version 10.3.2 or above (#632) - Require MariaDB version 10.3.2 or above (#632)
- Authentication page URL needs a `project_id` parameter (#802) - Enable session cookie security by default (#845)
The minimum supported version is now Python 3.6, and the project is tested The minimum supported version is now Python 3.6, and the project is tested
with up to Python 3.9 with up to Python 3.9
@ -26,6 +27,7 @@ Security
- Add CSRF validation on destructive actions (#796) - Add CSRF validation on destructive actions (#796)
- Ask for private code to delete project or project history (#796) - Ask for private code to delete project or project history (#796)
- Add headers to mitigate Clickjacking, XSS, and other attacks: `X-Frame-Options`, `X-XSS-Protection`, `X-Content-Type-Options`, `Content-Security-Policy`, `Referrer-Policy` (#845)
Added Added
----- -----
@ -38,10 +40,11 @@ Added
- Add sorting, pagination, and searching to the admin dashboard (#538) - Add sorting, pagination, and searching to the admin dashboard (#538)
- Add Project History page that records all changes (#553) - Add Project History page that records all changes (#553)
- Add token-based authentication to the API (#504) - Add token-based authentication to the API (#504)
- Add translations for Hindi, Portuguese (Brazil), Tamil
- Add illustrations as a showcase, currently only for French (#544) - Add illustrations as a showcase, currently only for French (#544)
- Add a page for downloading mobile application (#688) - Add a page for downloading mobile application (#688)
- Add optional support for a simple CAPTCHA (#844)
- Add translations for Greek, Esperanto, Italian, Japanese, Portuguese and Swedish - Add translations for Greek, Esperanto, Italian, Japanese, Portuguese and Swedish
- Publish an `official docker image <https://hub.docker.com/r/ihatemoney/ihatemoney>`_
Changed Changed
------- -------
@ -51,6 +54,7 @@ Changed
- Make language choice persistent (#547) - Make language choice persistent (#547)
- Localize date strings in the current language (#590) - Localize date strings in the current language (#590)
- Differenciate "flash alerts" notifications (#594) - Differenciate "flash alerts" notifications (#594)
- Display "flash messages" persistently instead of making them disappear (#856)
- Improve menu bar spacing, put history and settings in a submenu (#739) - Improve menu bar spacing, put history and settings in a submenu (#739)
- Change Dockerfile to install python dependencies at build time (#793) - Change Dockerfile to install python dependencies at build time (#793)
- Updating project settings doesn't require to enter or update project code (#774) - Updating project settings doesn't require to enter or update project code (#774)

View file

@ -29,8 +29,8 @@ Glandos
Heimen Stoffels Heimen Stoffels
James Leong James Leong
Jocelyn Delalande Jocelyn Delalande
Lucas Verney
Luc Didry Luc Didry
Lucas Verney
Marien Fressinaud Marien Fressinaud
Mathieu Leplatre Mathieu Leplatre
mcnesium mcnesium
@ -44,6 +44,7 @@ Richard Coates
THANOS SIOURDAKIS THANOS SIOURDAKIS
Toover Toover
Xavier Mehrenberger Xavier Mehrenberger
Youe Graillot
zorun zorun
The manual drawings are from Coline Billon, they are under CC BY 4.0. The manual drawings are from Coline Billon, they are under CC BY 4.0.

View file

@ -21,6 +21,7 @@ ADMIN_PASSWORD = '$ADMIN_PASSWORD'
ALLOW_PUBLIC_PROJECT_CREATION = $ALLOW_PUBLIC_PROJECT_CREATION ALLOW_PUBLIC_PROJECT_CREATION = $ALLOW_PUBLIC_PROJECT_CREATION
ACTIVATE_ADMIN_DASHBOARD = $ACTIVATE_ADMIN_DASHBOARD ACTIVATE_ADMIN_DASHBOARD = $ACTIVATE_ADMIN_DASHBOARD
BABEL_DEFAULT_TIMEZONE = "$BABEL_DEFAULT_TIMEZONE" BABEL_DEFAULT_TIMEZONE = "$BABEL_DEFAULT_TIMEZONE"
SESSION_COOKIE_SECURE = $SESSION_COOKIE_SECURE
EOF EOF
# Start gunicorn without forking # Start gunicorn without forking

View file

@ -4,7 +4,9 @@ The REST API
All of what's possible to do with the website is also possible via a web API. All of what's possible to do with the website is also possible via a web API.
This document explains how the API is organized and how you can query it. This document explains how the API is organized and how you can query it.
The only supported data format is JSON. The main supported data format is JSON. When using POST or PUT, you can
either pass data encoded in JSON or in ``application/x-www-form-urlencoded``
format.
Overall organisation Overall organisation
==================== ====================
@ -31,7 +33,7 @@ instead of basic auth.
For instance, start by generating the token (of course, you need to authenticate):: For instance, start by generating the token (of course, you need to authenticate)::
$ curl --basic -u demo:demo https://ihatemoney.org/api/projects/demo/token $ curl --basic -u demo:demo https://ihatemoney.org/api/projects/demo/token
{"token": "eyJwcm9qZWN0X2lkIjoiZGVtbyJ9.M86C3AiZa_SFEyiddYXdTh2-OOI"} {"token": "WyJ0ZXN0Il0.Rt04fNMmxp9YslCRq8hB6jE9s1Q"}
Make sure to store this token securely: it allows full access to the project. Make sure to store this token securely: it allows full access to the project.
For instance, use it to obtain information about the project (replace PROJECT_TOKEN with For instance, use it to obtain information about the project (replace PROJECT_TOKEN with
@ -47,7 +49,7 @@ looks like::
This token can also be used to authenticate for a project on the web interface, which can be useful This token can also be used to authenticate for a project on the web interface, which can be useful
to generate invitation links. You would simply create an URL of the form:: to generate invitation links. You would simply create an URL of the form::
https://ihatemoney.org/authenticate?token=PROJECT_TOKEN https://ihatemoney.org/demo/join/PROJECT_TOKEN
Such a link grants full access to the project associated with the token. Such a link grants full access to the project associated with the token.
@ -64,10 +66,16 @@ Creating a project
A project needs the following arguments: A project needs the following arguments:
* ``name``: The project name (string) * ``name``: the project name (string)
* ``id``: the project identifier (string without special chars or spaces) * ``id``: the project identifier (string without special chars or spaces)
* ``password``: the project password / secret code (string) * ``password``: the project password / secret code (string)
* ``contact_email``: the contact email * ``contact_email``: the contact email (string)
Optional arguments:
* ``default_currency``: the default currency to use for a multi-currency project,
in ISO 4217 format. Bills are converted to this currency for operations like balance
or statistics. Default value: ``XXX`` (no currency).
:: ::
@ -75,7 +83,7 @@ A project needs the following arguments:
-d 'name=yay&id=yay&password=yay&contact_email=yay@notmyidea.org' -d 'name=yay&id=yay&password=yay&contact_email=yay@notmyidea.org'
"yay" "yay"
As you can see, the API returns the identifier of the project As you can see, the API returns the identifier of the project.
Getting information about the project Getting information about the project
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -88,6 +96,7 @@ Getting information about the project::
"id": "demo", "id": "demo",
"name": "demonstration", "name": "demonstration",
"contact_email": "demo@notmyidea.org", "contact_email": "demo@notmyidea.org",
"default_currency": "XXX",
"members": [{"id": 11515, "name": "f", "weight": 1.0, "activated": true, "balance": 0}, "members": [{"id": 11515, "name": "f", "weight": 1.0, "activated": true, "balance": 0},
{"id": 11531, "name": "g", "weight": 1.0, "activated": true, "balance": 0}, {"id": 11531, "name": "g", "weight": 1.0, "activated": true, "balance": 0},
{"id": 11532, "name": "peter", "weight": 1.0, "activated": true, "balance": 5.0}, {"id": 11532, "name": "peter", "weight": 1.0, "activated": true, "balance": 5.0},
@ -151,22 +160,55 @@ You can get the list of bills by doing a ``GET`` on
$ curl --basic -u demo:demo https://ihatemoney.org/api/projects/demo/bills $ curl --basic -u demo:demo https://ihatemoney.org/api/projects/demo/bills
Add a bill with a ``POST`` query on ``/api/projects/<id>/bills``. you need the Or get a specific bill by ID::
following params:
* ``date``: the date of the bill; defaults to current date if not $ curl --basic -u demo:demo https://ihatemoney.org/api/projects/demo/bills/42
provided. (format is ``yyyy-mm-dd``) {
* ``what``: what have been payed "id": 42,
* ``payer``: by who ? (id) "payer_id": 11,
* ``payed_for``: for who ? (id, to set multiple id use a list, "owers": [
e.g. ``["id1", "id2"]``) {
* ``amount``: amount payed "id": 22,
"name": "Alexis",
"weight": 1,
"activated": true
}
],
"amount": 100,
"date": "2020-12-24",
"creation_date": "2021-01-13",
"what": "Raclette du nouvel an",
"external_link": "",
"original_currency": "XXX",
"converted_amount": 100
}
``amount`` is expressed in the ``original_currency`` of the bill, while
``converted_amount`` is expressed in the project ``default_currency``.
Here, they are the same.
Add a bill with a ``POST`` query on ``/api/projects/<id>/bills``. You need the
following required parameters:
* ``what``: what has been paid (string)
* ``payer``: paid by who? (id)
* ``payed_for``: for who ? (id). To set multiple id, simply pass
the parameter multiple times (x-www-form-urlencoded) or pass a list of id (JSON).
* ``amount``: amount payed (float)
And optional parameters:
* ``date``: the date of the bill (``yyyy-mm-dd`` format). Defaults to current date
if not provided.
* ``original_currency``: the currency in which ``amount`` has been paid (ISO 4217 code).
Only makes sense for a project with currencies. Defaults to the project ``default_currency``.
* ``external_link``: an optional URL associated with the bill.
Returns the id of the created bill :: Returns the id of the created bill ::
$ curl --basic -u demo:demo -X POST\ $ curl --basic -u demo:demo -X POST\
https://ihatemoney.org/api/projects/demo/bills\ https://ihatemoney.org/api/projects/demo/bills\
-d "date=2011-09-10&what=raclette&payer=31&payed_for=31&amount=200" -d "date=2011-09-10&what=raclette&payer=1&payed_for=3&payed_for=5&amount=200"
80 80
You can also ``PUT`` a new version of the bill at You can also ``PUT`` a new version of the bill at
@ -174,7 +216,7 @@ You can also ``PUT`` a new version of the bill at
$ curl --basic -u demo:demo -X PUT\ $ curl --basic -u demo:demo -X PUT\
https://ihatemoney.org/api/projects/demo/bills/80\ https://ihatemoney.org/api/projects/demo/bills/80\
-d "date=2011-09-10&what=raclette&payer=31&payed_for=31&amount=250" -d "date=2011-09-10&what=raclette&payer=1&payed_for=3&payed_for=5&payed_for=1&amount=250"
80 80
And you can of course ``DELETE`` them at And you can of course ``DELETE`` them at
@ -194,15 +236,15 @@ You can get some project stats with a ``GET`` on
$ curl --basic -u demo:demo https://ihatemoney.org/api/projects/demo/statistics $ curl --basic -u demo:demo https://ihatemoney.org/api/projects/demo/statistics
[ [
{ {
"balance": 12.5, "member": {"activated": true, "id": 1, "name": "alexis", "weight": 1.0},
"member": {"activated": True, "id": 1, "name": "alexis", "weight": 1.0}, "paid": 25.5,
"paid": 25.0, "spent": 15,
"spent": 12.5 "balance": 10.5
}, },
{ {
"balance": -12.5, "member": {"activated": true, "id": 2, "name": "fred", "weight": 1.0},
"member": {"activated": True, "id": 2, "name": "fred", "weight": 1.0}, "paid": 5,
"paid": 0, "spent": 15.5,
"spent": 12.5 "balance": -10.5
} }
] ]

View file

@ -13,6 +13,21 @@ To know defaults on your deployed instance, simply look at your
"Production values" are the recommended values for use in production. "Production values" are the recommended values for use in production.
Configuration files
-------------------
By default, Ihatemoney loads its configuration from ``/etc/ihatemoney/ihatemoney.cfg``.
If you need to load the configuration from a custom path, you can define the
``IHATEMONEY_SETTINGS_FILE_PATH`` environment variable with the path to the configuration
file.
For instance ::
export IHATEMONEY_SETTINGS_FILE_PATH="/path/to/your/conf/file.cfg"
The path should be absolute. A relative path will be interpreted as being
inside ``/etc/ihatemoney/``.
`SQLALCHEMY_DATABASE_URI` `SQLALCHEMY_DATABASE_URI`
------------------------- -------------------------
@ -49,6 +64,21 @@ of the secret key could easily access any project and bypass the private code ve
- **Production value:** `ihatemoney conf-example ihatemoney.cfg` sets it to - **Production value:** `ihatemoney conf-example ihatemoney.cfg` sets it to
something random, which is good. something random, which is good.
`SESSION_COOKIE_SECURE`
-----------------------
A boolean that controls whether the session cookie will be marked "secure".
If this is the case, browsers will refuse to send the session cookie over plain HTTP.
- **Default value:** ``True``
- **Production value:** ``True`` if you run your service over HTTPS, ``False`` if you run
your service over plain HTTP.
Note: this setting is actually interpreted by Flask, see the
`Flask documentation`_ for details.
.. _Flask documentation: https://flask.palletsprojects.com/en/2.0.x/config/#SESSION_COOKIE_SECURE
`MAIL_DEFAULT_SENDER` `MAIL_DEFAULT_SENDER`
--------------------- ---------------------
@ -125,6 +155,11 @@ Note: this setting is actually interpreted by Flask-Babel, see the
.. _Flask-Babel guide for formatting dates: https://pythonhosted.org/Flask-Babel/#formatting-dates .. _Flask-Babel guide for formatting dates: https://pythonhosted.org/Flask-Babel/#formatting-dates
`ENABLE_CAPTCHA`
---------------
It is possible to add a simple captcha in order to filter out spammer bots on the form creation.
In order to do so, you just have to set `ENABLE_CAPTCHA = True`.
Configuring emails sending Configuring emails sending
-------------------------- --------------------------
@ -142,12 +177,3 @@ possible to configure it to act differently, thanks to the great
* **MAIL_PASSWORD** : default **None** * **MAIL_PASSWORD** : default **None**
* **DEFAULT_MAIL_SENDER** : default **None** * **DEFAULT_MAIL_SENDER** : default **None**
Using an alternate settings path
--------------------------------
You can put your settings file where you want, and pass its path to the
application using the ``IHATEMONEY_SETTINGS_FILE_PATH`` environment variable.
For instance ::
export IHATEMONEY_SETTINGS_FILE_PATH="/path/to/your/conf/file.cfg"

View file

@ -104,12 +104,20 @@ You can create a ``settings.cfg`` file, with the following content::
DEBUG = True DEBUG = True
SQLACHEMY_ECHO = DEBUG SQLACHEMY_ECHO = DEBUG
You can also set the `TESTING` flag to `True` so no mails are sent
(and no exception is raised) while you're on development mode.
Then before running the application, declare its path with :: Then before running the application, declare its path with ::
export IHATEMONEY_SETTINGS_FILE_PATH="$(pwd)/settings.cfg" export IHATEMONEY_SETTINGS_FILE_PATH="$(pwd)/settings.cfg"
You can also set the ``TESTING`` flag to ``True`` so no mails are sent
(and no exception is raised) while you're on development mode.
In some cases, you may need to disable secure cookies by setting
``SESSION_COOKIE_SECURE`` to ``False``. This is needed if you
access your dev server over the network: with the default value
of ``SESSION_COOKIE_SECURE``, the browser will refuse to send
the session cookie over insecure HTTP, so many features of Ihatemoney
won't work (project login, language change, etc).
.. _contributing-developer: .. _contributing-developer:
Contributing as a developer Contributing as a developer

View file

@ -65,6 +65,17 @@ If so, pick the ``pip`` commands to use in the relevant section(s) of
Then follow :ref:`general-procedure` from step 1. in order to complete the update. Then follow :ref:`general-procedure` from step 1. in order to complete the update.
Disable session cookie security if running over plain HTTP
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
.. note:: If you are running Ihatemoney over HTTPS, no special action is required.
Session cookies are now marked "secure" by default to increase security.
If you run Ihatemoney over plain HTTP, you need to explicitly disable this security
feature by setting ``SESSION_COOKIE_SECURE`` to ``False``, see :ref:`configuration`.
Switch to MariaDB >= 10.3.2 instead of MySQL Switch to MariaDB >= 10.3.2 instead of MySQL
++++++++++++++++++++++++++++++++++++++++++++ ++++++++++++++++++++++++++++++++++++++++++++

View file

@ -35,7 +35,9 @@ def need_auth(f):
auth_token = auth_header.split(" ")[1] auth_token = auth_header.split(" ")[1]
except IndexError: except IndexError:
abort(401) abort(401)
project_id = Project.verify_token(auth_token, token_type="non_timed_token") project_id = Project.verify_token(
auth_token, token_type="auth", project_id=project_id
)
if auth_token and project_id: if auth_token and project_id:
project = Project.query.get(project_id) project = Project.query.get(project_id)
if project: if project:

View file

@ -38,3 +38,11 @@ ACTIVATE_ADMIN_DASHBOARD = False
# You can change the timezone used to display time. By default it will be # You can change the timezone used to display time. By default it will be
#derived from the server OS. #derived from the server OS.
#BABEL_DEFAULT_TIMEZONE = "Europe/Paris" #BABEL_DEFAULT_TIMEZONE = "Europe/Paris"
# Enable secure cookies. Requires HTTPS. Disable if you run your ihatemoney
# service over plain HTTP.
SESSION_COOKIE_SECURE = True
# You can activate an optional CAPTCHA if you want to. It can be helpful
# to filter spammer bots.
# ENABLE_CAPTCHA = True

View file

@ -8,6 +8,7 @@ ACTIVATE_DEMO_PROJECT = True
ADMIN_PASSWORD = "" ADMIN_PASSWORD = ""
ALLOW_PUBLIC_PROJECT_CREATION = True ALLOW_PUBLIC_PROJECT_CREATION = True
ACTIVATE_ADMIN_DASHBOARD = False ACTIVATE_ADMIN_DASHBOARD = False
SESSION_COOKIE_SECURE = True
SUPPORTED_LANGUAGES = [ SUPPORTED_LANGUAGES = [
"de", "de",
"el", "el",
@ -31,3 +32,4 @@ SUPPORTED_LANGUAGES = [
"uk", "uk",
"zh_Hans", "zh_Hans",
] ]
ENABLE_CAPTCHA = False

View file

@ -13,6 +13,7 @@ from wtforms.fields.core import Label, SelectField, SelectMultipleField
from wtforms.fields.html5 import DateField, DecimalField, URLField from wtforms.fields.html5 import DateField, DecimalField, URLField
from wtforms.fields.simple import BooleanField, PasswordField, StringField, SubmitField from wtforms.fields.simple import BooleanField, PasswordField, StringField, SubmitField
from wtforms.validators import ( from wtforms.validators import (
URL,
DataRequired, DataRequired,
Email, Email,
EqualTo, EqualTo,
@ -112,7 +113,11 @@ class EditProjectForm(FlaskForm):
project_history = BooleanField(_("Enable project history")) project_history = BooleanField(_("Enable project history"))
ip_recording = BooleanField(_("Use IP tracking for project history")) ip_recording = BooleanField(_("Use IP tracking for project history"))
currency_helper = CurrencyConverter() currency_helper = CurrencyConverter()
default_currency = SelectField(_("Default Currency"), validators=[DataRequired()]) default_currency = SelectField(
_("Default Currency"),
validators=[DataRequired()],
default=CurrencyConverter.no_currency,
)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
if not hasattr(self, "id"): if not hasattr(self, "id"):
@ -220,6 +225,19 @@ class ProjectForm(EditProjectForm):
) )
raise ValidationError(Markup(message)) raise ValidationError(Markup(message))
@classmethod
def enable_captcha(cls):
captchaField = StringField(
_("Which is a real currency: Euro or Petro dollar?"),
validators=[DataRequired()],
)
setattr(cls, "captcha", captchaField)
def validate_captcha(form, field):
if not field.data.lower() == _("euro"):
message = _("Please, validate the captcha to proceed.")
raise ValidationError(Markup(message))
class DestructiveActionProjectForm(FlaskForm): class DestructiveActionProjectForm(FlaskForm):
"""Used for any important "delete" action linked to a project: """Used for any important "delete" action linked to a project:
@ -292,7 +310,7 @@ class BillForm(FlaskForm):
original_currency = SelectField(_("Currency"), validators=[DataRequired()]) original_currency = SelectField(_("Currency"), validators=[DataRequired()])
external_link = URLField( external_link = URLField(
_("External link"), _("External link"),
validators=[Optional()], validators=[Optional(), URL()],
description=_("A link to an external document, related to this bill"), description=_("A link to an external document, related to this bill"),
) )
payed_for = SelectMultipleField( payed_for = SelectMultipleField(
@ -321,7 +339,7 @@ class BillForm(FlaskForm):
bill.external_link = "" bill.external_link = ""
bill.date = self.date bill.date = self.date
bill.owers = [Person.query.get(ower, project) for ower in self.payed_for] bill.owers = [Person.query.get(ower, project) for ower in self.payed_for]
bill.original_currency = CurrencyConverter.no_currency bill.original_currency = self.original_currency
bill.converted_amount = self.currency_helper.exchange_currency( bill.converted_amount = self.currency_helper.exchange_currency(
bill.amount, bill.original_currency, project.default_currency bill.amount, bill.original_currency, project.default_currency
) )

View file

@ -7,8 +7,8 @@ from flask_sqlalchemy import BaseQuery, SQLAlchemy
from itsdangerous import ( from itsdangerous import (
BadSignature, BadSignature,
SignatureExpired, SignatureExpired,
TimedJSONWebSignatureSerializer,
URLSafeSerializer, URLSafeSerializer,
URLSafeTimedSerializer,
) )
import sqlalchemy import sqlalchemy
from sqlalchemy import orm from sqlalchemy import orm
@ -179,6 +179,7 @@ class Project(db.Model):
"ower": transaction["ower"].name, "ower": transaction["ower"].name,
"receiver": transaction["receiver"].name, "receiver": transaction["receiver"].name,
"amount": round(transaction["amount"], 2), "amount": round(transaction["amount"], 2),
"currency": transaction["currency"],
} }
) )
return pretty_transactions return pretty_transactions
@ -192,6 +193,7 @@ class Project(db.Model):
"ower": members[ower_id], "ower": members[ower_id],
"receiver": members[receiver_id], "receiver": members[receiver_id],
"amount": amount, "amount": amount,
"currency": self.default_currency,
} }
for ower_id, amount, receiver_id in settle_plan for ower_id, amount, receiver_id in settle_plan
] ]
@ -269,6 +271,7 @@ class Project(db.Model):
{ {
"what": bill.what, "what": bill.what,
"amount": round(bill.amount, 2), "amount": round(bill.amount, 2),
"currency": bill.original_currency,
"date": str(bill.date), "date": str(bill.date),
"payer_name": Person.query.get(bill.payer_id).name, "payer_name": Person.query.get(bill.payer_id).name,
"payer_weight": Person.query.get(bill.payer_id).weight, "payer_weight": Person.query.get(bill.payer_id).weight,
@ -336,41 +339,61 @@ class Project(db.Model):
db.session.delete(self) db.session.delete(self)
db.session.commit() db.session.commit()
def generate_token(self, expiration=0): def generate_token(self, token_type="auth"):
"""Generate a timed and serialized JsonWebToken """Generate a timed and serialized JsonWebToken
:param expiration: Token expiration time (in seconds) :param token_type: Either "auth" for authentication (invalidated when project code changed),
or "reset" for password reset (invalidated after expiration)
""" """
if expiration:
serializer = TimedJSONWebSignatureSerializer( if token_type == "reset":
current_app.config["SECRET_KEY"], expiration serializer = URLSafeTimedSerializer(
current_app.config["SECRET_KEY"], salt=token_type
) )
token = serializer.dumps({"project_id": self.id}).decode("utf-8") token = serializer.dumps([self.id])
else: else:
serializer = URLSafeSerializer(current_app.config["SECRET_KEY"]) serializer = URLSafeSerializer(
token = serializer.dumps({"project_id": self.id}) current_app.config["SECRET_KEY"] + self.password, salt=token_type
)
token = serializer.dumps([self.id])
return token return token
@staticmethod @staticmethod
def verify_token(token, token_type="timed_token"): def verify_token(token, token_type="auth", project_id=None, max_age=3600):
"""Return the project id associated to the provided token, """Return the project id associated to the provided token,
None if the provided token is expired or not valid. None if the provided token is expired or not valid.
:param token: Serialized TimedJsonWebToken :param token: Serialized TimedJsonWebToken
:param token_type: Either "auth" for authentication (invalidated when project code changed),
or "reset" for password reset (invalidated after expiration)
:param project_id: Project ID. Used for token_type "auth" to use the password as serializer
secret key.
:param max_age: Token expiration time (in seconds). Only used with token_type "reset"
""" """
if token_type == "timed_token": loads_kwargs = {}
serializer = TimedJSONWebSignatureSerializer( if token_type == "reset":
current_app.config["SECRET_KEY"] serializer = URLSafeTimedSerializer(
current_app.config["SECRET_KEY"], salt=token_type
) )
loads_kwargs["max_age"] = max_age
else: else:
serializer = URLSafeSerializer(current_app.config["SECRET_KEY"]) project = Project.query.get(project_id) if project_id is not None else None
password = project.password if project is not None else ""
serializer = URLSafeSerializer(
current_app.config["SECRET_KEY"] + password, salt=token_type
)
try: try:
data = serializer.loads(token) data = serializer.loads(token, **loads_kwargs)
except SignatureExpired: except SignatureExpired:
return None return None
except BadSignature: except BadSignature:
return None return None
return data["project_id"]
data_project = data[0] if isinstance(data, list) else None
return (
data_project if project_id is None or data_project == project_id else None
)
def __str__(self): def __str__(self):
return self.name return self.name
@ -385,7 +408,7 @@ class Project(db.Model):
name="demonstration", name="demonstration",
password=generate_password_hash("demo"), password=generate_password_hash("demo"),
contact_email="demo@notmyidea.org", contact_email="demo@notmyidea.org",
default_currency="EUR", default_currency="XXX",
) )
db.session.add(project) db.session.add(project)
db.session.commit() db.session.commit()
@ -413,7 +436,7 @@ class Project(db.Model):
bill.what = subject bill.what = subject
bill.owers = [members[name] for name in owers] bill.owers = [members[name] for name in owers]
bill.amount = amount bill.amount = amount
bill.original_currency = "EUR" bill.original_currency = "XXX"
bill.converted_amount = amount bill.converted_amount = amount
db.session.add(bill) db.session.add(bill)

View file

@ -7,6 +7,7 @@ from flask import Flask, g, render_template, request, session
from flask_babel import Babel, format_currency from flask_babel import Babel, format_currency
from flask_mail import Mail from flask_mail import Mail
from flask_migrate import Migrate, stamp, upgrade from flask_migrate import Migrate, stamp, upgrade
from flask_talisman import Talisman
from jinja2 import pass_context from jinja2 import pass_context
from markupsafe import Markup from markupsafe import Markup
import pytz import pytz
@ -126,6 +127,24 @@ def create_app(
instance_relative_config=instance_relative_config, instance_relative_config=instance_relative_config,
) )
# If we need to load external JS/CSS/image resources, it needs to be added here, see
# https://github.com/wntrblm/flask-talisman#content-security-policy
csp = {
"default-src": ["'self'"],
# We have several inline javascript scripts :(
"script-src": ["'self'", "'unsafe-inline'"],
"object-src": "'none'",
}
Talisman(
app,
# Forcing HTTPS is the job of a reverse proxy
force_https=False,
# This is handled separately through the SESSION_COOKIE_SECURE Flask setting
session_cookie_secure=False,
content_security_policy=csp,
)
# If a configuration object is passed, use it. Otherwise try to find one. # If a configuration object is passed, use it. Otherwise try to find one.
load_configuration(app, configuration) load_configuration(app, configuration)
app.wsgi_app = PrefixedWSGI(app) app.wsgi_app = PrefixedWSGI(app)

View file

@ -28,6 +28,7 @@ body {
} }
.navbar-brand { .navbar-brand {
font-family: "Lobster", arial, serif; font-family: "Lobster", arial, serif;
font-size: 1.5rem;
} }
@media (min-width: 992px) { @media (min-width: 992px) {
@ -468,7 +469,6 @@ tr.payer_line .balance-name {
position: absolute; position: absolute;
top: 4.5rem; top: 4.5rem;
width: 100%; width: 100%;
pointer-events: none;
} }
.light { .light {

View file

@ -75,6 +75,9 @@
{{ input(form.name) }} {{ input(form.name) }}
{{ input(form.password) }} {{ input(form.password) }}
{{ input(form.contact_email) }} {{ input(form.contact_email) }}
{% if config['ENABLE_CAPTCHA'] %}
{{ input(form.captcha) }}
{% endif %}
{{ input(form.default_currency) }} {{ input(form.default_currency) }}
{% if not home %} {% if not home %}
{{ submit(form.submit, home=True) }} {{ submit(form.submit, home=True) }}

View file

@ -4,7 +4,7 @@ Someone using the email address {{ g.project.contact_email }} invited you to sha
It's as simple as saying what did you pay for, for whom, and how much did it cost you, we are caring about the rest. It's as simple as saying what did you pay for, for whom, and how much did it cost you, we are caring about the rest.
You can log in using this link: {{ url_for(".authenticate", _external=True, token=g.project.generate_token()) }}. You can log in using this link: {{ url_for(".join_project", _external=True, project_id=g.project.id, token=g.project.generate_token()) }}.
Once logged-in, you can use the following link which is easier to remember: {{ url_for(".list_bills", _external=True) }} Once logged-in, you can use the following link which is easier to remember: {{ url_for(".list_bills", _external=True) }}
If your cookie gets deleted or if you log out, you will need to log back in using the first link. If your cookie gets deleted or if you log out, you will need to log back in using the first link.

View file

@ -4,7 +4,7 @@ Quelqu'un dont l'adresse email est {{ g.project.contact_email }} vous a invité
Il suffit de renseigner qui a payé pour quoi, pour qui, combien ça a coûté, et on soccupe du reste. Il suffit de renseigner qui a payé pour quoi, pour qui, combien ça a coûté, et on soccupe du reste.
Vous pouvez vous connecter grâce à ce lien : {{ url_for(".authenticate", _external=True, token=g.project.generate_token()) }}. Vous pouvez vous connecter grâce à ce lien : {{ url_for(".join_project", _external=True, project_id=g.project.id, token=g.project.generate_token()) }}.
Une fois connecté, vous pourrez utiliser le lien suivant qui est plus facile à mémoriser : {{ url_for(".list_bills", _external=True) }} Une fois connecté, vous pourrez utiliser le lien suivant qui est plus facile à mémoriser : {{ url_for(".list_bills", _external=True) }}
Si vous êtes déconnecté volontairement ou non, vous devrez utiliser à nouveau le premier lien. Si vous êtes déconnecté volontairement ou non, vous devrez utiliser à nouveau le premier lien.

View file

@ -21,12 +21,6 @@
{% block head %}{% endblock %} {% block head %}{% endblock %}
<script type="text/javascript" charset="utf-8"> <script type="text/javascript" charset="utf-8">
$(document).ready(function(){ $(document).ready(function(){
setTimeout(function(){
$(".flash").fadeOut("slow", function () {
$(".flash").remove();
});
}, 4000);
$('.dropdown-toggle').dropdown(); $('.dropdown-toggle').dropdown();
$('[data-toggle="tooltip"]').tooltip(); $('[data-toggle="tooltip"]').tooltip();
{% block js %}{% endblock %} {% block js %}{% endblock %}
@ -128,11 +122,12 @@
<div class="messages"> <div class="messages">
{% for category, message in get_flashed_messages(with_categories=true) %} {% for category, message in get_flashed_messages(with_categories=true) %}
{% if category == "message" %}{# Default category for flash(msg) #} <div class="flash alert alert-{{ "success" if category == "message" else category }} alert-dismissible fade show">
<div class="flash alert alert-success">{{ message }}</div> {{ message }}
{% else %} <button type="button" class="close" data-dismiss="alert" aria-label="Close">
<div class="flash alert alert-{{ category }}">{{ message }}</div> <span aria-hidden="true">&times;</span>
{% endif %} </button>
</div>
{% endfor %} {% endfor %}
</div> </div>

View file

@ -1,7 +1,7 @@
Hi, Hi,
You requested to reset the password of the following project: "{{ project.name }}". You requested to reset the password of the following project: "{{ project.name }}".
You can reset it here: {{ url_for(".reset_password", _external=True, token=project.generate_token(expiration=3600)) }}. You can reset it here: {{ url_for(".reset_password", _external=True, token=project.generate_token(token_type="reset")) }}.
This link is only valid for one hour. This link is only valid for one hour.
Hope this helps, Hope this helps,

View file

@ -1,7 +1,7 @@
Salut, Salut,
Vous avez demandé à réinitialiser le mot de passe du projet suivant : "{{ project.name }}". Vous avez demandé à réinitialiser le mot de passe du projet suivant : "{{ project.name }}".
Vous pouvez le réinitialiser ici : {{ url_for(".reset_password", _external=True, token=project.generate_token(expiration=3600)) }}. Vous pouvez le réinitialiser ici : {{ url_for(".reset_password", _external=True, token=project.generate_token(token_type="reset")) }}.
Ce lien est seulement valide pendant 1 heure. Ce lien est seulement valide pendant 1 heure.
Faites-en bon usage ! Faites-en bon usage !

View file

@ -21,8 +21,8 @@
</td> </td>
<td> <td>
{{ _("You can directly share the following link via your prefered medium") }}</br> {{ _("You can directly share the following link via your prefered medium") }}</br>
<a href="{{ url_for(".authenticate", _external=True, token=g.project.generate_token()) }}"> <a href="{{ url_for(".join_project", _external=True, project_id=g.project.id, token=g.project.generate_token()) }}">
{{ url_for(".authenticate", _external=True, token=g.project.generate_token()) }} {{ url_for(".join_project", _external=True, project_id=g.project.id, token=g.project.generate_token()) }}
</a> </a>
</td> </td>
</tr> </tr>

View file

@ -15,7 +15,7 @@
<tr id="bal-member-{{ member.id }}" action="{% if member.activated %}delete{% else %}reactivate{% endif %}"> <tr id="bal-member-{{ member.id }}" action="{% if member.activated %}delete{% else %}reactivate{% endif %}">
<td class="balance-name">{{ member.name }} <td class="balance-name">{{ member.name }}
{%- if show_weight -%} {%- if show_weight -%}
<span class="light{% if not g.project.uses_weights %} extra-info{% endif %}">(x{{ member.weight|minimal_round(1) }})</span> <span class="light{% if not g.project.uses_weights %} extra-info{% endif %}">(x{{ member.weight|minimal_round(2) }})</span>
{%- endif -%} {%- endif -%}
</td> </td>
{%- if member_edit %} {%- if member_edit %}

View file

@ -11,20 +11,31 @@ class APITestCase(IhatemoneyTestCase):
"""Tests the API""" """Tests the API"""
def api_create(self, name, id=None, password=None, contact=None): def api_create(
self, name, id=None, password=None, contact=None, default_currency=None
):
id = id or name id = id or name
password = password or name password = password or name
contact = contact or f"{name}@notmyidea.org" contact = contact or f"{name}@notmyidea.org"
return self.client.post( if default_currency:
"/api/projects",
data = { data = {
"name": name, "name": name,
"id": id, "id": id,
"password": password, "password": password,
"contact_email": contact, "contact_email": contact,
"default_currency": "USD", "default_currency": default_currency,
}, }
else:
data = {
"name": name,
"id": id,
"password": password,
"contact_email": contact,
}
return self.client.post(
"/api/projects",
data=data,
) )
def api_add_member(self, project, name, weight=1): def api_add_member(self, project, name, weight=1):
@ -85,7 +96,7 @@ class APITestCase(IhatemoneyTestCase):
"id": "raclette", "id": "raclette",
"password": "raclette", "password": "raclette",
"contact_email": "not-an-email", "contact_email": "not-an-email",
"default_currency": "USD", "default_currency": "XXX",
}, },
) )
@ -114,7 +125,7 @@ class APITestCase(IhatemoneyTestCase):
"members": [], "members": [],
"name": "raclette", "name": "raclette",
"contact_email": "raclette@notmyidea.org", "contact_email": "raclette@notmyidea.org",
"default_currency": "USD", "default_currency": "XXX",
"id": "raclette", "id": "raclette",
"logging_preference": 1, "logging_preference": 1,
} }
@ -126,7 +137,7 @@ class APITestCase(IhatemoneyTestCase):
"/api/projects/raclette", "/api/projects/raclette",
data={ data={
"contact_email": "yeah@notmyidea.org", "contact_email": "yeah@notmyidea.org",
"default_currency": "USD", "default_currency": "XXX",
"password": "raclette", "password": "raclette",
"name": "The raclette party", "name": "The raclette party",
"project_history": "y", "project_history": "y",
@ -144,7 +155,7 @@ class APITestCase(IhatemoneyTestCase):
expected = { expected = {
"name": "The raclette party", "name": "The raclette party",
"contact_email": "yeah@notmyidea.org", "contact_email": "yeah@notmyidea.org",
"default_currency": "USD", "default_currency": "XXX",
"members": [], "members": [],
"id": "raclette", "id": "raclette",
"logging_preference": 1, "logging_preference": 1,
@ -157,7 +168,7 @@ class APITestCase(IhatemoneyTestCase):
"/api/projects/raclette", "/api/projects/raclette",
data={ data={
"contact_email": "yeah@notmyidea.org", "contact_email": "yeah@notmyidea.org",
"default_currency": "USD", "default_currency": "XXX",
"password": "tartiflette", "password": "tartiflette",
"name": "The raclette party", "name": "The raclette party",
}, },
@ -213,7 +224,7 @@ class APITestCase(IhatemoneyTestCase):
"/api/projects/raclette/token", headers=self.get_auth("raclette") "/api/projects/raclette/token", headers=self.get_auth("raclette")
) )
decoded_resp = json.loads(resp.data.decode("utf-8")) decoded_resp = json.loads(resp.data.decode("utf-8"))
resp = self.client.get("/authenticate?token={}".format(decoded_resp["token"])) resp = self.client.get(f"/raclette/join/{decoded_resp['token']}")
# Test that we are redirected. # Test that we are redirected.
self.assertEqual(302, resp.status_code) self.assertEqual(302, resp.status_code)
@ -380,7 +391,7 @@ class APITestCase(IhatemoneyTestCase):
"date": "2011-08-10", "date": "2011-08-10",
"id": 1, "id": 1,
"converted_amount": 25.0, "converted_amount": 25.0,
"original_currency": "USD", "original_currency": "XXX",
"external_link": "https://raclette.fr", "external_link": "https://raclette.fr",
} }
@ -451,7 +462,7 @@ class APITestCase(IhatemoneyTestCase):
"date": "2011-09-10", "date": "2011-09-10",
"external_link": "https://raclette.fr", "external_link": "https://raclette.fr",
"converted_amount": 25.0, "converted_amount": 25.0,
"original_currency": "USD", "original_currency": "XXX",
"id": 1, "id": 1,
} }
@ -529,7 +540,7 @@ class APITestCase(IhatemoneyTestCase):
"date": "2011-08-10", "date": "2011-08-10",
"id": id, "id": id,
"external_link": "", "external_link": "",
"original_currency": "USD", "original_currency": "XXX",
"converted_amount": expected_amount, "converted_amount": expected_amount,
} }
@ -564,6 +575,157 @@ class APITestCase(IhatemoneyTestCase):
) )
self.assertStatus(400, req) self.assertStatus(400, req)
def test_currencies(self):
# create project with a default currency
resp = self.api_create("raclette", default_currency="EUR")
self.assertTrue(201, resp.status_code)
# get information about it
resp = self.client.get(
"/api/projects/raclette", headers=self.get_auth("raclette")
)
self.assertTrue(200, resp.status_code)
expected = {
"members": [],
"name": "raclette",
"contact_email": "raclette@notmyidea.org",
"default_currency": "EUR",
"id": "raclette",
"logging_preference": 1,
}
decoded_resp = json.loads(resp.data.decode("utf-8"))
self.assertDictEqual(decoded_resp, expected)
# Add members
self.api_add_member("raclette", "zorglub")
self.api_add_member("raclette", "fred")
self.api_add_member("raclette", "quentin")
# Add a bill without explicit currency
req = self.client.post(
"/api/projects/raclette/bills",
data={
"date": "2011-08-10",
"what": "fromage",
"payer": "1",
"payed_for": ["1", "2"],
"amount": "25",
"external_link": "https://raclette.fr",
},
headers=self.get_auth("raclette"),
)
# should return the id
self.assertStatus(201, req)
self.assertEqual(req.data.decode("utf-8"), "1\n")
# get this bill details
req = self.client.get(
"/api/projects/raclette/bills/1", headers=self.get_auth("raclette")
)
# compare with the added info
self.assertStatus(200, req)
expected = {
"what": "fromage",
"payer_id": 1,
"owers": [
{"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": "EUR",
"external_link": "https://raclette.fr",
}
got = json.loads(req.data.decode("utf-8"))
self.assertEqual(
datetime.date.today(),
datetime.datetime.strptime(got["creation_date"], "%Y-%m-%d").date(),
)
del got["creation_date"]
self.assertDictEqual(expected, got)
# Change bill amount and currency
req = self.client.put(
"/api/projects/raclette/bills/1",
data={
"date": "2011-08-10",
"what": "fromage",
"payer": "1",
"payed_for": ["1", "2"],
"amount": "30",
"external_link": "https://raclette.fr",
"original_currency": "CAD",
},
headers=self.get_auth("raclette"),
)
self.assertStatus(200, req)
# Check result
req = self.client.get(
"/api/projects/raclette/bills/1", headers=self.get_auth("raclette")
)
self.assertStatus(200, req)
expected_amount = self.converter.exchange_currency(30.0, "CAD", "EUR")
expected = {
"what": "fromage",
"payer_id": 1,
"owers": [
{"activated": True, "id": 1, "name": "zorglub", "weight": 1.0},
{"activated": True, "id": 2, "name": "fred", "weight": 1.0},
],
"amount": 30.0,
"date": "2011-08-10",
"id": 1,
"converted_amount": expected_amount,
"original_currency": "CAD",
"external_link": "https://raclette.fr",
}
got = json.loads(req.data.decode("utf-8"))
del got["creation_date"]
self.assertDictEqual(expected, got)
# Add a bill with yet another currency
req = self.client.post(
"/api/projects/raclette/bills",
data={
"date": "2011-09-10",
"what": "Pierogi",
"payer": "1",
"payed_for": ["2", "3"],
"amount": "80",
"original_currency": "PLN",
},
headers=self.get_auth("raclette"),
)
# should return the id
self.assertStatus(201, req)
self.assertEqual(req.data.decode("utf-8"), "2\n")
# Try to remove default project currency, it should fail
req = self.client.put(
"/api/projects/raclette",
data={
"contact_email": "yeah@notmyidea.org",
"default_currency": "XXX",
"password": "raclette",
"name": "The raclette party",
},
headers=self.get_auth("raclette"),
)
self.assertStatus(400, req)
self.assertIn("This project cannot be set", req.data.decode("utf-8"))
self.assertIn(
"because it contains bills in multiple currencies", req.data.decode("utf-8")
)
def test_statistics(self): def test_statistics(self):
# create a project # create a project
self.api_create("raclette") self.api_create("raclette")
@ -674,7 +836,7 @@ class APITestCase(IhatemoneyTestCase):
"id": 1, "id": 1,
"external_link": "", "external_link": "",
"converted_amount": 25.0, "converted_amount": 25.0,
"original_currency": "USD", "original_currency": "XXX",
} }
got = json.loads(req.data.decode("utf-8")) got = json.loads(req.data.decode("utf-8"))
self.assertEqual( self.assertEqual(
@ -717,7 +879,7 @@ class APITestCase(IhatemoneyTestCase):
"id": "raclette", "id": "raclette",
"name": "raclette", "name": "raclette",
"logging_preference": 1, "logging_preference": 1,
"default_currency": "USD", "default_currency": "XXX",
} }
self.assertStatus(200, req) self.assertStatus(200, req)

View file

@ -4,7 +4,7 @@ import json
import re import re
from time import sleep from time import sleep
import unittest import unittest
from unittest.mock import MagicMock from urllib.parse import urlparse, urlunparse
from flask import session from flask import session
import pytest import pytest
@ -12,6 +12,7 @@ from werkzeug.security import check_password_hash, generate_password_hash
from ihatemoney import models from ihatemoney import models
from ihatemoney.currency_convertor import CurrencyConverter from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.tests.common.help_functions import extract_link
from ihatemoney.tests.common.ihatemoney_testcase import IhatemoneyTestCase from ihatemoney.tests.common.ihatemoney_testcase import IhatemoneyTestCase
from ihatemoney.versioning import LoggingMode from ihatemoney.versioning import LoggingMode
@ -88,10 +89,53 @@ class BudgetTestCase(IhatemoneyTestCase):
) )
# Test empty and invalid tokens # Test empty and invalid tokens
self.client.get("/exit") self.client.get("/exit")
resp = self.client.get("/authenticate") # Use another project_id
self.assertIn("You either provided a bad token", resp.data.decode("utf-8")) parsed_url = urlparse(url)
resp = self.client.get("/authenticate?token=token") resp = self.client.get(
self.assertIn("You either provided a bad token", resp.data.decode("utf-8")) urlunparse(
parsed_url._replace(
path=parsed_url.path.replace("raclette/", "invalid_project/")
)
),
follow_redirects=True,
)
assert "Create a new project" in resp.data.decode("utf-8")
# A token MUST have a point between payload and signature
resp = self.client.get("/raclette/join/token.invalid", follow_redirects=True)
self.assertIn("Provided token is invalid", resp.data.decode("utf-8"))
def test_invite_code_invalidation(self):
"""Test that invitation link expire after code change"""
self.login("raclette")
self.post_project("raclette")
response = self.client.get("/raclette/invite").data.decode("utf-8")
link = extract_link(response, "share the following link")
self.client.get("/exit")
response = self.client.get(link)
# Link is valid
assert response.status_code == 302
# Change password to invalidate token
# Other data are required, but useless for the test
response = self.client.post(
"/raclette/edit",
data={
"name": "raclette",
"contact_email": "zorglub@notmyidea.org",
"password": "didoudida",
"default_currency": "XXX",
},
follow_redirects=True,
)
assert response.status_code == 200
assert "alert-danger" not in response.data.decode("utf-8")
self.client.get("/exit")
response = self.client.get(link, follow_redirects=True)
# Link is invalid
self.assertIn("Provided token is invalid", response.data.decode("utf-8"))
def test_password_reminder(self): def test_password_reminder(self):
# test that it is possible to have an email containing the password of a # test that it is possible to have an email containing the password of a
@ -633,6 +677,35 @@ class BudgetTestCase(IhatemoneyTestCase):
bill = models.Bill.query.filter(models.Bill.date == "2011-08-01")[0] bill = models.Bill.query.filter(models.Bill.date == "2011-08-01")[0]
self.assertEqual(bill.amount, 25.02) self.assertEqual(bill.amount, 25.02)
# add a bill with a valid external link
self.client.post(
"/raclette/add",
data={
"date": "2015-05-05",
"what": "fromage à raclette",
"payer": members_ids[0],
"payed_for": members_ids,
"amount": "42",
"external_link": "https://example.com/fromage",
},
)
bill = models.Bill.query.filter(models.Bill.date == "2015-05-05")[0]
self.assertEqual(bill.external_link, "https://example.com/fromage")
# add a bill with an invalid external link
resp = self.client.post(
"/raclette/add",
data={
"date": "2015-05-06",
"what": "mauvais fromage à raclette",
"payer": members_ids[0],
"payed_for": members_ids,
"amount": "42000",
"external_link": "javascript:alert('Tu bluffes, Martoni.')",
},
)
self.assertIn("Invalid URL", resp.data.decode("utf-8"))
def test_weighted_balance(self): def test_weighted_balance(self):
self.post_project("raclette") self.post_project("raclette")
@ -1009,6 +1082,8 @@ class BudgetTestCase(IhatemoneyTestCase):
) )
def test_export(self): def test_export(self):
# Export a simple project without currencies
self.post_project("raclette") self.post_project("raclette")
# add members # add members
@ -1026,7 +1101,6 @@ class BudgetTestCase(IhatemoneyTestCase):
"payer": 1, "payer": 1,
"payed_for": [1, 2, 3, 4], "payed_for": [1, 2, 3, 4],
"amount": "10.0", "amount": "10.0",
"original_currency": "USD",
}, },
) )
@ -1038,7 +1112,6 @@ class BudgetTestCase(IhatemoneyTestCase):
"payer": 2, "payer": 2,
"payed_for": [1, 3], "payed_for": [1, 3],
"amount": "200", "amount": "200",
"original_currency": "USD",
}, },
) )
@ -1050,13 +1123,464 @@ class BudgetTestCase(IhatemoneyTestCase):
"payer": 3, "payer": 3,
"payed_for": [2], "payed_for": [2],
"amount": "13.33", "amount": "13.33",
"original_currency": "USD",
}, },
) )
# generate json export of bills # generate json export of bills
resp = self.client.get("/raclette/export/bills.json") resp = self.client.get("/raclette/export/bills.json")
expected = [ expected = [
{
"date": "2017-01-01",
"what": "refund",
"amount": 13.33,
"currency": "XXX",
"payer_name": "tata",
"payer_weight": 1.0,
"owers": ["fred"],
},
{
"date": "2016-12-31",
"what": "red wine",
"amount": 200.0,
"currency": "XXX",
"payer_name": "fred",
"payer_weight": 1.0,
"owers": ["zorglub", "tata"],
},
{
"date": "2016-12-31",
"what": "fromage \xe0 raclette",
"amount": 10.0,
"currency": "XXX",
"payer_name": "zorglub",
"payer_weight": 2.0,
"owers": ["zorglub", "fred", "tata", "p\xe9p\xe9"],
},
]
self.assertEqual(json.loads(resp.data.decode("utf-8")), expected)
# generate csv export of bills
resp = self.client.get("/raclette/export/bills.csv")
expected = [
"date,what,amount,currency,payer_name,payer_weight,owers",
"2017-01-01,refund,XXX,13.33,tata,1.0,fred",
'2016-12-31,red wine,XXX,200.0,fred,1.0,"zorglub, tata"',
'2016-12-31,fromage à raclette,10.0,XXX,zorglub,2.0,"zorglub, fred, tata, pépé"',
]
received_lines = resp.data.decode("utf-8").split("\n")
for i, line in enumerate(expected):
self.assertEqual(
set(line.split(",")), set(received_lines[i].strip("\r").split(","))
)
# generate json export of transactions
resp = self.client.get("/raclette/export/transactions.json")
expected = [
{
"amount": 2.00,
"currency": "XXX",
"receiver": "fred",
"ower": "p\xe9p\xe9",
},
{"amount": 55.34, "currency": "XXX", "receiver": "fred", "ower": "tata"},
{
"amount": 127.33,
"currency": "XXX",
"receiver": "fred",
"ower": "zorglub",
},
]
self.assertEqual(json.loads(resp.data.decode("utf-8")), expected)
# generate csv export of transactions
resp = self.client.get("/raclette/export/transactions.csv")
expected = [
"amount,currency,receiver,ower",
"2.0,XXX,fred,pépé",
"55.34,XXX,fred,tata",
"127.33,XXX,fred,zorglub",
]
received_lines = resp.data.decode("utf-8").split("\n")
for i, line in enumerate(expected):
self.assertEqual(
set(line.split(",")), set(received_lines[i].strip("\r").split(","))
)
# wrong export_format should return a 404
resp = self.client.get("/raclette/export/transactions.wrong")
self.assertEqual(resp.status_code, 404)
def test_export_with_currencies(self):
self.post_project("raclette", default_currency="EUR")
# add members
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é"})
# create bills
self.client.post(
"/raclette/add",
data={
"date": "2016-12-31",
"what": "fromage à raclette",
"payer": 1,
"payed_for": [1, 2, 3, 4],
"amount": "10.0",
"original_currency": "EUR",
},
)
self.client.post(
"/raclette/add",
data={
"date": "2016-12-31",
"what": "poutine from Québec",
"payer": 2,
"payed_for": [1, 3],
"amount": "100",
"original_currency": "CAD",
},
)
self.client.post(
"/raclette/add",
data={
"date": "2017-01-01",
"what": "refund",
"payer": 3,
"payed_for": [2],
"amount": "13.33",
"original_currency": "EUR",
},
)
# generate json export of bills
resp = self.client.get("/raclette/export/bills.json")
expected = [
{
"date": "2017-01-01",
"what": "refund",
"amount": 13.33,
"currency": "EUR",
"payer_name": "tata",
"payer_weight": 1.0,
"owers": ["fred"],
},
{
"date": "2016-12-31",
"what": "poutine from Qu\xe9bec",
"amount": 100.0,
"currency": "CAD",
"payer_name": "fred",
"payer_weight": 1.0,
"owers": ["zorglub", "tata"],
},
{
"date": "2016-12-31",
"what": "fromage \xe0 raclette",
"amount": 10.0,
"currency": "EUR",
"payer_name": "zorglub",
"payer_weight": 2.0,
"owers": ["zorglub", "fred", "tata", "p\xe9p\xe9"],
},
]
self.assertEqual(json.loads(resp.data.decode("utf-8")), expected)
# generate csv export of bills
resp = self.client.get("/raclette/export/bills.csv")
expected = [
"date,what,amount,currency,payer_name,payer_weight,owers",
"2017-01-01,refund,13.33,EUR,tata,1.0,fred",
'2016-12-31,poutine from Québec,100.0,CAD,fred,1.0,"zorglub, tata"',
'2016-12-31,fromage à raclette,10.0,EUR,zorglub,2.0,"zorglub, fred, tata, pépé"',
]
received_lines = resp.data.decode("utf-8").split("\n")
for i, line in enumerate(expected):
self.assertEqual(
set(line.split(",")), set(received_lines[i].strip("\r").split(","))
)
# generate json export of transactions (in EUR!)
resp = self.client.get("/raclette/export/transactions.json")
expected = [
{
"amount": 2.00,
"currency": "EUR",
"receiver": "fred",
"ower": "p\xe9p\xe9",
},
{"amount": 10.89, "currency": "EUR", "receiver": "fred", "ower": "tata"},
{"amount": 38.45, "currency": "EUR", "receiver": "fred", "ower": "zorglub"},
]
self.assertEqual(json.loads(resp.data.decode("utf-8")), expected)
# generate csv export of transactions
resp = self.client.get("/raclette/export/transactions.csv")
expected = [
"amount,currency,receiver,ower",
"2.0,EUR,fred,pépé",
"10.89,EUR,fred,tata",
"38.45,EUR,fred,zorglub",
]
received_lines = resp.data.decode("utf-8").split("\n")
for i, line in enumerate(expected):
self.assertEqual(
set(line.split(",")), set(received_lines[i].strip("\r").split(","))
)
# Change project currency to CAD
project = models.Project.query.get("raclette")
project.switch_currency("CAD")
# generate json export of transactions (now in CAD!)
resp = self.client.get("/raclette/export/transactions.json")
expected = [
{
"amount": 3.00,
"currency": "CAD",
"receiver": "fred",
"ower": "p\xe9p\xe9",
},
{"amount": 16.34, "currency": "CAD", "receiver": "fred", "ower": "tata"},
{"amount": 57.67, "currency": "CAD", "receiver": "fred", "ower": "zorglub"},
]
self.assertEqual(json.loads(resp.data.decode("utf-8")), expected)
# generate csv export of transactions
resp = self.client.get("/raclette/export/transactions.csv")
expected = [
"amount,currency,receiver,ower",
"3.0,CAD,fred,pépé",
"16.34,CAD,fred,tata",
"57.67,CAD,fred,zorglub",
]
received_lines = resp.data.decode("utf-8").split("\n")
for i, line in enumerate(expected):
self.assertEqual(
set(line.split(",")), set(received_lines[i].strip("\r").split(","))
)
def test_import_currencies_in_empty_project_with_currency(self):
# Import JSON with currencies in an empty project with a default currency
self.post_project("raclette", default_currency="EUR")
self.login("raclette")
project = models.Project.query.get("raclette")
json_to_import = [
{
"date": "2017-01-01",
"what": "refund",
"amount": 13.33,
"currency": "EUR",
"payer_name": "tata",
"payer_weight": 1.0,
"owers": ["fred"],
},
{
"date": "2016-12-31",
"what": "poutine from québec",
"amount": 50.0,
"currency": "CAD",
"payer_name": "fred",
"payer_weight": 1.0,
"owers": ["zorglub", "tata"],
},
{
"date": "2016-12-31",
"what": "fromage a raclette",
"amount": 10.0,
"currency": "EUR",
"payer_name": "zorglub",
"payer_weight": 2.0,
"owers": ["zorglub", "fred", "tata", "pepe"],
},
]
from ihatemoney.web import import_project
file = io.StringIO()
json.dump(json_to_import, file)
file.seek(0)
import_project(file, project)
bills = project.get_pretty_bills()
# Check if all bills have been added
self.assertEqual(len(bills), len(json_to_import))
# Check if name of bills are ok
b = [e["what"] for e in bills]
b.sort()
ref = [e["what"] for e in json_to_import]
ref.sort()
self.assertEqual(b, ref)
# Check if other informations in bill are ok
for i in json_to_import:
for j in bills:
if j["what"] == i["what"]:
self.assertEqual(j["payer_name"], i["payer_name"])
self.assertEqual(j["amount"], i["amount"])
self.assertEqual(j["currency"], i["currency"])
self.assertEqual(j["payer_weight"], i["payer_weight"])
self.assertEqual(j["date"], i["date"])
list_project = [ower for ower in j["owers"]]
list_project.sort()
list_json = [ower for ower in i["owers"]]
list_json.sort()
self.assertEqual(list_project, list_json)
def test_import_single_currency_in_empty_project_without_currency(self):
# Import JSON with a single currency in an empty project with no
# default currency. It should work by stripping the currency from
# bills.
self.post_project("raclette")
self.login("raclette")
project = models.Project.query.get("raclette")
json_to_import = [
{
"date": "2017-01-01",
"what": "refund",
"amount": 13.33,
"currency": "EUR",
"payer_name": "tata",
"payer_weight": 1.0,
"owers": ["fred"],
},
{
"date": "2016-12-31",
"what": "fromage a raclette",
"amount": 10.0,
"currency": "EUR",
"payer_name": "zorglub",
"payer_weight": 2.0,
"owers": ["zorglub", "fred", "tata", "pepe"],
},
]
from ihatemoney.web import import_project
file = io.StringIO()
json.dump(json_to_import, file)
file.seek(0)
import_project(file, project)
bills = project.get_pretty_bills()
# Check if all bills have been added
self.assertEqual(len(bills), len(json_to_import))
# Check if name of bills are ok
b = [e["what"] for e in bills]
b.sort()
ref = [e["what"] for e in json_to_import]
ref.sort()
self.assertEqual(b, ref)
# Check if other informations in bill are ok
for i in json_to_import:
for j in bills:
if j["what"] == i["what"]:
self.assertEqual(j["payer_name"], i["payer_name"])
self.assertEqual(j["amount"], i["amount"])
# Currency should have been stripped
self.assertEqual(j["currency"], "XXX")
self.assertEqual(j["payer_weight"], i["payer_weight"])
self.assertEqual(j["date"], i["date"])
list_project = [ower for ower in j["owers"]]
list_project.sort()
list_json = [ower for ower in i["owers"]]
list_json.sort()
self.assertEqual(list_project, list_json)
def test_import_multiple_currencies_in_empty_project_without_currency(self):
# Import JSON with multiple currencies in an empty project with no
# default currency. It should fail.
self.post_project("raclette")
self.login("raclette")
project = models.Project.query.get("raclette")
json_to_import = [
{
"date": "2017-01-01",
"what": "refund",
"amount": 13.33,
"currency": "EUR",
"payer_name": "tata",
"payer_weight": 1.0,
"owers": ["fred"],
},
{
"date": "2016-12-31",
"what": "poutine from québec",
"amount": 50.0,
"currency": "CAD",
"payer_name": "fred",
"payer_weight": 1.0,
"owers": ["zorglub", "tata"],
},
{
"date": "2016-12-31",
"what": "fromage a raclette",
"amount": 10.0,
"currency": "EUR",
"payer_name": "zorglub",
"payer_weight": 2.0,
"owers": ["zorglub", "fred", "tata", "pepe"],
},
]
from ihatemoney.web import import_project
file = io.StringIO()
json.dump(json_to_import, file)
file.seek(0)
# Import should fail
with pytest.raises(ValueError):
import_project(file, project)
bills = project.get_pretty_bills()
# Check that there are no bills
self.assertEqual(len(bills), 0)
def test_import_no_currency_in_empty_project_with_currency(self):
# Import JSON without currencies (from ihatemoney < 5) in an empty
# project with a default currency.
self.post_project("raclette", default_currency="EUR")
self.login("raclette")
project = models.Project.query.get("raclette")
json_to_import = [
{ {
"date": "2017-01-01", "date": "2017-01-01",
"what": "refund", "what": "refund",
@ -1075,62 +1599,55 @@ class BudgetTestCase(IhatemoneyTestCase):
}, },
{ {
"date": "2016-12-31", "date": "2016-12-31",
"what": "fromage \xe0 raclette", "what": "fromage a raclette",
"amount": 10.0, "amount": 10.0,
"payer_name": "zorglub", "payer_name": "zorglub",
"payer_weight": 2.0, "payer_weight": 2.0,
"owers": ["zorglub", "fred", "tata", "p\xe9p\xe9"], "owers": ["zorglub", "fred", "tata", "pepe"],
}, },
] ]
self.assertEqual(json.loads(resp.data.decode("utf-8")), expected)
# generate csv export of bills from ihatemoney.web import import_project
resp = self.client.get("/raclette/export/bills.csv")
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,"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")
for i, line in enumerate(expected): file = io.StringIO()
self.assertEqual( json.dump(json_to_import, file)
set(line.split(",")), set(received_lines[i].strip("\r").split(",")) file.seek(0)
) import_project(file, project)
# generate json export of transactions bills = project.get_pretty_bills()
resp = self.client.get("/raclette/export/transactions.json")
expected = [
{"amount": 2.00, "receiver": "fred", "ower": "p\xe9p\xe9"},
{"amount": 55.34, "receiver": "fred", "ower": "tata"},
{"amount": 127.33, "receiver": "fred", "ower": "zorglub"},
]
self.assertEqual(json.loads(resp.data.decode("utf-8")), expected) # Check if all bills have been added
self.assertEqual(len(bills), len(json_to_import))
# generate csv export of transactions # Check if name of bills are ok
resp = self.client.get("/raclette/export/transactions.csv") b = [e["what"] for e in bills]
b.sort()
ref = [e["what"] for e in json_to_import]
ref.sort()
expected = [ self.assertEqual(b, ref)
"amount,receiver,ower",
"2.0,fred,pépé",
"55.34,fred,tata",
"127.33,fred,zorglub",
]
received_lines = resp.data.decode("utf-8").split("\n")
for i, line in enumerate(expected): # Check if other informations in bill are ok
self.assertEqual( for i in json_to_import:
set(line.split(",")), set(received_lines[i].strip("\r").split(",")) for j in bills:
) if j["what"] == i["what"]:
self.assertEqual(j["payer_name"], i["payer_name"])
self.assertEqual(j["amount"], i["amount"])
# All bills are converted to default project currency
self.assertEqual(j["currency"], "EUR")
self.assertEqual(j["payer_weight"], i["payer_weight"])
self.assertEqual(j["date"], i["date"])
# wrong export_format should return a 404 list_project = [ower for ower in j["owers"]]
resp = self.client.get("/raclette/export/transactions.wrong") list_project.sort()
self.assertEqual(resp.status_code, 404) list_json = [ower for ower in i["owers"]]
list_json.sort()
def test_import_new_project(self): self.assertEqual(list_project, list_json)
# Import JSON in an empty project
def test_import_no_currency_in_empty_project_without_currency(self):
# Import JSON without currencies (from ihatemoney < 5) in an empty
# project with no default currency.
self.post_project("raclette") self.post_project("raclette")
self.login("raclette") self.login("raclette")
@ -1173,7 +1690,7 @@ class BudgetTestCase(IhatemoneyTestCase):
bills = project.get_pretty_bills() bills = project.get_pretty_bills()
# Check if all bills has been add # Check if all bills have been added
self.assertEqual(len(bills), len(json_to_import)) self.assertEqual(len(bills), len(json_to_import))
# Check if name of bills are ok # Check if name of bills are ok
@ -1190,6 +1707,7 @@ class BudgetTestCase(IhatemoneyTestCase):
if j["what"] == i["what"]: if j["what"] == i["what"]:
self.assertEqual(j["payer_name"], i["payer_name"]) self.assertEqual(j["payer_name"], i["payer_name"])
self.assertEqual(j["amount"], i["amount"]) self.assertEqual(j["amount"], i["amount"])
self.assertEqual(j["currency"], "XXX")
self.assertEqual(j["payer_weight"], i["payer_weight"]) self.assertEqual(j["payer_weight"], i["payer_weight"])
self.assertEqual(j["date"], i["date"]) self.assertEqual(j["date"], i["date"])
@ -1227,6 +1745,7 @@ class BudgetTestCase(IhatemoneyTestCase):
"date": "2017-01-01", "date": "2017-01-01",
"what": "refund", "what": "refund",
"amount": 13.33, "amount": 13.33,
"currency": "XXX",
"payer_name": "tata", "payer_name": "tata",
"payer_weight": 1.0, "payer_weight": 1.0,
"owers": ["fred"], "owers": ["fred"],
@ -1235,6 +1754,7 @@ class BudgetTestCase(IhatemoneyTestCase):
"date": "2016-12-31", "date": "2016-12-31",
"what": "red wine", "what": "red wine",
"amount": 200.0, "amount": 200.0,
"currency": "XXX",
"payer_name": "fred", "payer_name": "fred",
"payer_weight": 1.0, "payer_weight": 1.0,
"owers": ["zorglub", "tata"], "owers": ["zorglub", "tata"],
@ -1243,6 +1763,7 @@ class BudgetTestCase(IhatemoneyTestCase):
"date": "2016-12-31", "date": "2016-12-31",
"what": "fromage a raclette", "what": "fromage a raclette",
"amount": 10.0, "amount": 10.0,
"currency": "XXX",
"payer_name": "zorglub", "payer_name": "zorglub",
"payer_weight": 2.0, "payer_weight": 2.0,
"owers": ["zorglub", "fred", "tata", "pepe"], "owers": ["zorglub", "fred", "tata", "pepe"],
@ -1258,7 +1779,7 @@ class BudgetTestCase(IhatemoneyTestCase):
bills = project.get_pretty_bills() bills = project.get_pretty_bills()
# Check if all bills has been add # Check if all bills have been added
self.assertEqual(len(bills), len(json_to_import)) self.assertEqual(len(bills), len(json_to_import))
# Check if name of bills are ok # Check if name of bills are ok
@ -1275,6 +1796,7 @@ class BudgetTestCase(IhatemoneyTestCase):
if j["what"] == i["what"]: if j["what"] == i["what"]:
self.assertEqual(j["payer_name"], i["payer_name"]) self.assertEqual(j["payer_name"], i["payer_name"])
self.assertEqual(j["amount"], i["amount"]) self.assertEqual(j["amount"], i["amount"])
self.assertEqual(j["currency"], i["currency"])
self.assertEqual(j["payer_weight"], i["payer_weight"]) self.assertEqual(j["payer_weight"], i["payer_weight"])
self.assertEqual(j["date"], i["date"]) self.assertEqual(j["date"], i["date"])
@ -1314,29 +1836,12 @@ class BudgetTestCase(IhatemoneyTestCase):
from ihatemoney.web import import_project from ihatemoney.web import import_project
try: for data in [json_1, json_2]:
file = io.StringIO() file = io.StringIO()
json.dump(json_1, file) json.dump(data, file)
file.seek(0) file.seek(0)
with pytest.raises(ValueError):
import_project(file, project) import_project(file, project)
except ValueError:
self.assertTrue(True)
except Exception:
self.fail("unexpected exception raised")
else:
self.fail("ExpectedException not raised")
try:
file = io.StringIO()
json.dump(json_2, file)
file.seek(0)
import_project(file, project)
except ValueError:
self.assertTrue(True)
except Exception:
self.fail("unexpected exception raised")
else:
self.fail("ExpectedException not raised")
def test_access_other_projects(self): def test_access_other_projects(self):
"""Test that accessing or editing bills and members from another project fails""" """Test that accessing or editing bills and members from another project fails"""
@ -1464,10 +1969,6 @@ class BudgetTestCase(IhatemoneyTestCase):
def test_currency_switch(self): def test_currency_switch(self):
mock_data = {"USD": 1, "EUR": 0.8, "CAD": 1.2, CurrencyConverter.no_currency: 1}
converter = CurrencyConverter()
converter.get_rates = MagicMock(return_value=mock_data)
# A project should be editable # A project should be editable
self.post_project("raclette") self.post_project("raclette")
@ -1559,14 +2060,16 @@ class BudgetTestCase(IhatemoneyTestCase):
}, },
) )
last_bill = project.get_bills().first() last_bill = project.get_bills().first()
expected_amount = converter.exchange_currency(last_bill.amount, "CAD", "EUR") expected_amount = self.converter.exchange_currency(
last_bill.amount, "CAD", "EUR"
)
assert last_bill.converted_amount == expected_amount assert last_bill.converted_amount == expected_amount
# Switch to USD. Now, NO bill should be in USD, since they already had a currency # Switch to USD. Now, NO bill should be in USD, since they already had a currency
project.switch_currency("USD") project.switch_currency("USD")
for bill in project.get_bills(): for bill in project.get_bills():
assert bill.original_currency != "USD" assert bill.original_currency != "USD"
expected_amount = converter.exchange_currency( expected_amount = self.converter.exchange_currency(
bill.amount, bill.original_currency, "USD" bill.amount, bill.original_currency, "USD"
) )
assert bill.converted_amount == expected_amount assert bill.converted_amount == expected_amount
@ -1583,7 +2086,7 @@ class BudgetTestCase(IhatemoneyTestCase):
"password": "demo", "password": "demo",
"contact_email": "demo@notmyidea.org", "contact_email": "demo@notmyidea.org",
"project_history": "y", "project_history": "y",
"default_currency": converter.no_currency, "default_currency": CurrencyConverter.no_currency,
}, },
) )
# A user displayed error should be generated, and its currency should be the same. # A user displayed error should be generated, and its currency should be the same.
@ -1593,10 +2096,6 @@ class BudgetTestCase(IhatemoneyTestCase):
def test_currency_switch_to_bill_currency(self): def test_currency_switch_to_bill_currency(self):
mock_data = {"USD": 1, "EUR": 0.8, "CAD": 1.2, CurrencyConverter.no_currency: 1}
converter = CurrencyConverter()
converter.get_rates = MagicMock(return_value=mock_data)
# Default currency is 'XXX', but we should start from a project with a currency # Default currency is 'XXX', but we should start from a project with a currency
self.post_project("raclette", default_currency="USD") self.post_project("raclette", default_currency="USD")
@ -1620,7 +2119,7 @@ class BudgetTestCase(IhatemoneyTestCase):
project = models.Project.query.get("raclette") project = models.Project.query.get("raclette")
bill = project.get_bills().first() bill = project.get_bills().first()
assert bill.converted_amount == converter.exchange_currency( assert bill.converted_amount == self.converter.exchange_currency(
bill.amount, "EUR", "USD" bill.amount, "EUR", "USD"
) )
@ -1631,10 +2130,6 @@ class BudgetTestCase(IhatemoneyTestCase):
def test_currency_switch_to_no_currency(self): def test_currency_switch_to_no_currency(self):
mock_data = {"USD": 1, "EUR": 0.8, "CAD": 1.2, CurrencyConverter.no_currency: 1}
converter = CurrencyConverter()
converter.get_rates = MagicMock(return_value=mock_data)
# Default currency is 'XXX', but we should start from a project with a currency # Default currency is 'XXX', but we should start from a project with a currency
self.post_project("raclette", default_currency="USD") self.post_project("raclette", default_currency="USD")
@ -1670,17 +2165,40 @@ class BudgetTestCase(IhatemoneyTestCase):
project = models.Project.query.get("raclette") project = models.Project.query.get("raclette")
for bill in project.get_bills_unordered(): for bill in project.get_bills_unordered():
assert bill.converted_amount == converter.exchange_currency( assert bill.converted_amount == self.converter.exchange_currency(
bill.amount, "EUR", "USD" bill.amount, "EUR", "USD"
) )
# And switch project to no currency: amount should be equal to what was submitted # And switch project to no currency: amount should be equal to what was submitted
project.switch_currency(converter.no_currency) project.switch_currency(CurrencyConverter.no_currency)
no_currency_bills = [ no_currency_bills = [
(bill.amount, bill.converted_amount) for bill in project.get_bills() (bill.amount, bill.converted_amount) for bill in project.get_bills()
] ]
assert no_currency_bills == [(5.0, 5.0), (10.0, 10.0)] assert no_currency_bills == [(5.0, 5.0), (10.0, 10.0)]
def test_decimals_on_weighted_members_list(self):
self.post_project("raclette")
# add three users with different weights
self.client.post(
"/raclette/members/add", data={"name": "zorglub", "weight": 1.0}
)
self.client.post("/raclette/members/add", data={"name": "tata", "weight": 1.10})
self.client.post("/raclette/members/add", data={"name": "fred", "weight": 1.15})
# check if weights of the users are 1, 1.1, 1.15 respectively
resp = self.client.get("/raclette/")
self.assertIn(
'zorglub<span class="light">(x1)</span>', resp.data.decode("utf-8")
)
self.assertIn(
'tata<span class="light">(x1.1)</span>', resp.data.decode("utf-8")
)
self.assertIn(
'fred<span class="light">(x1.15)</span>', resp.data.decode("utf-8")
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -1,5 +1,16 @@
from markupsafe import Markup
def em_surround(string, regex_escape=False): def em_surround(string, regex_escape=False):
if regex_escape: if regex_escape:
return r'<em class="font-italic">%s<\/em>' % string return r'<em class="font-italic">%s<\/em>' % string
else: else:
return '<em class="font-italic">%s</em>' % string return '<em class="font-italic">%s</em>' % string
def extract_link(data, start_prefix):
base_index = data.find(start_prefix)
start = data.find('href="', base_index) + 6
end = data.find('">', base_index)
link = Markup(data[start:end]).unescape()
return link

View file

@ -1,9 +1,11 @@
import os import os
from unittest.mock import MagicMock
from flask_testing import TestCase from flask_testing import TestCase
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
from ihatemoney import models from ihatemoney import models
from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.run import create_app, db from ihatemoney.run import create_app, db
@ -13,6 +15,7 @@ class BaseTestCase(TestCase):
SQLALCHEMY_DATABASE_URI = os.environ.get( SQLALCHEMY_DATABASE_URI = os.environ.get(
"TESTING_SQLALCHEMY_DATABASE_URI", "sqlite://" "TESTING_SQLALCHEMY_DATABASE_URI", "sqlite://"
) )
ENABLE_CAPTCHA = False
def create_app(self): def create_app(self):
# Pass the test object as a configuration. # Pass the test object as a configuration.
@ -20,6 +23,18 @@ class BaseTestCase(TestCase):
def setUp(self): def setUp(self):
db.create_all() db.create_all()
# Add dummy data to CurrencyConverter for all tests (since it's a singleton)
mock_data = {
"USD": 1,
"EUR": 0.8,
"CAD": 1.2,
"PLN": 4,
CurrencyConverter.no_currency: 1,
}
converter = CurrencyConverter()
converter.get_rates = MagicMock(return_value=mock_data)
# Also add it to an attribute to make tests clearer
self.converter = converter
def tearDown(self): def tearDown(self):
# clean after testing # clean after testing

View file

@ -31,6 +31,7 @@ class ConfigurationTestCase(BaseTestCase):
self.assertTrue(self.app.config["ACTIVATE_DEMO_PROJECT"]) self.assertTrue(self.app.config["ACTIVATE_DEMO_PROJECT"])
self.assertTrue(self.app.config["ALLOW_PUBLIC_PROJECT_CREATION"]) self.assertTrue(self.app.config["ALLOW_PUBLIC_PROJECT_CREATION"])
self.assertFalse(self.app.config["ACTIVATE_ADMIN_DASHBOARD"]) self.assertFalse(self.app.config["ACTIVATE_ADMIN_DASHBOARD"])
self.assertFalse(self.app.config["ENABLE_CAPTCHA"])
def test_env_var_configuration_file(self): def test_env_var_configuration_file(self):
"""Test that settings are loaded from a configuration file specified """Test that settings are loaded from a configuration file specified
@ -241,9 +242,59 @@ class EmailFailureTestCase(IhatemoneyTestCase):
) )
class CaptchaTestCase(IhatemoneyTestCase):
ENABLE_CAPTCHA = True
def test_project_creation_with_captcha(self):
with self.app.test_client() as c:
c.post(
"/create",
data={
"name": "raclette party",
"id": "raclette",
"password": "party",
"contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
},
)
assert len(models.Project.query.all()) == 0
c.post(
"/create",
data={
"name": "raclette party",
"id": "raclette",
"password": "party",
"contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
"captcha": "nope",
},
)
assert len(models.Project.query.all()) == 0
c.post(
"/create",
data={
"name": "raclette party",
"id": "raclette",
"password": "party",
"contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
"captcha": "euro",
},
)
assert len(models.Project.query.all()) == 1
class TestCurrencyConverter(unittest.TestCase): class TestCurrencyConverter(unittest.TestCase):
converter = CurrencyConverter() converter = CurrencyConverter()
mock_data = {"USD": 1, "EUR": 0.8, "CAD": 1.2, CurrencyConverter.no_currency: 1} mock_data = {
"USD": 1,
"EUR": 0.8,
"CAD": 1.2,
"PLN": 4,
CurrencyConverter.no_currency: 1,
}
converter.get_rates = MagicMock(return_value=mock_data) converter.get_rates = MagicMock(return_value=mock_data)
def test_only_one_instance(self): def test_only_one_instance(self):
@ -254,7 +305,7 @@ class TestCurrencyConverter(unittest.TestCase):
def test_get_currencies(self): def test_get_currencies(self):
self.assertCountEqual( self.assertCountEqual(
self.converter.get_currencies(), self.converter.get_currencies(),
["USD", "EUR", "CAD", CurrencyConverter.no_currency], ["USD", "EUR", "CAD", "PLN", CurrencyConverter.no_currency],
) )
def test_exchange_currency(self): def test_exchange_currency(self):

View file

@ -30,7 +30,7 @@ msgid "New private code"
msgstr "ব্যক্তিগত কোড" msgstr "ব্যক্তিগত কোড"
msgid "Enter a new code if you want to change it" msgid "Enter a new code if you want to change it"
msgstr "" msgstr "আপনি যদি এটি পরিবর্তন করতে চান তবে একটি নতুন কোড লিখুন"
msgid "Email" msgid "Email"
msgstr "ইমেইল" msgstr "ইমেইল"
@ -39,10 +39,10 @@ msgid "Enable project history"
msgstr "প্রকল্পের ইতিহাস সক্রিয় করো" msgstr "প্রকল্পের ইতিহাস সক্রিয় করো"
msgid "Use IP tracking for project history" msgid "Use IP tracking for project history"
msgstr "" msgstr "প্রকল্পের ইতিহাসের জন্য আইপি ট্র্যাকিং ব্যবহার করুন"
msgid "Default Currency" msgid "Default Currency"
msgstr "" msgstr "ডিফল্ট মুদ্রা"
msgid "" msgid ""
"This project cannot be set to 'no currency' because it contains bills in " "This project cannot be set to 'no currency' because it contains bills in "
@ -50,19 +50,19 @@ msgid ""
msgstr "" msgstr ""
msgid "Import previously exported JSON file" msgid "Import previously exported JSON file"
msgstr "" msgstr "পূর্বে রপ্তানি করা JSON ফাইল আমদানি করুন"
msgid "Import" msgid "Import"
msgstr "" msgstr "আমদানি"
msgid "Project identifier" msgid "Project identifier"
msgstr "" msgstr "প্রকল্প শনাক্তকারী"
msgid "Private code" msgid "Private code"
msgstr "ব্যক্তিগত কোড" msgstr "ব্যক্তিগত কোড"
msgid "Create the project" msgid "Create the project"
msgstr "" msgstr "প্রকল্প তৈরি করুন"
#, python-format #, python-format
msgid "" msgid ""
@ -71,20 +71,21 @@ msgid ""
msgstr "" msgstr ""
msgid "Enter private code to confirm deletion" msgid "Enter private code to confirm deletion"
msgstr "" msgstr "মুছে ফেলার জন্য ব্যক্তিগত কোড লিখুন"
msgid "Unknown error" msgid "Unknown error"
msgstr "" msgstr "অজানা ত্রুটি
"
#, fuzzy #, fuzzy
msgid "Invalid private code." msgid "Invalid private code."
msgstr "ব্যক্তিগত কোড" msgstr "ব্যক্তিগত কোড"
msgid "Get in" msgid "Get in"
msgstr "" msgstr "ভিতরে আস"
msgid "Admin password" msgid "Admin password"
msgstr "" msgstr "অ্যাডমিন পাসওয়ার্ড"
msgid "Send me the code by email" msgid "Send me the code by email"
msgstr "" msgstr ""

View file

@ -1,18 +1,18 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-07-17 17:31+0200\n" "POT-Creation-Date: 2021-07-17 17:31+0200\n"
"PO-Revision-Date: 2021-01-08 14:32+0000\n" "PO-Revision-Date: 2021-09-05 13:34+0000\n"
"Last-Translator: Oliver Klimt <klimt.oliver@gmail.com>\n" "Last-Translator: Clonewayx <fillip1@seznam.cz>\n"
"Language-Team: Czech <https://hosted.weblate.org/projects/i-hate-money/"
"i-hate-money/cs/>\n"
"Language: cs\n" "Language: cs\n"
"Language-Team: Czech <https://hosted.weblate.org/projects/i-hate-money/i"
"-hate-money/cs/>\n"
"Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2\n"
"MIME-Version: 1.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" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n"
"X-Generator: Weblate 4.8.1-dev\n"
"Generated-By: Babel 2.9.0\n" "Generated-By: Babel 2.9.0\n"
msgid "" msgid ""
@ -23,12 +23,11 @@ msgstr "Neplatná částka nebo výraz. Pouze čísla a operátory + - * / jsou
msgid "Project name" msgid "Project name"
msgstr "Název projektu" msgstr "Název projektu"
#, fuzzy
msgid "New private code" msgid "New private code"
msgstr "Přístupový kód" msgstr "Nový soukromý kód"
msgid "Enter a new code if you want to change it" msgid "Enter a new code if you want to change it"
msgstr "" msgstr "Pokud chcete provést změnu vložte nový kód"
msgid "Email" msgid "Email"
msgstr "Email" msgstr "Email"
@ -46,6 +45,8 @@ msgid ""
"This project cannot be set to 'no currency' because it contains bills in " "This project cannot be set to 'no currency' because it contains bills in "
"multiple currencies." "multiple currencies."
msgstr "" msgstr ""
"Tento projekt nemůže být nastaven na 'bez měny' protože obsahuje účty v "
"různých měnáh."
msgid "Import previously exported JSON file" msgid "Import previously exported JSON file"
msgstr "Import exportovaného JSON souboru" msgstr "Import exportovaného JSON souboru"
@ -71,14 +72,13 @@ msgstr ""
"nový identifikátor" "nový identifikátor"
msgid "Enter private code to confirm deletion" msgid "Enter private code to confirm deletion"
msgstr "" msgstr "Potvrďte smazání vložením soukromého kódu"
msgid "Unknown error" msgid "Unknown error"
msgstr "" msgstr "Neznámá chyba"
#, fuzzy
msgid "Invalid private code." msgid "Invalid private code."
msgstr "Přístupový kód" msgstr "Neplatný soukromý přístupový kód."
msgid "Get in" msgid "Get in"
msgstr "Vstoupit" msgstr "Vstoupit"
@ -145,7 +145,7 @@ msgid "Name"
msgstr "Jméno" msgstr "Jméno"
msgid "Weights should be positive" msgid "Weights should be positive"
msgstr "" msgstr "Váhy musí být kladné"
msgid "Weight" msgid "Weight"
msgstr "Váha" msgstr "Váha"
@ -171,11 +171,11 @@ msgstr "Toto (%(email)s) není validní e-mail"
#. List with two items only #. List with two items only
msgid "{dual_object_0} and {dual_object_1}" msgid "{dual_object_0} and {dual_object_1}"
msgstr "" msgstr "{dual_object_0} a {dual_object_1}"
#. Last two items of a list with more than 3 items #. Last two items of a list with more than 3 items
msgid "{previous_object}, and {end_object}" msgid "{previous_object}, and {end_object}"
msgstr "" msgstr "{previous_object}, a {end_object}"
#. Two items in a middle of a list with more than 5 objects #. Two items in a middle of a list with more than 5 objects
msgid "{previous_object}, {next_object}" msgid "{previous_object}, {next_object}"
@ -230,37 +230,42 @@ msgid ""
"instructions. Please check the email configuration of the server or " "instructions. Please check the email configuration of the server or "
"contact the administrator." "contact the administrator."
msgstr "" msgstr ""
"Omlouváme se, během odesílání emailu s instrukcemi pro obnovení hesla se "
"vyskytla chyba. Zkontrolujte si prosím nastavení vašeho emailového serveru "
"nebo kontaktujte administrátora."
#, fuzzy
msgid "No token provided" msgid "No token provided"
msgstr "" msgstr "Nebyl vložen klíč"
#, fuzzy
msgid "Invalid token" msgid "Invalid token"
msgstr "" msgstr "Neplatná klíč"
msgid "Unknown project" msgid "Unknown project"
msgstr "" msgstr "Neznámý projekt"
msgid "Password successfully reset." msgid "Password successfully reset."
msgstr "" msgstr "Heslo bylo úspěšné obnoveno."
msgid "Project successfully uploaded" msgid "Project successfully uploaded"
msgstr "" msgstr "Projekt byl úspěšně nahrán."
msgid "Invalid JSON" msgid "Invalid JSON"
msgstr "" msgstr "Neplatný JSON"
msgid "Project successfully deleted" msgid "Project successfully deleted"
msgstr "" msgstr "Projekt byl úspěšně smazán"
msgid "Error deleting project" msgid "Error deleting project"
msgstr "" msgstr "Nastala chyba při mazání projektu"
#, python-format #, python-format
msgid "You have been invited to share your expenses for %(project)s" msgid "You have been invited to share your expenses for %(project)s"
msgstr "" msgstr ""
msgid "Your invitations have been sent" msgid "Your invitations have been sent"
msgstr "" msgstr "Vaše pozvánka byla odeslána"
msgid "" msgid ""
"Sorry, there was an error while trying to send the invitation emails. " "Sorry, there was an error while trying to send the invitation emails. "

View file

@ -3,8 +3,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-07-17 17:31+0200\n" "POT-Creation-Date: 2021-07-17 17:31+0200\n"
"PO-Revision-Date: 2021-08-19 20:34+0000\n" "PO-Revision-Date: 2021-09-23 19:36+0000\n"
"Last-Translator: corny <nico.eckstein+weblate@gmail.com>\n" "Last-Translator: Christian H. <sunrisechain@gmail.com>\n"
"Language-Team: German <https://hosted.weblate.org/projects/i-hate-money/" "Language-Team: German <https://hosted.weblate.org/projects/i-hate-money/"
"i-hate-money/de/>\n" "i-hate-money/de/>\n"
"Language: de\n" "Language: de\n"
@ -12,7 +12,7 @@ msgstr ""
"Content-Type: text/plain; charset=utf-8\n" "Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n" "Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 4.8-dev\n" "X-Generator: Weblate 4.9-dev\n"
"Generated-By: Babel 2.9.0\n" "Generated-By: Babel 2.9.0\n"
msgid "" msgid ""
@ -74,7 +74,7 @@ msgstr ""
"wähle eine andere Kennung" "wähle eine andere Kennung"
msgid "Enter private code to confirm deletion" msgid "Enter private code to confirm deletion"
msgstr "" msgstr "Geben Sie Ihren privaten Code ein, um die Löschung zu bestätigen"
msgid "Unknown error" msgid "Unknown error"
msgstr "Unbekannter Fehler" msgstr "Unbekannter Fehler"

View file

@ -1,18 +1,18 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-07-17 17:31+0200\n" "POT-Creation-Date: 2021-07-17 17:31+0200\n"
"PO-Revision-Date: 2021-07-10 15:34+0000\n" "PO-Revision-Date: 2021-10-01 20:35+0000\n"
"Last-Translator: phlostically <phlostically@mailinator.com>\n" "Last-Translator: phlostically <phlostically@mailinator.com>\n"
"Language-Team: Esperanto <https://hosted.weblate.org/projects/i-hate-money/"
"i-hate-money/eo/>\n"
"Language: eo\n" "Language: eo\n"
"Language-Team: Esperanto <https://hosted.weblate.org/projects/i-hate-"
"money/i-hate-money/eo/>\n"
"Plural-Forms: nplurals=2; plural=n != 1\n"
"MIME-Version: 1.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" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 4.9-dev\n"
"Generated-By: Babel 2.9.0\n" "Generated-By: Babel 2.9.0\n"
msgid "" msgid ""
@ -76,13 +76,11 @@ msgstr ""
msgid "Enter private code to confirm deletion" msgid "Enter private code to confirm deletion"
msgstr "" msgstr ""
#, fuzzy
msgid "Unknown error" msgid "Unknown error"
msgstr "Nekonata projekto" msgstr "Nekonata eraro"
#, fuzzy
msgid "Invalid private code." msgid "Invalid private code."
msgstr "Nova privata kodo" msgstr "Nevalida privata kodo."
msgid "Get in" msgid "Get in"
msgstr "Eniri" msgstr "Eniri"
@ -175,30 +173,30 @@ msgstr "La retpoŝta adreso %(email)s ne validas"
#. List with two items only #. List with two items only
msgid "{dual_object_0} and {dual_object_1}" msgid "{dual_object_0} and {dual_object_1}"
msgstr "" msgstr "{dual_object_0} kaj {dual_object_1}"
#. Last two items of a list with more than 3 items #. Last two items of a list with more than 3 items
msgid "{previous_object}, and {end_object}" msgid "{previous_object}, and {end_object}"
msgstr "" msgstr "{previous_object} kaj {end_object}"
#. Two items in a middle of a list with more than 5 objects #. Two items in a middle of a list with more than 5 objects
msgid "{previous_object}, {next_object}" msgid "{previous_object}, {next_object}"
msgstr "" msgstr "{previous_object}, {next_object}"
#. First two items of a list with more than 3 items #. First two items of a list with more than 3 items
msgid "{start_object}, {next_object}" msgid "{start_object}, {next_object}"
msgstr "" msgstr "{start_object}, {next_object}"
msgid "No Currency" msgid "No Currency"
msgstr "Neniu valuto" msgstr "Neniu valuto"
#. Form error with only one error #. Form error with only one error
msgid "{prefix}: {error}" msgid "{prefix}: {error}"
msgstr "" msgstr "{prefix}: {error}"
#. Form error with a list of errors #. Form error with a list of errors
msgid "{prefix}:<br />{errors}" msgid "{prefix}:<br />{errors}"
msgstr "" msgstr "{prefix}:<br />{errors}"
msgid "Too many failed login attempts, please retry later." msgid "Too many failed login attempts, please retry later."
msgstr "Tro da malsukcesaj provoj de salutado; bonvolu reprovi poste." msgstr "Tro da malsukcesaj provoj de salutado; bonvolu reprovi poste."
@ -396,16 +394,14 @@ msgstr "Elŝuti programon por poŝaparato"
msgid "Get it on" msgid "Get it on"
msgstr "Elŝuti ĝin ĉe" msgstr "Elŝuti ĝin ĉe"
#, fuzzy
msgid "Are you sure?" msgid "Are you sure?"
msgstr "ĉu vi certas?" msgstr "Ĉu vi certas?"
msgid "Edit project" msgid "Edit project"
msgstr "Redakti projekton" msgstr "Redakti projekton"
#, fuzzy
msgid "Delete project" msgid "Delete project"
msgstr "Redakti projekton" msgstr "Forviŝi projekton"
msgid "Import JSON" msgid "Import JSON"
msgstr "Enporti JSON-dosieron" msgstr "Enporti JSON-dosieron"
@ -453,7 +449,7 @@ msgid "Everyone"
msgstr "Ĉiuj" msgstr "Ĉiuj"
msgid "No one" msgid "No one"
msgstr "" msgstr "Neniu"
msgid "More options" msgid "More options"
msgstr "" msgstr ""

Binary file not shown.

View file

@ -0,0 +1,881 @@
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-10-13 09:18+0200\n"
"PO-Revision-Date: 2021-10-13 20:00+0000\n"
"Last-Translator: a-g-rao <athrigrao@gmail.com>\n"
"Language-Team: Kannada <https://hosted.weblate.org/projects/i-hate-money/"
"i-hate-money/kn/>\n"
"Language: kn\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 4.9-dev\n"
msgid ""
"Not a valid amount or expression. Only numbers and + - * / operators are "
"accepted."
msgstr ""
"ಮೊತ್ತ ಅಥವಾ ಪದಕಂತೆ ಸರಿಯಿಲ್ಲ. ಸಂಖ್ಯೆ ಮತ್ತು +-*/ ಎಣಿಕೆಬಳಕಗಳನ್ನು ಮಾತ್ರ "
"ಸ್ವೀಕರಿಸಲಾಗುವುದು."
msgid "Project name"
msgstr "ಯೋಜನೆಯ ಹೆಸರು"
msgid "New private code"
msgstr "ಹೊಸ ಸಂಕೇತಪದ"
msgid "Enter a new code if you want to change it"
msgstr "ಸಂಕೇತಪದ ಬದಲಾಯಿಸಬೇಕೆಂದರೆ ನಮೂದಿಸಿ."
msgid "Email"
msgstr "ಮಿನ್ನಂಚೆ"
msgid "Enable project history"
msgstr "ಯೋಜನೆ ಇತಿಹಾಸ ಸಕ್ರಿಯಗೊಳಿಸಿ"
msgid "Use IP tracking for project history"
msgstr ""
msgid "Default Currency"
msgstr ""
msgid ""
"This project cannot be set to 'no currency' because it contains bills in "
"multiple currencies."
msgstr ""
msgid "Import previously exported JSON file"
msgstr ""
msgid "Import"
msgstr "ಆಮದು"
msgid "Project identifier"
msgstr ""
msgid "Private code"
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 "Enter private code to confirm deletion"
msgstr "ತೆಗೆದುಹಾಕಲು ನಿಮ್ಮ ಸಂಕೇತಪದವನ್ನು ನಮೂದಿಸಿ"
msgid "Unknown error"
msgstr "ಅಜ್ಞಾತ ದೋಷ"
msgid "Invalid private code."
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 "Currency"
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 "ಕೋರಿಕೆ ಸಲ್ಲಿಸಿ ಹೊಸದನ್ನುಸೇರಿಸು"
#, python-format
msgid "Project default: %(currency)s"
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)s ಸಮಂಜಸವಾಗಿಲ್ಲ"
#. List with two items only
msgid "{dual_object_0} and {dual_object_1}"
msgstr "{dual_object_0} ಮತ್ತು {dual_object_1}"
#. Last two items of a list with more than 3 items
msgid "{previous_object}, and {end_object}"
msgstr "{previous_object}, ಮತ್ತು{end_object}"
#. Two items in a middle of a list with more than 5 objects
msgid "{previous_object}, {next_object}"
msgstr "{previous_object}, {next_object}"
#. First two items of a list with more than 3 items
msgid "{start_object}, {next_object}"
msgstr "{start_object}, {next_object}"
msgid "No Currency"
msgstr ""
#. Form error with only one error
msgid "{prefix}: {error}"
msgstr "{prefix}: {error}"
#. Form error with a list of errors
msgid "{prefix}:<br />{errors}"
msgstr "{prefix}:<br />{errors}"
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 ""
msgid "A reminder email has just been sent to you"
msgstr ""
msgid ""
"We tried to send you an reminder email, but there was an error. You can "
"still use the project normally."
msgstr ""
#, python-format
msgid "The project identifier is %(project)s"
msgstr ""
msgid ""
"Sorry, there was an error while sending you an email with password reset "
"instructions. Please check the email configuration of the server or "
"contact the administrator."
msgstr ""
msgid "No token provided"
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 ""
msgid "Error deleting project"
msgstr ""
#, python-format
msgid "You have been invited to share your expenses for %(project)s"
msgstr ""
msgid "Your invitations have been sent"
msgstr ""
msgid ""
"Sorry, there was an error while trying to send the invitation emails. "
"Please check the email configuration of the server or contact the "
"administrator."
msgstr ""
#, python-format
msgid "%(member)s has been added"
msgstr ""
msgid "Error activating member"
msgstr ""
#, python-format
msgid "%(name)s is part of this project again"
msgstr ""
msgid "Error removing member"
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 "Error deleting bill"
msgstr ""
msgid "The bill has been deleted"
msgstr ""
msgid "The bill has been modified"
msgstr ""
msgid "Error deleting project history"
msgstr ""
msgid "Deleted project history."
msgstr ""
msgid "Error deleting recorded IP addresses"
msgstr ""
msgid "Deleted recorded IP addresses in project history."
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 "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 "show"
msgstr ""
msgid "The Dashboard is currently deactivated."
msgstr ""
msgid "Download Mobile Application"
msgstr ""
msgid "Get it on"
msgstr ""
msgid "Are you sure?"
msgstr ""
msgid "Edit project"
msgstr ""
msgid "Delete 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 "This will remove all bills and participants in this project!"
msgstr ""
msgid "Edit this bill"
msgstr ""
msgid "Add a bill"
msgstr ""
msgid "Everyone"
msgstr ""
msgid "No one"
msgstr ""
msgid "More options"
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 ""
#, python-format
msgid "Bill %(name)s: %(property_name)s changed from %(before)s to %(after)s"
msgstr ""
#, python-format
msgid "Bill %(name)s: %(property_name)s changed to %(after)s"
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 "Confirm deletion"
msgstr ""
msgid "Close"
msgstr ""
msgid "Delete Confirmation"
msgstr ""
msgid ""
"Are you sure you want to erase all history for this project? This action "
"cannot be undone."
msgstr ""
#, python-format
msgid "Bill %(name)s: added %(owers_list_str)s to owers list"
msgstr ""
#, python-format
msgid "Bill %(name)s: removed %(owers_list_str)s from owers list"
msgstr ""
#, python-format
msgid ""
"\n"
" <i>This project has history disabled. New actions won't "
"appear below. You can enable history on the</i>\n"
" <a href=\"%(url)s\">settings page</a>\n"
" "
msgstr ""
msgid ""
"\n"
" <i>The table below reflects actions recorded prior to "
"disabling project history. You can\n"
" <a href=\"#\" data-toggle=\"modal\" data-keyboard=\"false\" "
"data-target=\"#confirm-erase\">clear project history</a> to remove "
"them.</i></p>\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 ""
#, python-format
msgid "Project %(name)s added"
msgstr ""
#, python-format
msgid "Bill %(name)s added"
msgstr ""
#, python-format
msgid "Participant %(name)s added"
msgstr ""
msgid "Project private code changed"
msgstr ""
#, python-format
msgid "Project renamed to %(new_project_name)s"
msgstr ""
#, python-format
msgid "Project contact email changed to %(new_email)s"
msgstr ""
msgid "Project settings modified"
msgstr ""
#, python-format
msgid "Participant %(name)s deactivated"
msgstr ""
#, python-format
msgid "Participant %(name)s reactivated"
msgstr ""
#, python-format
msgid "Participant %(name)s renamed to %(new_name)s"
msgstr ""
#, python-format
msgid "Bill %(name)s renamed to %(new_description)s"
msgstr ""
#, python-format
msgid "Participant %(name)s: weight changed from %(old_weight)s to %(new_weight)s"
msgstr ""
msgid "Amount"
msgstr ""
#, python-format
msgid "Amount in %(currency)s"
msgstr ""
#, python-format
msgid "Bill %(name)s modified"
msgstr ""
#, python-format
msgid "Participant %(name)s modified"
msgstr ""
#, python-format
msgid "Bill %(name)s removed"
msgstr ""
#, python-format
msgid "Participant %(name)s removed"
msgstr ""
#, python-format
msgid "Project %(name)s changed in an unknown way"
msgstr ""
#, python-format
msgid "Bill %(name)s changed in an unknown way"
msgstr ""
#, python-format
msgid "Participant %(name)s changed in an unknown way"
msgstr ""
msgid "Nothing to list"
msgstr ""
msgid "Someone probably cleared the project history."
msgstr ""
msgid "Manage your shared <br />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 ""
"Don\\'t reuse a personal password. Choose a private code and send it to "
"your friends"
msgstr ""
msgid "Account manager"
msgstr ""
msgid "Bills"
msgstr ""
msgid "Settle"
msgstr ""
msgid "Statistics"
msgstr ""
msgid "Languages"
msgstr ""
msgid "Projects"
msgstr ""
msgid "Start a new project"
msgstr ""
msgid "History"
msgstr ""
msgid "Settings"
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 free software"
msgstr ""
msgid "you can contribute and improve it!"
msgstr ""
#, python-format
msgid "%(amount)s each"
msgstr ""
msgid "you sure?"
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 ""
#, python-format
msgid "Everyone but %(excluded)s"
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 "Who?"
msgstr ""
msgid "Balance"
msgstr ""
msgid "deactivate"
msgstr ""
msgid "reactivate"
msgstr ""
msgid "Paid"
msgstr ""
msgid "Spent"
msgstr ""
msgid "Expenses by Month"
msgstr ""
msgid "Period"
msgstr ""

View file

@ -1,19 +1,19 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-07-17 17:31+0200\n" "POT-Creation-Date: 2021-07-17 17:31+0200\n"
"PO-Revision-Date: 2021-05-10 11:33+0000\n" "PO-Revision-Date: 2021-09-20 12:38+0000\n"
"Last-Translator: Vsevolod <sevauserg.com@gmail.com>\n" "Last-Translator: Роман Прокопов <pochta.romana.iz.vyborga@gmail.com>\n"
"Language-Team: Russian <https://hosted.weblate.org/projects/i-hate-money/"
"i-hate-money/ru/>\n"
"Language: ru\n" "Language: ru\n"
"Language-Team: Russian <https://hosted.weblate.org/projects/i-hate-"
"money/i-hate-money/ru/>\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" "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" "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.9-dev\n"
"Generated-By: Babel 2.9.0\n" "Generated-By: Babel 2.9.0\n"
msgid "" msgid ""
@ -26,9 +26,8 @@ msgstr ""
msgid "Project name" msgid "Project name"
msgstr "Имя проекта" msgstr "Имя проекта"
#, fuzzy
msgid "New private code" msgid "New private code"
msgstr "Приватный код" msgstr "Новый приватный код"
msgid "Enter a new code if you want to change it" msgid "Enter a new code if you want to change it"
msgstr "" msgstr ""

Binary file not shown.

View file

@ -0,0 +1,880 @@
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-09-15 15:41+0200\n"
"PO-Revision-Date: 2021-09-16 14:36+0000\n"
"Last-Translator: PPNplus <ppnplus@protonmail.com>\n"
"Language-Team: Thai <https://hosted.weblate.org/projects/i-hate-money/"
"i-hate-money/th/>\n"
"Language: th\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 4.9-dev\n"
msgid ""
"Not a valid amount or expression. Only numbers and + - * / operators are "
"accepted."
msgstr ""
msgid "Project name"
msgstr "ชื่อโครงการ"
msgid "New private code"
msgstr ""
msgid "Enter a new code if you want to change it"
msgstr ""
msgid "Email"
msgstr "อีเมล"
msgid "Enable project history"
msgstr "เปิดใช้ประวัติโครงการ"
msgid "Use IP tracking for project history"
msgstr "ใช้การติดตามที่อยู่ IP สำหรับประวัติโครงการ"
msgid "Default Currency"
msgstr "สกุลเงินค่าเริ่มต้น"
msgid ""
"This project cannot be set to 'no currency' because it contains bills in "
"multiple currencies."
msgstr ""
msgid "Import previously exported JSON file"
msgstr ""
msgid "Import"
msgstr "นำเข้า"
msgid "Project identifier"
msgstr ""
msgid "Private code"
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 "Enter private code to confirm deletion"
msgstr ""
msgid "Unknown error"
msgstr "ข้อผิดพลาดที่ไม่รู้จัก"
msgid "Invalid private code."
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 "Currency"
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 "ส่งและเพิ่มอันใหม่"
#, python-format
msgid "Project default: %(currency)s"
msgstr "ค่าเริ่มต้นของโครงการ: %(currency)s"
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)s ไม่ถูกต้อง"
#. List with two items only
msgid "{dual_object_0} and {dual_object_1}"
msgstr "{dual_object_0} และ {dual_object_1}"
#. Last two items of a list with more than 3 items
msgid "{previous_object}, and {end_object}"
msgstr "{previous_object}, และ {end_object}"
#. Two items in a middle of a list with more than 5 objects
msgid "{previous_object}, {next_object}"
msgstr "{previous_object}, {next_object}"
#. First two items of a list with more than 3 items
msgid "{start_object}, {next_object}"
msgstr "{start_object}, {next_object}"
msgid "No Currency"
msgstr "ไม่มีสกุลเงิน"
#. Form error with only one error
msgid "{prefix}: {error}"
msgstr ""
#. Form error with a list of errors
msgid "{prefix}:<br />{errors}"
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 ""
msgid "A reminder email has just been sent to you"
msgstr "ส่งอีเมลเตือนความจำให้คุณแล้ว"
msgid ""
"We tried to send you an reminder email, but there was an error. You can "
"still use the project normally."
msgstr ""
#, python-format
msgid "The project identifier is %(project)s"
msgstr ""
msgid ""
"Sorry, there was an error while sending you an email with password reset "
"instructions. Please check the email configuration of the server or "
"contact the administrator."
msgstr ""
msgid "No token provided"
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 ""
msgid "Error deleting project"
msgstr ""
#, python-format
msgid "You have been invited to share your expenses for %(project)s"
msgstr ""
msgid "Your invitations have been sent"
msgstr ""
msgid ""
"Sorry, there was an error while trying to send the invitation emails. "
"Please check the email configuration of the server or contact the "
"administrator."
msgstr ""
#, python-format
msgid "%(member)s has been added"
msgstr ""
msgid "Error activating member"
msgstr ""
#, python-format
msgid "%(name)s is part of this project again"
msgstr ""
msgid "Error removing member"
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 "Error deleting bill"
msgstr ""
msgid "The bill has been deleted"
msgstr ""
msgid "The bill has been modified"
msgstr ""
msgid "Error deleting project history"
msgstr ""
msgid "Deleted project history."
msgstr ""
msgid "Error deleting recorded IP addresses"
msgstr ""
msgid "Deleted recorded IP addresses in project history."
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 "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 "show"
msgstr ""
msgid "The Dashboard is currently deactivated."
msgstr ""
msgid "Download Mobile Application"
msgstr ""
msgid "Get it on"
msgstr ""
msgid "Are you sure?"
msgstr ""
msgid "Edit project"
msgstr ""
msgid "Delete 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 "This will remove all bills and participants in this project!"
msgstr ""
msgid "Edit this bill"
msgstr ""
msgid "Add a bill"
msgstr ""
msgid "Everyone"
msgstr "ทุกคน"
msgid "No one"
msgstr "ไม่มีใคร"
msgid "More options"
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 ""
#, python-format
msgid "Bill %(name)s: %(property_name)s changed from %(before)s to %(after)s"
msgstr ""
#, python-format
msgid "Bill %(name)s: %(property_name)s changed to %(after)s"
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 "Confirm deletion"
msgstr ""
msgid "Close"
msgstr ""
msgid "Delete Confirmation"
msgstr ""
msgid ""
"Are you sure you want to erase all history for this project? This action "
"cannot be undone."
msgstr ""
#, python-format
msgid "Bill %(name)s: added %(owers_list_str)s to owers list"
msgstr ""
#, python-format
msgid "Bill %(name)s: removed %(owers_list_str)s from owers list"
msgstr ""
#, python-format
msgid ""
"\n"
" <i>This project has history disabled. New actions won't "
"appear below. You can enable history on the</i>\n"
" <a href=\"%(url)s\">settings page</a>\n"
" "
msgstr ""
msgid ""
"\n"
" <i>The table below reflects actions recorded prior to "
"disabling project history. You can\n"
" <a href=\"#\" data-toggle=\"modal\" data-keyboard=\"false\" "
"data-target=\"#confirm-erase\">clear project history</a> to remove "
"them.</i></p>\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 ""
#, python-format
msgid "Project %(name)s added"
msgstr ""
#, python-format
msgid "Bill %(name)s added"
msgstr ""
#, python-format
msgid "Participant %(name)s added"
msgstr ""
msgid "Project private code changed"
msgstr ""
#, python-format
msgid "Project renamed to %(new_project_name)s"
msgstr ""
#, python-format
msgid "Project contact email changed to %(new_email)s"
msgstr ""
msgid "Project settings modified"
msgstr ""
#, python-format
msgid "Participant %(name)s deactivated"
msgstr ""
#, python-format
msgid "Participant %(name)s reactivated"
msgstr ""
#, python-format
msgid "Participant %(name)s renamed to %(new_name)s"
msgstr ""
#, python-format
msgid "Bill %(name)s renamed to %(new_description)s"
msgstr ""
#, python-format
msgid "Participant %(name)s: weight changed from %(old_weight)s to %(new_weight)s"
msgstr ""
msgid "Amount"
msgstr ""
#, python-format
msgid "Amount in %(currency)s"
msgstr ""
#, python-format
msgid "Bill %(name)s modified"
msgstr ""
#, python-format
msgid "Participant %(name)s modified"
msgstr ""
#, python-format
msgid "Bill %(name)s removed"
msgstr ""
#, python-format
msgid "Participant %(name)s removed"
msgstr ""
#, python-format
msgid "Project %(name)s changed in an unknown way"
msgstr ""
#, python-format
msgid "Bill %(name)s changed in an unknown way"
msgstr ""
#, python-format
msgid "Participant %(name)s changed in an unknown way"
msgstr ""
msgid "Nothing to list"
msgstr ""
msgid "Someone probably cleared the project history."
msgstr ""
msgid "Manage your shared <br />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 ""
"Don\\'t reuse a personal password. Choose a private code and send it to "
"your friends"
msgstr ""
msgid "Account manager"
msgstr ""
msgid "Bills"
msgstr ""
msgid "Settle"
msgstr ""
msgid "Statistics"
msgstr ""
msgid "Languages"
msgstr ""
msgid "Projects"
msgstr ""
msgid "Start a new project"
msgstr ""
msgid "History"
msgstr ""
msgid "Settings"
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 free software"
msgstr ""
msgid "you can contribute and improve it!"
msgstr ""
#, python-format
msgid "%(amount)s each"
msgstr ""
msgid "you sure?"
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 ""
#, python-format
msgid "Everyone but %(excluded)s"
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 "Who?"
msgstr ""
msgid "Balance"
msgstr ""
msgid "deactivate"
msgstr ""
msgid "reactivate"
msgstr ""
msgid "Paid"
msgstr ""
msgid "Spent"
msgstr ""
msgid "Expenses by Month"
msgstr ""
msgid "Period"
msgstr ""

View file

@ -1,19 +1,18 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-07-17 17:31+0200\n" "POT-Creation-Date: 2021-07-17 17:31+0200\n"
"PO-Revision-Date: 2020-10-12 04:47+0000\n" "PO-Revision-Date: 2021-10-10 05:05+0000\n"
"Last-Translator: Jwen921 <yangjingwen0921@gmail.com>\n" "Last-Translator: Frank.wu <me@wuzhiping.top>\n"
"Language-Team: Chinese (Simplified) <https://hosted.weblate.org/projects/"
"i-hate-money/i-hate-money/zh_Hans/>\n"
"Language: zh_Hans\n" "Language: zh_Hans\n"
"Language-Team: Chinese (Simplified) "
"<https://hosted.weblate.org/projects/i-hate-money/i-hate-money/zh_Hans/>"
"\n"
"Plural-Forms: nplurals=1; plural=0\n"
"MIME-Version: 1.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" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Generator: Weblate 4.9-dev\n"
"Generated-By: Babel 2.9.0\n" "Generated-By: Babel 2.9.0\n"
msgid "" msgid ""
@ -24,12 +23,11 @@ msgstr "金额或符号无效。仅限数字与+-*/符号。"
msgid "Project name" msgid "Project name"
msgstr "账目名称" msgstr "账目名称"
#, fuzzy
msgid "New private code" msgid "New private code"
msgstr "共享密钥" msgstr "新的私人代码"
msgid "Enter a new code if you want to change it" msgid "Enter a new code if you want to change it"
msgstr "" msgstr "如要更改,请输入新代码"
msgid "Email" msgid "Email"
msgstr "邮箱" msgstr "邮箱"
@ -46,7 +44,7 @@ msgstr "默认货币"
msgid "" msgid ""
"This project cannot be set to 'no currency' because it contains bills in " "This project cannot be set to 'no currency' because it contains bills in "
"multiple currencies." "multiple currencies."
msgstr "" msgstr "此项目不能设置为“无货币”,因为它包含多种货币的账单。"
msgid "Import previously exported JSON file" msgid "Import previously exported JSON file"
msgstr "导入之前的JSON 文件" msgstr "导入之前的JSON 文件"
@ -70,15 +68,13 @@ msgid ""
msgstr "账目(“%(project)s”)已存在,请选择一个新名称" msgstr "账目(“%(project)s”)已存在,请选择一个新名称"
msgid "Enter private code to confirm deletion" msgid "Enter private code to confirm deletion"
msgstr "" msgstr "请输入专用代码以确认删除"
#, fuzzy
msgid "Unknown error" msgid "Unknown error"
msgstr "未知项目" msgstr "未知错误"
#, fuzzy
msgid "Invalid private code." msgid "Invalid private code."
msgstr "共享密钥" msgstr "无效的私人代码。"
msgid "Get in" msgid "Get in"
msgstr "进入" msgstr "进入"
@ -171,40 +167,40 @@ msgstr "此邮箱%(email)s不存在"
#. List with two items only #. List with two items only
msgid "{dual_object_0} and {dual_object_1}" msgid "{dual_object_0} and {dual_object_1}"
msgstr "" msgstr "{dual_object_0} 和 {dual_object_1}"
#. Last two items of a list with more than 3 items #. Last two items of a list with more than 3 items
msgid "{previous_object}, and {end_object}" msgid "{previous_object}, and {end_object}"
msgstr "" msgstr "{previous_object} 和 {end_object}"
#. Two items in a middle of a list with more than 5 objects #. Two items in a middle of a list with more than 5 objects
msgid "{previous_object}, {next_object}" msgid "{previous_object}, {next_object}"
msgstr "" msgstr "{previous_object}{next_object}"
#. First two items of a list with more than 3 items #. First two items of a list with more than 3 items
msgid "{start_object}, {next_object}" msgid "{start_object}, {next_object}"
msgstr "" msgstr "{start_object}, {next_object}"
msgid "No Currency" msgid "No Currency"
msgstr "没有货币" msgstr "货币"
#. Form error with only one error #. Form error with only one error
msgid "{prefix}: {error}" msgid "{prefix}: {error}"
msgstr "" msgstr "{prefix}: {error}"
#. Form error with a list of errors #. Form error with a list of errors
msgid "{prefix}:<br />{errors}" msgid "{prefix}:<br />{errors}"
msgstr "" msgstr "{prefix}:<br />{errors}"
msgid "Too many failed login attempts, please retry later." msgid "Too many failed login attempts, please retry later."
msgstr "输入错误太多次了,请稍后重试。" msgstr "登录失败次数过多,请稍后重试。"
#, python-format #, python-format
msgid "This admin password is not the right one. Only %(num)d attempts left." msgid "This admin password is not the right one. Only %(num)d attempts left."
msgstr "管理密码有误,只剩 %(num)d次尝试机会" msgstr "管理密码有误,只剩 %(num)d次尝试机会"
msgid "You either provided a bad token or no project identifier." msgid "You either provided a bad token or no project identifier."
msgstr "你输入了错误的符号或没有项目标识符。" msgstr "你输入了错误的令牌或没有项目标识符。"
msgid "This private code is not the right one" msgid "This private code is not the right one"
msgstr "专用码不正确" msgstr "专用码不正确"
@ -241,7 +237,7 @@ msgid "Unknown project"
msgstr "未知项目" msgstr "未知项目"
msgid "Password successfully reset." msgid "Password successfully reset."
msgstr "密码重置成功" msgstr "密码重置成功"
msgid "Project successfully uploaded" msgid "Project successfully uploaded"
msgstr "项目成功上传" msgstr "项目成功上传"
@ -253,7 +249,7 @@ msgid "Project successfully deleted"
msgstr "项目成功删除" msgstr "项目成功删除"
msgid "Error deleting project" msgid "Error deleting project"
msgstr "" msgstr "删除项目时出错"
#, python-format #, python-format
msgid "You have been invited to share your expenses for %(project)s" msgid "You have been invited to share your expenses for %(project)s"
@ -273,20 +269,20 @@ msgid "%(member)s has been added"
msgstr "已添加%(member)s" msgstr "已添加%(member)s"
msgid "Error activating member" msgid "Error activating member"
msgstr "" msgstr "激活成员时出错"
#, python-format #, python-format
msgid "%(name)s is part of this project again" msgid "%(name)s is part of this project again"
msgstr "%(name)s 已经在项目里了" msgstr "%(name)s 已经在项目里了"
msgid "Error removing member" msgid "Error removing member"
msgstr "" msgstr "删除成员时出错"
#, python-format #, python-format
msgid "" msgid ""
"User '%(name)s' has been deactivated. It will still appear in the users " "User '%(name)s' has been deactivated. It will still appear in the users "
"list until its balance becomes zero." "list until its balance becomes zero."
msgstr "用户 '%(name)s'已被暂停在余额为0之前会继续显示在用户列表里" msgstr "用户 '%(name)s'已被暂停在余额为0之前会继续显示在用户列表里"
#, python-format #, python-format
msgid "User '%(name)s' has been removed" msgid "User '%(name)s' has been removed"
@ -300,7 +296,7 @@ msgid "The bill has been added"
msgstr "帐单已添加" msgstr "帐单已添加"
msgid "Error deleting bill" msgid "Error deleting bill"
msgstr "" msgstr "删除账单时出错"
msgid "The bill has been deleted" msgid "The bill has been deleted"
msgstr "账单已删除" msgstr "账单已删除"
@ -308,26 +304,23 @@ msgstr "账单已删除"
msgid "The bill has been modified" msgid "The bill has been modified"
msgstr "帐单已修改" msgstr "帐单已修改"
#, fuzzy
msgid "Error deleting project history" msgid "Error deleting project history"
msgstr "启用项目历史" msgstr "删除项目历史记录时出错"
#, fuzzy
msgid "Deleted project history." msgid "Deleted project history."
msgstr "启用项目历史" msgstr "已删除的项目历史记录。"
#, fuzzy
msgid "Error deleting recorded IP addresses" msgid "Error deleting recorded IP addresses"
msgstr "删除已储存的IP地址" msgstr "删除记录的IP地址时出错"
msgid "Deleted recorded IP addresses in project history." msgid "Deleted recorded IP addresses in project history."
msgstr "" msgstr "删除项目历史记录中的 IP 地址。"
msgid "Sorry, we were unable to find the page you've asked for." msgid "Sorry, we were unable to find the page you've asked for."
msgstr "对不起,未找到该页面" msgstr "抱歉,我们无法找到您要求的页面。"
msgid "The best thing to do is probably to get back to the main page." msgid "The best thing to do is probably to get back to the main page."
msgstr "最好的办法是返回主页" msgstr "最好的办法是返回主页"
msgid "Back to the list" msgid "Back to the list"
msgstr "返回列表" msgstr "返回列表"
@ -375,26 +368,22 @@ msgid "show"
msgstr "显示" msgstr "显示"
msgid "The Dashboard is currently deactivated." msgid "The Dashboard is currently deactivated."
msgstr "操作面板失效" msgstr "操作面板失效"
#, fuzzy
msgid "Download Mobile Application" msgid "Download Mobile Application"
msgstr "手机软件" msgstr "下载移动应用程序"
#, fuzzy
msgid "Get it on" msgid "Get it on"
msgstr "进入" msgstr "获取"
#, fuzzy
msgid "Are you sure?" msgid "Are you sure?"
msgstr "确定?" msgstr "是否确定?"
msgid "Edit project" msgid "Edit project"
msgstr "编辑项目" msgstr "编辑项目"
#, fuzzy
msgid "Delete project" msgid "Delete project"
msgstr "编辑项目" msgstr "删除项目"
msgid "Import JSON" msgid "Import JSON"
msgstr "导入json文件" msgstr "导入json文件"
@ -430,7 +419,7 @@ msgid "Edit the project"
msgstr "编辑项目" msgstr "编辑项目"
msgid "This will remove all bills and participants in this project!" msgid "This will remove all bills and participants in this project!"
msgstr "" msgstr "这将删除此项目的所有账单和参与者!"
msgid "Edit this bill" msgid "Edit this bill"
msgstr "编辑帐单" msgstr "编辑帐单"
@ -442,10 +431,10 @@ msgid "Everyone"
msgstr "每个人" msgstr "每个人"
msgid "No one" msgid "No one"
msgstr "" msgstr "无人"
msgid "More options" msgid "More options"
msgstr "" msgstr "更多选项"
msgid "Add participant" msgid "Add participant"
msgstr "添加参与人" msgstr "添加参与人"
@ -485,11 +474,11 @@ msgstr "历史设置改变"
#, python-format #, python-format
msgid "Bill %(name)s: %(property_name)s changed from %(before)s to %(after)s" msgid "Bill %(name)s: %(property_name)s changed from %(before)s to %(after)s"
msgstr "" msgstr "账单 %(name)s %(property_name)s 从 %(before)s 改为 %(after)s"
#, python-format #, python-format
msgid "Bill %(name)s: %(property_name)s changed to %(after)s" msgid "Bill %(name)s: %(property_name)s changed to %(after)s"
msgstr "" msgstr "账单 %(name)s %(property_name)s 改为 %(after)s"
msgid "Confirm Remove IP Adresses" msgid "Confirm Remove IP Adresses"
msgstr "确认移除IP地址" msgstr "确认移除IP地址"
@ -503,7 +492,6 @@ msgstr ""
"你确定要删除此项目里所有的IP地址吗\n" "你确定要删除此项目里所有的IP地址吗\n"
"项目其他内容不受影响,此操作不可撤回。" "项目其他内容不受影响,此操作不可撤回。"
#, fuzzy
msgid "Confirm deletion" msgid "Confirm deletion"
msgstr "确认删除" msgstr "确认删除"
@ -520,11 +508,11 @@ msgstr "确定删除此项目所有记录?此操作不可撤回。"
#, python-format #, python-format
msgid "Bill %(name)s: added %(owers_list_str)s to owers list" msgid "Bill %(name)s: added %(owers_list_str)s to owers list"
msgstr "" msgstr "帐单 %(name)s将 %(owers_list_str)s 添加到所有者列表"
#, python-format #, python-format
msgid "Bill %(name)s: removed %(owers_list_str)s from owers list" msgid "Bill %(name)s: removed %(owers_list_str)s from owers list"
msgstr "" msgstr "账单 %(name)s从所有者列表中删除了 %(owers_list_str)s"
#, python-format #, python-format
msgid "" msgid ""
@ -590,51 +578,51 @@ msgstr "IP地址记录可在设置里禁用"
msgid "From IP" msgid "From IP"
msgstr "从IP" msgstr "从IP"
#, fuzzy, python-format #, python-format
msgid "Project %(name)s added" msgid "Project %(name)s added"
msgstr "账目名称" msgstr "项目 %(name)s 已添加"
#, fuzzy, python-format #, python-format
msgid "Bill %(name)s added" msgid "Bill %(name)s added"
msgstr "帐单已添加" msgstr "帐单 %(name)s 已添加"
#, python-format #, python-format
msgid "Participant %(name)s added" msgid "Participant %(name)s added"
msgstr "" msgstr "成员 %(name)s 已添加"
msgid "Project private code changed" msgid "Project private code changed"
msgstr "项目专用码已更改" msgstr "项目专用码已更改"
#, fuzzy, python-format #, python-format
msgid "Project renamed to %(new_project_name)s" msgid "Project renamed to %(new_project_name)s"
msgstr "项目的标识符是%(project)s" msgstr "项目的标识符是 %(new_project_name)s"
#, fuzzy, python-format #, python-format
msgid "Project contact email changed to %(new_email)s" msgid "Project contact email changed to %(new_email)s"
msgstr "项目联系邮箱更改为" msgstr "项目联系邮箱更改为 %(new_email)s"
msgid "Project settings modified" msgid "Project settings modified"
msgstr "项目设置已修改" msgstr "项目设置已修改"
#, python-format #, python-format
msgid "Participant %(name)s deactivated" msgid "Participant %(name)s deactivated"
msgstr "" msgstr "成员 %(name)s 已停用"
#, python-format #, python-format
msgid "Participant %(name)s reactivated" msgid "Participant %(name)s reactivated"
msgstr "" msgstr "成员 %(name)s 被重新激活"
#, python-format #, python-format
msgid "Participant %(name)s renamed to %(new_name)s" msgid "Participant %(name)s renamed to %(new_name)s"
msgstr "" msgstr "成员 %(name)s 重命名为 %(new_name)s"
#, python-format #, python-format
msgid "Bill %(name)s renamed to %(new_description)s" msgid "Bill %(name)s renamed to %(new_description)s"
msgstr "" msgstr "账单 %(name)s 更名为 %(new_description)s"
#, python-format #, python-format
msgid "Participant %(name)s: weight changed from %(old_weight)s to %(new_weight)s" msgid "Participant %(name)s: weight changed from %(old_weight)s to %(new_weight)s"
msgstr "" msgstr "成员 %(name)s权重从%(old_weight)s变为%(new_weight)s"
msgid "Amount" msgid "Amount"
msgstr "数量" msgstr "数量"
@ -643,33 +631,33 @@ msgstr "数量"
msgid "Amount in %(currency)s" msgid "Amount in %(currency)s"
msgstr "%(currency)s的数量是" msgstr "%(currency)s的数量是"
#, fuzzy, python-format #, python-format
msgid "Bill %(name)s modified" msgid "Bill %(name)s modified"
msgstr "帐单已修改" msgstr "帐单 %(name)s 已修改"
#, python-format #, python-format
msgid "Participant %(name)s modified" msgid "Participant %(name)s modified"
msgstr "" msgstr "成员 %(name)s 已修改"
#, fuzzy, python-format #, python-format
msgid "Bill %(name)s removed" msgid "Bill %(name)s removed"
msgstr "用户 '%(name)s'已被移除" msgstr "账单 %(name)s 已被移除"
#, fuzzy, python-format #, python-format
msgid "Participant %(name)s removed" msgid "Participant %(name)s removed"
msgstr "用户 '%(name)s'已被移除" msgstr "用户 %(name)s 已被移除"
#, fuzzy, python-format #, python-format
msgid "Project %(name)s changed in an unknown way" msgid "Project %(name)s changed in an unknown way"
msgstr "未知的改变" msgstr "项目 %(name)s 以未知方式更改"
#, fuzzy, python-format #, python-format
msgid "Bill %(name)s changed in an unknown way" msgid "Bill %(name)s changed in an unknown way"
msgstr "未知的改变" msgstr "账单 %(name)s 以一种未知的方式更改"
#, fuzzy, python-format #, python-format
msgid "Participant %(name)s changed in an unknown way" msgid "Participant %(name)s changed in an unknown way"
msgstr "未知的改变" msgstr "成员 %(name)s 以未知方式更改"
msgid "Nothing to list" msgid "Nothing to list"
msgstr "无列表" msgstr "无列表"
@ -815,7 +803,7 @@ msgid "No bills"
msgstr "没有账单" msgstr "没有账单"
msgid "Nothing to list yet." msgid "Nothing to list yet."
msgstr "没有列表" msgstr "没有列表"
msgid "You probably want to" msgid "You probably want to"
msgstr "你想要" msgstr "你想要"

View file

@ -271,7 +271,7 @@ def get_members(file):
def same_bill(bill1, bill2): def same_bill(bill1, bill2):
attr = ["what", "payer_name", "payer_weight", "amount", "date", "owers"] attr = ["what", "payer_name", "payer_weight", "amount", "currency", "date", "owers"]
for a in attr: for a in attr:
if bill1[a] != bill2[a]: if bill1[a] != bill2[a]:
return False return False
@ -370,6 +370,7 @@ def localize_list(items, surround_with_em=True):
def render_localized_currency(code, detailed=True): def render_localized_currency(code, detailed=True):
# We cannot use CurrencyConvertor.no_currency here because of circular dependencies
if code == "XXX": if code == "XXX":
return _("No Currency") return _("No Currency")
locale = get_locale() or "en_US" locale = get_locale() or "en_US"

View file

@ -36,6 +36,7 @@ from sqlalchemy_continuum import Operation
from werkzeug.exceptions import NotFound from werkzeug.exceptions import NotFound
from werkzeug.security import check_password_hash, generate_password_hash from werkzeug.security import check_password_hash, generate_password_hash
from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.forms import ( from ihatemoney.forms import (
AdminAuthenticationForm, AdminAuthenticationForm,
AuthenticationForm, AuthenticationForm,
@ -142,7 +143,8 @@ def pull_project(endpoint, values):
raise Redirect303(url_for(".create_project", project_id=project_id)) raise Redirect303(url_for(".create_project", project_id=project_id))
is_admin = session.get("is_admin") is_admin = session.get("is_admin")
if session.get(project.id) or is_admin: is_invitation = endpoint == "main.join_project"
if session.get(project.id) or is_admin or is_invitation:
# add project into kwargs and call the original function # add project into kwargs and call the original function
g.project = project g.project = project
else: else:
@ -194,28 +196,38 @@ def admin():
) )
@main.route("/<project_id>/join/<string:token>", methods=["GET"])
def join_project(token):
project_id = g.project.id
verified_project_id = Project.verify_token(
token, token_type="auth", project_id=project_id
)
if verified_project_id != project_id:
flash(_("Provided token is invalid"), "danger")
return redirect("/")
# maintain a list of visited projects
if "projects" not in session:
session["projects"] = []
# add the project on the top of the list
session["projects"].insert(0, (project_id, g.project.name))
session[project_id] = True
# Set session to permanent to make language choice persist
session.permanent = True
session.update()
return redirect(url_for(".list_bills"))
@main.route("/authenticate", methods=["GET", "POST"]) @main.route("/authenticate", methods=["GET", "POST"])
def authenticate(project_id=None): def authenticate(project_id=None):
"""Authentication form""" """Authentication form"""
form = AuthenticationForm() form = AuthenticationForm()
# Try to get project_id from token first
token = request.args.get("token")
if token:
project_id = Project.verify_token(token, token_type="non_timed_token")
token_auth = True
else:
if not form.id.data and request.args.get("project_id"): if not form.id.data and request.args.get("project_id"):
form.id.data = request.args["project_id"] form.id.data = request.args["project_id"]
project_id = form.id.data project_id = form.id.data
token_auth = False
if project_id is None:
# User doesn't provide project identifier or a valid token
# return to authenticate form
msg = _("You either provided a bad token or no project identifier.")
form["id"].errors = [msg]
return render_template("authenticate.html", form=form)
project = Project.query.get(project_id) project = Project.query.get(project_id) if project_id is not None else None
if not project: if not project:
# If the user try to connect to an unexisting project, we will # If the user try to connect to an unexisting project, we will
# propose him a link to the creation form. # propose him a link to the creation form.
@ -228,13 +240,9 @@ def authenticate(project_id=None):
setattr(g, "project", project) setattr(g, "project", project)
return redirect(url_for(".list_bills")) return redirect(url_for(".list_bills"))
# else do form authentication or token authentication # else do form authentication authentication
is_post_auth = request.method == "POST" and form.validate() is_post_auth = request.method == "POST" and form.validate()
if ( if is_post_auth and check_password_hash(project.password, form.password.data):
is_post_auth
and check_password_hash(project.password, form.password.data)
or token_auth
):
# maintain a list of visited projects # maintain a list of visited projects
if "projects" not in session: if "projects" not in session:
session["projects"] = [] session["projects"] = []
@ -253,9 +261,16 @@ def authenticate(project_id=None):
return render_template("authenticate.html", form=form) return render_template("authenticate.html", form=form)
def get_project_form():
if current_app.config.get("ENABLE_CAPTCHA", False):
ProjectForm.enable_captcha()
return ProjectForm()
@main.route("/", strict_slashes=False) @main.route("/", strict_slashes=False)
def home(): def home():
project_form = ProjectForm() project_form = get_project_form()
auth_form = AuthenticationForm() auth_form = AuthenticationForm()
is_demo_project_activated = current_app.config["ACTIVATE_DEMO_PROJECT"] is_demo_project_activated = current_app.config["ACTIVATE_DEMO_PROJECT"]
is_public_project_creation_allowed = current_app.config[ is_public_project_creation_allowed = current_app.config[
@ -280,7 +295,7 @@ def mobile():
@main.route("/create", methods=["GET", "POST"]) @main.route("/create", methods=["GET", "POST"])
@requires_admin(bypass=("ALLOW_PUBLIC_PROJECT_CREATION", True)) @requires_admin(bypass=("ALLOW_PUBLIC_PROJECT_CREATION", True))
def create_project(): def create_project():
form = ProjectForm() form = get_project_form()
if request.method == "GET" and "project_id" in request.values: if request.method == "GET" and "project_id" in request.values:
form.name.data = request.values["project_id"] form.name.data = request.values["project_id"]
@ -380,7 +395,7 @@ def reset_password():
return render_template( return render_template(
"reset_password.html", form=form, error=_("No token provided") "reset_password.html", form=form, error=_("No token provided")
) )
project_id = Project.verify_token(token) project_id = Project.verify_token(token, token_type="reset")
if not project_id: if not project_id:
return render_template( return render_template(
"reset_password.html", form=form, error=_("Invalid token") "reset_password.html", form=form, error=_("Invalid token")
@ -412,8 +427,8 @@ def edit_project():
flash(_("Project successfully uploaded")) flash(_("Project successfully uploaded"))
return redirect(url_for("main.list_bills")) return redirect(url_for("main.list_bills"))
except ValueError: except ValueError as e:
flash(_("Invalid JSON"), category="danger") flash(e.args[0], category="danger")
# Edit form # Edit form
if edit_form.validate_on_submit(): if edit_form.validate_on_submit():
@ -447,17 +462,37 @@ def import_project(file, project):
json_file = json.load(file) json_file = json.load(file)
# Check if JSON is correct # Check if JSON is correct
attr = ["what", "payer_name", "payer_weight", "amount", "date", "owers"] attr = ["what", "payer_name", "payer_weight", "amount", "currency", "date", "owers"]
attr.sort() attr.sort()
currencies = set()
for e in json_file: for e in json_file:
# If currency is absent, empty, or explicitly set to XXX
# set it to project default.
if e.get("currency", "") in ["", "XXX"]:
e["currency"] = project.default_currency
if len(e) != len(attr): if len(e) != len(attr):
raise ValueError raise ValueError(_("Invalid JSON"))
list_attr = [] list_attr = []
for i in e: for i in e:
list_attr.append(i) list_attr.append(i)
list_attr.sort() list_attr.sort()
if list_attr != attr: if list_attr != attr:
raise ValueError raise ValueError(_("Invalid JSON"))
# Keep track of currencies
currencies.add(e["currency"])
# Additional checks if project has no default currency
if project.default_currency == CurrencyConverter.no_currency:
# If bills have currencies, they must be consistent
if len(currencies - {CurrencyConverter.no_currency}) >= 2:
raise ValueError(
_(
"Cannot add bills in multiple currencies to a project without default currency"
)
)
# Strip currency from bills (since it's the same for every bill)
for e in json_file:
e["currency"] = CurrencyConverter.no_currency
# From json : export list of members # From json : export list of members
members_json = get_members(json_file) members_json = get_members(json_file)
@ -505,10 +540,10 @@ def import_project(file, project):
form = get_billform_for(project) form = get_billform_for(project)
form.what = b["what"] form.what = b["what"]
form.amount = b["amount"] form.amount = b["amount"]
form.original_currency = b["currency"]
form.date = parse(b["date"]) form.date = parse(b["date"])
form.payer = id_dict[b["payer_name"]] form.payer = id_dict[b["payer_name"]]
form.payed_for = owers_id form.payed_for = owers_id
form.original_currency = b.get("original_currency")
db.session.add(form.fake_form(bill, project)) db.session.add(form.fake_form(bill, project))

View file

@ -33,6 +33,7 @@ install_requires =
Flask-Migrate>=2.5.3,<4 # Not following semantic versioning (e.g. https://github.com/miguelgrinberg/flask-migrate/commit/1af28ba273de6c88544623b8dc02dd539340294b) Flask-Migrate>=2.5.3,<4 # Not following semantic versioning (e.g. https://github.com/miguelgrinberg/flask-migrate/commit/1af28ba273de6c88544623b8dc02dd539340294b)
Flask-RESTful>=0.3.9,<1 Flask-RESTful>=0.3.9,<1
Flask-SQLAlchemy>=2.4,<3 Flask-SQLAlchemy>=2.4,<3
Flask-Talisman>=0.8,<1
Flask-WTF>=0.14.3,<1 Flask-WTF>=0.14.3,<1
WTForms>=2.3.1,<2.4 WTForms>=2.3.1,<2.4
Flask>=2,<3 Flask>=2,<3
@ -56,7 +57,7 @@ dev =
PyMySQL>=0.9,<1.1 PyMySQL>=0.9,<1.1
doc = doc =
Sphinx==4.1.2 Sphinx==4.2.0
docutils==0.17.1 docutils==0.17.1
[options.entry_points] [options.entry_points]