mirror of
https://github.com/spiral-project/ihatemoney.git
synced 2025-05-05 20:51:49 +02:00
Merge branch 'master' into Glandos-patch-1
This commit is contained in:
commit
cea50022c2
46 changed files with 3140 additions and 381 deletions
52
.github/workflows/dockerhub.yml
vendored
Normal file
52
.github/workflows/dockerhub.yml
vendored
Normal 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 }}
|
|
@ -9,11 +9,12 @@ This document describes changes between each past release.
|
|||
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 3.5 (#571)
|
||||
- Drop support for MySQL (#743)
|
||||
- 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
|
||||
with up to Python 3.9
|
||||
|
@ -26,6 +27,7 @@ Security
|
|||
|
||||
- Add CSRF validation on destructive actions (#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
|
||||
-----
|
||||
|
@ -38,10 +40,11 @@ Added
|
|||
- Add sorting, pagination, and searching to the admin dashboard (#538)
|
||||
- Add Project History page that records all changes (#553)
|
||||
- Add token-based authentication to the API (#504)
|
||||
- Add translations for Hindi, Portuguese (Brazil), Tamil
|
||||
- Add illustrations as a showcase, currently only for French (#544)
|
||||
- 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
|
||||
- Publish an `official docker image <https://hub.docker.com/r/ihatemoney/ihatemoney>`_
|
||||
|
||||
Changed
|
||||
-------
|
||||
|
@ -51,6 +54,7 @@ Changed
|
|||
- Make language choice persistent (#547)
|
||||
- Localize date strings in the current language (#590)
|
||||
- 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)
|
||||
- Change Dockerfile to install python dependencies at build time (#793)
|
||||
- Updating project settings doesn't require to enter or update project code (#774)
|
||||
|
|
|
@ -29,8 +29,8 @@ Glandos
|
|||
Heimen Stoffels
|
||||
James Leong
|
||||
Jocelyn Delalande
|
||||
Lucas Verney
|
||||
Luc Didry
|
||||
Lucas Verney
|
||||
Marien Fressinaud
|
||||
Mathieu Leplatre
|
||||
mcnesium
|
||||
|
@ -44,6 +44,7 @@ Richard Coates
|
|||
THANOS SIOURDAKIS
|
||||
Toover
|
||||
Xavier Mehrenberger
|
||||
Youe Graillot
|
||||
zorun
|
||||
|
||||
The manual drawings are from Coline Billon, they are under CC BY 4.0.
|
||||
|
|
|
@ -21,6 +21,7 @@ ADMIN_PASSWORD = '$ADMIN_PASSWORD'
|
|||
ALLOW_PUBLIC_PROJECT_CREATION = $ALLOW_PUBLIC_PROJECT_CREATION
|
||||
ACTIVATE_ADMIN_DASHBOARD = $ACTIVATE_ADMIN_DASHBOARD
|
||||
BABEL_DEFAULT_TIMEZONE = "$BABEL_DEFAULT_TIMEZONE"
|
||||
SESSION_COOKIE_SECURE = $SESSION_COOKIE_SECURE
|
||||
EOF
|
||||
|
||||
# Start gunicorn without forking
|
||||
|
|
92
docs/api.rst
92
docs/api.rst
|
@ -4,7 +4,9 @@ The REST 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.
|
||||
|
||||
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
|
||||
====================
|
||||
|
@ -31,7 +33,7 @@ instead of basic auth.
|
|||
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
|
||||
{"token": "eyJwcm9qZWN0X2lkIjoiZGVtbyJ9.M86C3AiZa_SFEyiddYXdTh2-OOI"}
|
||||
{"token": "WyJ0ZXN0Il0.Rt04fNMmxp9YslCRq8hB6jE9s1Q"}
|
||||
|
||||
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
|
||||
|
@ -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
|
||||
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.
|
||||
|
||||
|
@ -64,10 +66,16 @@ Creating a project
|
|||
|
||||
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)
|
||||
* ``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'
|
||||
"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
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
@ -88,6 +96,7 @@ Getting information about the project::
|
|||
"id": "demo",
|
||||
"name": "demonstration",
|
||||
"contact_email": "demo@notmyidea.org",
|
||||
"default_currency": "XXX",
|
||||
"members": [{"id": 11515, "name": "f", "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},
|
||||
|
@ -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
|
||||
|
||||
Add a bill with a ``POST`` query on ``/api/projects/<id>/bills``. you need the
|
||||
following params:
|
||||
Or get a specific bill by ID::
|
||||
|
||||
* ``date``: the date of the bill; defaults to current date if not
|
||||
provided. (format is ``yyyy-mm-dd``)
|
||||
* ``what``: what have been payed
|
||||
* ``payer``: by who ? (id)
|
||||
* ``payed_for``: for who ? (id, to set multiple id use a list,
|
||||
e.g. ``["id1", "id2"]``)
|
||||
* ``amount``: amount payed
|
||||
$ curl --basic -u demo:demo https://ihatemoney.org/api/projects/demo/bills/42
|
||||
{
|
||||
"id": 42,
|
||||
"payer_id": 11,
|
||||
"owers": [
|
||||
{
|
||||
"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 ::
|
||||
|
||||
$ curl --basic -u demo:demo -X POST\
|
||||
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
|
||||
|
||||
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\
|
||||
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
|
||||
|
||||
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
|
||||
[
|
||||
{
|
||||
"balance": 12.5,
|
||||
"member": {"activated": True, "id": 1, "name": "alexis", "weight": 1.0},
|
||||
"paid": 25.0,
|
||||
"spent": 12.5
|
||||
"member": {"activated": true, "id": 1, "name": "alexis", "weight": 1.0},
|
||||
"paid": 25.5,
|
||||
"spent": 15,
|
||||
"balance": 10.5
|
||||
},
|
||||
{
|
||||
"balance": -12.5,
|
||||
"member": {"activated": True, "id": 2, "name": "fred", "weight": 1.0},
|
||||
"paid": 0,
|
||||
"spent": 12.5
|
||||
"member": {"activated": true, "id": 2, "name": "fred", "weight": 1.0},
|
||||
"paid": 5,
|
||||
"spent": 15.5,
|
||||
"balance": -10.5
|
||||
}
|
||||
]
|
||||
|
|
|
@ -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.
|
||||
|
||||
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`
|
||||
-------------------------
|
||||
|
||||
|
@ -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
|
||||
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`
|
||||
---------------------
|
||||
|
||||
|
@ -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
|
||||
|
||||
`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
|
||||
--------------------------
|
||||
|
@ -142,12 +177,3 @@ possible to configure it to act differently, thanks to the great
|
|||
* **MAIL_PASSWORD** : 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"
|
||||
|
|
|
@ -104,12 +104,20 @@ You can create a ``settings.cfg`` file, with the following content::
|
|||
DEBUG = True
|
||||
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 ::
|
||||
|
||||
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 as a developer
|
||||
|
|
|
@ -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.
|
||||
|
||||
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
|
||||
++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
|
|
|
@ -35,7 +35,9 @@ def need_auth(f):
|
|||
auth_token = auth_header.split(" ")[1]
|
||||
except IndexError:
|
||||
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:
|
||||
project = Project.query.get(project_id)
|
||||
if project:
|
||||
|
|
|
@ -38,3 +38,11 @@ ACTIVATE_ADMIN_DASHBOARD = False
|
|||
# You can change the timezone used to display time. By default it will be
|
||||
#derived from the server OS.
|
||||
#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
|
||||
|
|
|
@ -8,6 +8,7 @@ ACTIVATE_DEMO_PROJECT = True
|
|||
ADMIN_PASSWORD = ""
|
||||
ALLOW_PUBLIC_PROJECT_CREATION = True
|
||||
ACTIVATE_ADMIN_DASHBOARD = False
|
||||
SESSION_COOKIE_SECURE = True
|
||||
SUPPORTED_LANGUAGES = [
|
||||
"de",
|
||||
"el",
|
||||
|
@ -31,3 +32,4 @@ SUPPORTED_LANGUAGES = [
|
|||
"uk",
|
||||
"zh_Hans",
|
||||
]
|
||||
ENABLE_CAPTCHA = False
|
||||
|
|
|
@ -13,6 +13,7 @@ from wtforms.fields.core import Label, SelectField, SelectMultipleField
|
|||
from wtforms.fields.html5 import DateField, DecimalField, URLField
|
||||
from wtforms.fields.simple import BooleanField, PasswordField, StringField, SubmitField
|
||||
from wtforms.validators import (
|
||||
URL,
|
||||
DataRequired,
|
||||
Email,
|
||||
EqualTo,
|
||||
|
@ -112,7 +113,11 @@ class EditProjectForm(FlaskForm):
|
|||
project_history = BooleanField(_("Enable project history"))
|
||||
ip_recording = BooleanField(_("Use IP tracking for project history"))
|
||||
currency_helper = CurrencyConverter()
|
||||
default_currency = SelectField(_("Default Currency"), validators=[DataRequired()])
|
||||
default_currency = SelectField(
|
||||
_("Default Currency"),
|
||||
validators=[DataRequired()],
|
||||
default=CurrencyConverter.no_currency,
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
if not hasattr(self, "id"):
|
||||
|
@ -220,6 +225,19 @@ class ProjectForm(EditProjectForm):
|
|||
)
|
||||
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):
|
||||
"""Used for any important "delete" action linked to a project:
|
||||
|
@ -292,7 +310,7 @@ class BillForm(FlaskForm):
|
|||
original_currency = SelectField(_("Currency"), validators=[DataRequired()])
|
||||
external_link = URLField(
|
||||
_("External link"),
|
||||
validators=[Optional()],
|
||||
validators=[Optional(), URL()],
|
||||
description=_("A link to an external document, related to this bill"),
|
||||
)
|
||||
payed_for = SelectMultipleField(
|
||||
|
@ -321,7 +339,7 @@ class BillForm(FlaskForm):
|
|||
bill.external_link = ""
|
||||
bill.date = self.date
|
||||
bill.owers = [Person.query.get(ower, project) for ower in self.payed_for]
|
||||
bill.original_currency = CurrencyConverter.no_currency
|
||||
bill.original_currency = self.original_currency
|
||||
bill.converted_amount = self.currency_helper.exchange_currency(
|
||||
bill.amount, bill.original_currency, project.default_currency
|
||||
)
|
||||
|
|
|
@ -7,8 +7,8 @@ from flask_sqlalchemy import BaseQuery, SQLAlchemy
|
|||
from itsdangerous import (
|
||||
BadSignature,
|
||||
SignatureExpired,
|
||||
TimedJSONWebSignatureSerializer,
|
||||
URLSafeSerializer,
|
||||
URLSafeTimedSerializer,
|
||||
)
|
||||
import sqlalchemy
|
||||
from sqlalchemy import orm
|
||||
|
@ -179,6 +179,7 @@ class Project(db.Model):
|
|||
"ower": transaction["ower"].name,
|
||||
"receiver": transaction["receiver"].name,
|
||||
"amount": round(transaction["amount"], 2),
|
||||
"currency": transaction["currency"],
|
||||
}
|
||||
)
|
||||
return pretty_transactions
|
||||
|
@ -192,6 +193,7 @@ class Project(db.Model):
|
|||
"ower": members[ower_id],
|
||||
"receiver": members[receiver_id],
|
||||
"amount": amount,
|
||||
"currency": self.default_currency,
|
||||
}
|
||||
for ower_id, amount, receiver_id in settle_plan
|
||||
]
|
||||
|
@ -269,6 +271,7 @@ class Project(db.Model):
|
|||
{
|
||||
"what": bill.what,
|
||||
"amount": round(bill.amount, 2),
|
||||
"currency": bill.original_currency,
|
||||
"date": str(bill.date),
|
||||
"payer_name": Person.query.get(bill.payer_id).name,
|
||||
"payer_weight": Person.query.get(bill.payer_id).weight,
|
||||
|
@ -336,41 +339,61 @@ class Project(db.Model):
|
|||
db.session.delete(self)
|
||||
db.session.commit()
|
||||
|
||||
def generate_token(self, expiration=0):
|
||||
def generate_token(self, token_type="auth"):
|
||||
"""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(
|
||||
current_app.config["SECRET_KEY"], expiration
|
||||
|
||||
if token_type == "reset":
|
||||
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:
|
||||
serializer = URLSafeSerializer(current_app.config["SECRET_KEY"])
|
||||
token = serializer.dumps({"project_id": self.id})
|
||||
serializer = URLSafeSerializer(
|
||||
current_app.config["SECRET_KEY"] + self.password, salt=token_type
|
||||
)
|
||||
token = serializer.dumps([self.id])
|
||||
|
||||
return token
|
||||
|
||||
@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,
|
||||
None if the provided token is expired or not valid.
|
||||
|
||||
: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":
|
||||
serializer = TimedJSONWebSignatureSerializer(
|
||||
current_app.config["SECRET_KEY"]
|
||||
loads_kwargs = {}
|
||||
if token_type == "reset":
|
||||
serializer = URLSafeTimedSerializer(
|
||||
current_app.config["SECRET_KEY"], salt=token_type
|
||||
)
|
||||
loads_kwargs["max_age"] = max_age
|
||||
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:
|
||||
data = serializer.loads(token)
|
||||
data = serializer.loads(token, **loads_kwargs)
|
||||
except SignatureExpired:
|
||||
return None
|
||||
except BadSignature:
|
||||
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):
|
||||
return self.name
|
||||
|
@ -385,7 +408,7 @@ class Project(db.Model):
|
|||
name="demonstration",
|
||||
password=generate_password_hash("demo"),
|
||||
contact_email="demo@notmyidea.org",
|
||||
default_currency="EUR",
|
||||
default_currency="XXX",
|
||||
)
|
||||
db.session.add(project)
|
||||
db.session.commit()
|
||||
|
@ -413,7 +436,7 @@ class Project(db.Model):
|
|||
bill.what = subject
|
||||
bill.owers = [members[name] for name in owers]
|
||||
bill.amount = amount
|
||||
bill.original_currency = "EUR"
|
||||
bill.original_currency = "XXX"
|
||||
bill.converted_amount = amount
|
||||
|
||||
db.session.add(bill)
|
||||
|
|
|
@ -7,6 +7,7 @@ from flask import Flask, g, render_template, request, session
|
|||
from flask_babel import Babel, format_currency
|
||||
from flask_mail import Mail
|
||||
from flask_migrate import Migrate, stamp, upgrade
|
||||
from flask_talisman import Talisman
|
||||
from jinja2 import pass_context
|
||||
from markupsafe import Markup
|
||||
import pytz
|
||||
|
@ -126,6 +127,24 @@ def create_app(
|
|||
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.
|
||||
load_configuration(app, configuration)
|
||||
app.wsgi_app = PrefixedWSGI(app)
|
||||
|
|
|
@ -28,6 +28,7 @@ body {
|
|||
}
|
||||
.navbar-brand {
|
||||
font-family: "Lobster", arial, serif;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
|
@ -468,7 +469,6 @@ tr.payer_line .balance-name {
|
|||
position: absolute;
|
||||
top: 4.5rem;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.light {
|
||||
|
|
|
@ -75,6 +75,9 @@
|
|||
{{ input(form.name) }}
|
||||
{{ input(form.password) }}
|
||||
{{ input(form.contact_email) }}
|
||||
{% if config['ENABLE_CAPTCHA'] %}
|
||||
{{ input(form.captcha) }}
|
||||
{% endif %}
|
||||
{{ input(form.default_currency) }}
|
||||
{% if not home %}
|
||||
{{ submit(form.submit, home=True) }}
|
||||
|
|
|
@ -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.
|
||||
|
||||
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) }}
|
||||
If your cookie gets deleted or if you log out, you will need to log back in using the first link.
|
||||
|
|
|
@ -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 s’occupe 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) }}
|
||||
Si vous êtes déconnecté volontairement ou non, vous devrez utiliser à nouveau le premier lien.
|
||||
|
|
|
@ -21,12 +21,6 @@
|
|||
{% block head %}{% endblock %}
|
||||
<script type="text/javascript" charset="utf-8">
|
||||
$(document).ready(function(){
|
||||
setTimeout(function(){
|
||||
$(".flash").fadeOut("slow", function () {
|
||||
$(".flash").remove();
|
||||
});
|
||||
}, 4000);
|
||||
|
||||
$('.dropdown-toggle').dropdown();
|
||||
$('[data-toggle="tooltip"]').tooltip();
|
||||
{% block js %}{% endblock %}
|
||||
|
@ -128,11 +122,12 @@
|
|||
|
||||
<div class="messages">
|
||||
{% for category, message in get_flashed_messages(with_categories=true) %}
|
||||
{% if category == "message" %}{# Default category for flash(msg) #}
|
||||
<div class="flash alert alert-success">{{ message }}</div>
|
||||
{% else %}
|
||||
<div class="flash alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endif %}
|
||||
<div class="flash alert alert-{{ "success" if category == "message" else category }} alert-dismissible fade show">
|
||||
{{ message }}
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
Hi,
|
||||
|
||||
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.
|
||||
|
||||
Hope this helps,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
Salut,
|
||||
|
||||
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.
|
||||
|
||||
Faites-en bon usage !
|
||||
|
|
|
@ -21,8 +21,8 @@
|
|||
</td>
|
||||
<td>
|
||||
{{ _("You can directly share the following link via your prefered medium") }}</br>
|
||||
<a href="{{ url_for(".authenticate", _external=True, token=g.project.generate_token()) }}">
|
||||
{{ 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(".join_project", _external=True, project_id=g.project.id, token=g.project.generate_token()) }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
<tr id="bal-member-{{ member.id }}" action="{% if member.activated %}delete{% else %}reactivate{% endif %}">
|
||||
<td class="balance-name">{{ member.name }}
|
||||
{%- 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 -%}
|
||||
</td>
|
||||
{%- if member_edit %}
|
||||
|
|
|
@ -11,20 +11,31 @@ class APITestCase(IhatemoneyTestCase):
|
|||
|
||||
"""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
|
||||
password = password or name
|
||||
contact = contact or f"{name}@notmyidea.org"
|
||||
|
||||
return self.client.post(
|
||||
"/api/projects",
|
||||
data={
|
||||
if default_currency:
|
||||
data = {
|
||||
"name": name,
|
||||
"id": id,
|
||||
"password": password,
|
||||
"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):
|
||||
|
@ -85,7 +96,7 @@ class APITestCase(IhatemoneyTestCase):
|
|||
"id": "raclette",
|
||||
"password": "raclette",
|
||||
"contact_email": "not-an-email",
|
||||
"default_currency": "USD",
|
||||
"default_currency": "XXX",
|
||||
},
|
||||
)
|
||||
|
||||
|
@ -114,7 +125,7 @@ class APITestCase(IhatemoneyTestCase):
|
|||
"members": [],
|
||||
"name": "raclette",
|
||||
"contact_email": "raclette@notmyidea.org",
|
||||
"default_currency": "USD",
|
||||
"default_currency": "XXX",
|
||||
"id": "raclette",
|
||||
"logging_preference": 1,
|
||||
}
|
||||
|
@ -126,7 +137,7 @@ class APITestCase(IhatemoneyTestCase):
|
|||
"/api/projects/raclette",
|
||||
data={
|
||||
"contact_email": "yeah@notmyidea.org",
|
||||
"default_currency": "USD",
|
||||
"default_currency": "XXX",
|
||||
"password": "raclette",
|
||||
"name": "The raclette party",
|
||||
"project_history": "y",
|
||||
|
@ -144,7 +155,7 @@ class APITestCase(IhatemoneyTestCase):
|
|||
expected = {
|
||||
"name": "The raclette party",
|
||||
"contact_email": "yeah@notmyidea.org",
|
||||
"default_currency": "USD",
|
||||
"default_currency": "XXX",
|
||||
"members": [],
|
||||
"id": "raclette",
|
||||
"logging_preference": 1,
|
||||
|
@ -157,7 +168,7 @@ class APITestCase(IhatemoneyTestCase):
|
|||
"/api/projects/raclette",
|
||||
data={
|
||||
"contact_email": "yeah@notmyidea.org",
|
||||
"default_currency": "USD",
|
||||
"default_currency": "XXX",
|
||||
"password": "tartiflette",
|
||||
"name": "The raclette party",
|
||||
},
|
||||
|
@ -213,7 +224,7 @@ class APITestCase(IhatemoneyTestCase):
|
|||
"/api/projects/raclette/token", headers=self.get_auth("raclette")
|
||||
)
|
||||
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.
|
||||
self.assertEqual(302, resp.status_code)
|
||||
|
||||
|
@ -380,7 +391,7 @@ class APITestCase(IhatemoneyTestCase):
|
|||
"date": "2011-08-10",
|
||||
"id": 1,
|
||||
"converted_amount": 25.0,
|
||||
"original_currency": "USD",
|
||||
"original_currency": "XXX",
|
||||
"external_link": "https://raclette.fr",
|
||||
}
|
||||
|
||||
|
@ -451,7 +462,7 @@ class APITestCase(IhatemoneyTestCase):
|
|||
"date": "2011-09-10",
|
||||
"external_link": "https://raclette.fr",
|
||||
"converted_amount": 25.0,
|
||||
"original_currency": "USD",
|
||||
"original_currency": "XXX",
|
||||
"id": 1,
|
||||
}
|
||||
|
||||
|
@ -529,7 +540,7 @@ class APITestCase(IhatemoneyTestCase):
|
|||
"date": "2011-08-10",
|
||||
"id": id,
|
||||
"external_link": "",
|
||||
"original_currency": "USD",
|
||||
"original_currency": "XXX",
|
||||
"converted_amount": expected_amount,
|
||||
}
|
||||
|
||||
|
@ -564,6 +575,157 @@ class APITestCase(IhatemoneyTestCase):
|
|||
)
|
||||
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):
|
||||
# create a project
|
||||
self.api_create("raclette")
|
||||
|
@ -674,7 +836,7 @@ class APITestCase(IhatemoneyTestCase):
|
|||
"id": 1,
|
||||
"external_link": "",
|
||||
"converted_amount": 25.0,
|
||||
"original_currency": "USD",
|
||||
"original_currency": "XXX",
|
||||
}
|
||||
got = json.loads(req.data.decode("utf-8"))
|
||||
self.assertEqual(
|
||||
|
@ -717,7 +879,7 @@ class APITestCase(IhatemoneyTestCase):
|
|||
"id": "raclette",
|
||||
"name": "raclette",
|
||||
"logging_preference": 1,
|
||||
"default_currency": "USD",
|
||||
"default_currency": "XXX",
|
||||
}
|
||||
|
||||
self.assertStatus(200, req)
|
||||
|
|
|
@ -4,7 +4,7 @@ import json
|
|||
import re
|
||||
from time import sleep
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from flask import session
|
||||
import pytest
|
||||
|
@ -12,6 +12,7 @@ from werkzeug.security import check_password_hash, generate_password_hash
|
|||
|
||||
from ihatemoney import models
|
||||
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.versioning import LoggingMode
|
||||
|
||||
|
@ -88,10 +89,53 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
)
|
||||
# Test empty and invalid tokens
|
||||
self.client.get("/exit")
|
||||
resp = self.client.get("/authenticate")
|
||||
self.assertIn("You either provided a bad token", resp.data.decode("utf-8"))
|
||||
resp = self.client.get("/authenticate?token=token")
|
||||
self.assertIn("You either provided a bad token", resp.data.decode("utf-8"))
|
||||
# Use another project_id
|
||||
parsed_url = urlparse(url)
|
||||
resp = self.client.get(
|
||||
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):
|
||||
# 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]
|
||||
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):
|
||||
self.post_project("raclette")
|
||||
|
||||
|
@ -1009,6 +1082,8 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
)
|
||||
|
||||
def test_export(self):
|
||||
# Export a simple project without currencies
|
||||
|
||||
self.post_project("raclette")
|
||||
|
||||
# add members
|
||||
|
@ -1026,7 +1101,6 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
"payer": 1,
|
||||
"payed_for": [1, 2, 3, 4],
|
||||
"amount": "10.0",
|
||||
"original_currency": "USD",
|
||||
},
|
||||
)
|
||||
|
||||
|
@ -1038,7 +1112,6 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
"payer": 2,
|
||||
"payed_for": [1, 3],
|
||||
"amount": "200",
|
||||
"original_currency": "USD",
|
||||
},
|
||||
)
|
||||
|
||||
|
@ -1050,13 +1123,464 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
"payer": 3,
|
||||
"payed_for": [2],
|
||||
"amount": "13.33",
|
||||
"original_currency": "USD",
|
||||
},
|
||||
)
|
||||
|
||||
# generate json export of bills
|
||||
resp = self.client.get("/raclette/export/bills.json")
|
||||
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",
|
||||
"what": "refund",
|
||||
|
@ -1075,62 +1599,55 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
},
|
||||
{
|
||||
"date": "2016-12-31",
|
||||
"what": "fromage \xe0 raclette",
|
||||
"what": "fromage a raclette",
|
||||
"amount": 10.0,
|
||||
"payer_name": "zorglub",
|
||||
"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
|
||||
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")
|
||||
from ihatemoney.web import import_project
|
||||
|
||||
for i, line in enumerate(expected):
|
||||
self.assertEqual(
|
||||
set(line.split(",")), set(received_lines[i].strip("\r").split(","))
|
||||
)
|
||||
file = io.StringIO()
|
||||
json.dump(json_to_import, file)
|
||||
file.seek(0)
|
||||
import_project(file, project)
|
||||
|
||||
# generate json export of transactions
|
||||
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"},
|
||||
]
|
||||
bills = project.get_pretty_bills()
|
||||
|
||||
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
|
||||
resp = self.client.get("/raclette/export/transactions.csv")
|
||||
# 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()
|
||||
|
||||
expected = [
|
||||
"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")
|
||||
self.assertEqual(b, ref)
|
||||
|
||||
for i, line in enumerate(expected):
|
||||
self.assertEqual(
|
||||
set(line.split(",")), set(received_lines[i].strip("\r").split(","))
|
||||
)
|
||||
# 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"])
|
||||
# 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
|
||||
resp = self.client.get("/raclette/export/transactions.wrong")
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
list_project = [ower for ower in j["owers"]]
|
||||
list_project.sort()
|
||||
list_json = [ower for ower in i["owers"]]
|
||||
list_json.sort()
|
||||
|
||||
def test_import_new_project(self):
|
||||
# Import JSON in an empty project
|
||||
self.assertEqual(list_project, list_json)
|
||||
|
||||
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.login("raclette")
|
||||
|
@ -1173,7 +1690,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
|
||||
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))
|
||||
|
||||
# Check if name of bills are ok
|
||||
|
@ -1190,6 +1707,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
if j["what"] == i["what"]:
|
||||
self.assertEqual(j["payer_name"], i["payer_name"])
|
||||
self.assertEqual(j["amount"], i["amount"])
|
||||
self.assertEqual(j["currency"], "XXX")
|
||||
self.assertEqual(j["payer_weight"], i["payer_weight"])
|
||||
self.assertEqual(j["date"], i["date"])
|
||||
|
||||
|
@ -1227,6 +1745,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
"date": "2017-01-01",
|
||||
"what": "refund",
|
||||
"amount": 13.33,
|
||||
"currency": "XXX",
|
||||
"payer_name": "tata",
|
||||
"payer_weight": 1.0,
|
||||
"owers": ["fred"],
|
||||
|
@ -1235,6 +1754,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
"date": "2016-12-31",
|
||||
"what": "red wine",
|
||||
"amount": 200.0,
|
||||
"currency": "XXX",
|
||||
"payer_name": "fred",
|
||||
"payer_weight": 1.0,
|
||||
"owers": ["zorglub", "tata"],
|
||||
|
@ -1243,6 +1763,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
"date": "2016-12-31",
|
||||
"what": "fromage a raclette",
|
||||
"amount": 10.0,
|
||||
"currency": "XXX",
|
||||
"payer_name": "zorglub",
|
||||
"payer_weight": 2.0,
|
||||
"owers": ["zorglub", "fred", "tata", "pepe"],
|
||||
|
@ -1258,7 +1779,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
|
||||
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))
|
||||
|
||||
# Check if name of bills are ok
|
||||
|
@ -1275,6 +1796,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
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"])
|
||||
|
||||
|
@ -1314,29 +1836,12 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
|
||||
from ihatemoney.web import import_project
|
||||
|
||||
try:
|
||||
for data in [json_1, json_2]:
|
||||
file = io.StringIO()
|
||||
json.dump(json_1, file)
|
||||
json.dump(data, 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")
|
||||
|
||||
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")
|
||||
with pytest.raises(ValueError):
|
||||
import_project(file, project)
|
||||
|
||||
def test_access_other_projects(self):
|
||||
"""Test that accessing or editing bills and members from another project fails"""
|
||||
|
@ -1464,10 +1969,6 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
|
||||
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
|
||||
self.post_project("raclette")
|
||||
|
||||
|
@ -1559,14 +2060,16 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
},
|
||||
)
|
||||
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
|
||||
|
||||
# Switch to USD. Now, NO bill should be in USD, since they already had a currency
|
||||
project.switch_currency("USD")
|
||||
for bill in project.get_bills():
|
||||
assert bill.original_currency != "USD"
|
||||
expected_amount = converter.exchange_currency(
|
||||
expected_amount = self.converter.exchange_currency(
|
||||
bill.amount, bill.original_currency, "USD"
|
||||
)
|
||||
assert bill.converted_amount == expected_amount
|
||||
|
@ -1583,7 +2086,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
"password": "demo",
|
||||
"contact_email": "demo@notmyidea.org",
|
||||
"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.
|
||||
|
@ -1593,10 +2096,6 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
|
||||
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
|
||||
self.post_project("raclette", default_currency="USD")
|
||||
|
||||
|
@ -1620,7 +2119,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
project = models.Project.query.get("raclette")
|
||||
|
||||
bill = project.get_bills().first()
|
||||
assert bill.converted_amount == converter.exchange_currency(
|
||||
assert bill.converted_amount == self.converter.exchange_currency(
|
||||
bill.amount, "EUR", "USD"
|
||||
)
|
||||
|
||||
|
@ -1631,10 +2130,6 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
|
||||
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
|
||||
self.post_project("raclette", default_currency="USD")
|
||||
|
||||
|
@ -1670,17 +2165,40 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
project = models.Project.query.get("raclette")
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
# 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 = [
|
||||
(bill.amount, bill.converted_amount) for bill in project.get_bills()
|
||||
]
|
||||
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__":
|
||||
unittest.main()
|
||||
|
|
|
@ -1,5 +1,16 @@
|
|||
from markupsafe import Markup
|
||||
|
||||
|
||||
def em_surround(string, regex_escape=False):
|
||||
if regex_escape:
|
||||
return r'<em class="font-italic">%s<\/em>' % string
|
||||
else:
|
||||
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
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import os
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from flask_testing import TestCase
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from ihatemoney import models
|
||||
from ihatemoney.currency_convertor import CurrencyConverter
|
||||
from ihatemoney.run import create_app, db
|
||||
|
||||
|
||||
|
@ -13,6 +15,7 @@ class BaseTestCase(TestCase):
|
|||
SQLALCHEMY_DATABASE_URI = os.environ.get(
|
||||
"TESTING_SQLALCHEMY_DATABASE_URI", "sqlite://"
|
||||
)
|
||||
ENABLE_CAPTCHA = False
|
||||
|
||||
def create_app(self):
|
||||
# Pass the test object as a configuration.
|
||||
|
@ -20,6 +23,18 @@ class BaseTestCase(TestCase):
|
|||
|
||||
def setUp(self):
|
||||
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):
|
||||
# clean after testing
|
||||
|
|
|
@ -31,6 +31,7 @@ class ConfigurationTestCase(BaseTestCase):
|
|||
self.assertTrue(self.app.config["ACTIVATE_DEMO_PROJECT"])
|
||||
self.assertTrue(self.app.config["ALLOW_PUBLIC_PROJECT_CREATION"])
|
||||
self.assertFalse(self.app.config["ACTIVATE_ADMIN_DASHBOARD"])
|
||||
self.assertFalse(self.app.config["ENABLE_CAPTCHA"])
|
||||
|
||||
def test_env_var_configuration_file(self):
|
||||
"""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):
|
||||
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)
|
||||
|
||||
def test_only_one_instance(self):
|
||||
|
@ -254,7 +305,7 @@ class TestCurrencyConverter(unittest.TestCase):
|
|||
def test_get_currencies(self):
|
||||
self.assertCountEqual(
|
||||
self.converter.get_currencies(),
|
||||
["USD", "EUR", "CAD", CurrencyConverter.no_currency],
|
||||
["USD", "EUR", "CAD", "PLN", CurrencyConverter.no_currency],
|
||||
)
|
||||
|
||||
def test_exchange_currency(self):
|
||||
|
|
|
@ -30,7 +30,7 @@ msgid "New private code"
|
|||
msgstr "ব্যক্তিগত কোড"
|
||||
|
||||
msgid "Enter a new code if you want to change it"
|
||||
msgstr ""
|
||||
msgstr "আপনি যদি এটি পরিবর্তন করতে চান তবে একটি নতুন কোড লিখুন"
|
||||
|
||||
msgid "Email"
|
||||
msgstr "ইমেইল"
|
||||
|
@ -39,10 +39,10 @@ msgid "Enable project history"
|
|||
msgstr "প্রকল্পের ইতিহাস সক্রিয় করো"
|
||||
|
||||
msgid "Use IP tracking for project history"
|
||||
msgstr ""
|
||||
msgstr "প্রকল্পের ইতিহাসের জন্য আইপি ট্র্যাকিং ব্যবহার করুন"
|
||||
|
||||
msgid "Default Currency"
|
||||
msgstr ""
|
||||
msgstr "ডিফল্ট মুদ্রা"
|
||||
|
||||
msgid ""
|
||||
"This project cannot be set to 'no currency' because it contains bills in "
|
||||
|
@ -50,19 +50,19 @@ msgid ""
|
|||
msgstr ""
|
||||
|
||||
msgid "Import previously exported JSON file"
|
||||
msgstr ""
|
||||
msgstr "পূর্বে রপ্তানি করা JSON ফাইল আমদানি করুন"
|
||||
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
msgstr "আমদানি"
|
||||
|
||||
msgid "Project identifier"
|
||||
msgstr ""
|
||||
msgstr "প্রকল্প শনাক্তকারী"
|
||||
|
||||
msgid "Private code"
|
||||
msgstr "ব্যক্তিগত কোড"
|
||||
|
||||
msgid "Create the project"
|
||||
msgstr ""
|
||||
msgstr "প্রকল্প তৈরি করুন"
|
||||
|
||||
#, python-format
|
||||
msgid ""
|
||||
|
@ -71,20 +71,21 @@ msgid ""
|
|||
msgstr ""
|
||||
|
||||
msgid "Enter private code to confirm deletion"
|
||||
msgstr ""
|
||||
msgstr "মুছে ফেলার জন্য ব্যক্তিগত কোড লিখুন"
|
||||
|
||||
msgid "Unknown error"
|
||||
msgstr ""
|
||||
msgstr "অজানা ত্রুটি
|
||||
"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Invalid private code."
|
||||
msgstr "ব্যক্তিগত কোড"
|
||||
|
||||
msgid "Get in"
|
||||
msgstr ""
|
||||
msgstr "ভিতরে আস"
|
||||
|
||||
msgid "Admin password"
|
||||
msgstr ""
|
||||
msgstr "অ্যাডমিন পাসওয়ার্ড"
|
||||
|
||||
msgid "Send me the code by email"
|
||||
msgstr ""
|
||||
|
|
Binary file not shown.
|
@ -1,18 +1,18 @@
|
|||
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2021-07-17 17:31+0200\n"
|
||||
"PO-Revision-Date: 2021-01-08 14:32+0000\n"
|
||||
"Last-Translator: Oliver Klimt <klimt.oliver@gmail.com>\n"
|
||||
"PO-Revision-Date: 2021-09-05 13:34+0000\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-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"
|
||||
"Content-Type: text/plain; charset=utf-8\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"
|
||||
|
||||
msgid ""
|
||||
|
@ -23,12 +23,11 @@ msgstr "Neplatná částka nebo výraz. Pouze čísla a operátory + - * / jsou
|
|||
msgid "Project name"
|
||||
msgstr "Název projektu"
|
||||
|
||||
#, fuzzy
|
||||
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"
|
||||
msgstr ""
|
||||
msgstr "Pokud chcete provést změnu vložte nový kód"
|
||||
|
||||
msgid "Email"
|
||||
msgstr "Email"
|
||||
|
@ -46,6 +45,8 @@ msgid ""
|
|||
"This project cannot be set to 'no currency' because it contains bills in "
|
||||
"multiple currencies."
|
||||
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"
|
||||
msgstr "Import exportovaného JSON souboru"
|
||||
|
@ -71,14 +72,13 @@ msgstr ""
|
|||
"nový identifikátor"
|
||||
|
||||
msgid "Enter private code to confirm deletion"
|
||||
msgstr ""
|
||||
msgstr "Potvrďte smazání vložením soukromého kódu"
|
||||
|
||||
msgid "Unknown error"
|
||||
msgstr ""
|
||||
msgstr "Neznámá chyba"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Invalid private code."
|
||||
msgstr "Přístupový kód"
|
||||
msgstr "Neplatný soukromý přístupový kód."
|
||||
|
||||
msgid "Get in"
|
||||
msgstr "Vstoupit"
|
||||
|
@ -145,7 +145,7 @@ msgid "Name"
|
|||
msgstr "Jméno"
|
||||
|
||||
msgid "Weights should be positive"
|
||||
msgstr ""
|
||||
msgstr "Váhy musí být kladné"
|
||||
|
||||
msgid "Weight"
|
||||
msgstr "Váha"
|
||||
|
@ -171,11 +171,11 @@ msgstr "Toto (%(email)s) není validní e-mail"
|
|||
|
||||
#. List with two items only
|
||||
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
|
||||
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
|
||||
msgid "{previous_object}, {next_object}"
|
||||
|
@ -230,37 +230,42 @@ msgid ""
|
|||
"instructions. Please check the email configuration of the server or "
|
||||
"contact the administrator."
|
||||
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"
|
||||
msgstr ""
|
||||
msgstr "Nebyl vložen klíč"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Invalid token"
|
||||
msgstr ""
|
||||
msgstr "Neplatná klíč"
|
||||
|
||||
msgid "Unknown project"
|
||||
msgstr ""
|
||||
msgstr "Neznámý projekt"
|
||||
|
||||
msgid "Password successfully reset."
|
||||
msgstr ""
|
||||
msgstr "Heslo bylo úspěšné obnoveno."
|
||||
|
||||
msgid "Project successfully uploaded"
|
||||
msgstr ""
|
||||
msgstr "Projekt byl úspěšně nahrán."
|
||||
|
||||
msgid "Invalid JSON"
|
||||
msgstr ""
|
||||
msgstr "Neplatný JSON"
|
||||
|
||||
msgid "Project successfully deleted"
|
||||
msgstr ""
|
||||
msgstr "Projekt byl úspěšně smazán"
|
||||
|
||||
msgid "Error deleting project"
|
||||
msgstr ""
|
||||
msgstr "Nastala chyba při mazání projektu"
|
||||
|
||||
#, python-format
|
||||
msgid "You have been invited to share your expenses for %(project)s"
|
||||
msgstr ""
|
||||
|
||||
msgid "Your invitations have been sent"
|
||||
msgstr ""
|
||||
msgstr "Vaše pozvánka byla odeslána"
|
||||
|
||||
msgid ""
|
||||
"Sorry, there was an error while trying to send the invitation emails. "
|
||||
|
|
Binary file not shown.
|
@ -3,8 +3,8 @@ msgstr ""
|
|||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2021-07-17 17:31+0200\n"
|
||||
"PO-Revision-Date: 2021-08-19 20:34+0000\n"
|
||||
"Last-Translator: corny <nico.eckstein+weblate@gmail.com>\n"
|
||||
"PO-Revision-Date: 2021-09-23 19:36+0000\n"
|
||||
"Last-Translator: Christian H. <sunrisechain@gmail.com>\n"
|
||||
"Language-Team: German <https://hosted.weblate.org/projects/i-hate-money/"
|
||||
"i-hate-money/de/>\n"
|
||||
"Language: de\n"
|
||||
|
@ -12,7 +12,7 @@ msgstr ""
|
|||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n != 1;\n"
|
||||
"X-Generator: Weblate 4.8-dev\n"
|
||||
"X-Generator: Weblate 4.9-dev\n"
|
||||
"Generated-By: Babel 2.9.0\n"
|
||||
|
||||
msgid ""
|
||||
|
@ -74,7 +74,7 @@ msgstr ""
|
|||
"wähle eine andere Kennung"
|
||||
|
||||
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"
|
||||
msgstr "Unbekannter Fehler"
|
||||
|
|
Binary file not shown.
|
@ -1,18 +1,18 @@
|
|||
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \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"
|
||||
"Language-Team: Esperanto <https://hosted.weblate.org/projects/i-hate-money/"
|
||||
"i-hate-money/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"
|
||||
"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"
|
||||
"Generated-By: Babel 2.9.0\n"
|
||||
|
||||
msgid ""
|
||||
|
@ -76,13 +76,11 @@ msgstr ""
|
|||
msgid "Enter private code to confirm deletion"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Unknown error"
|
||||
msgstr "Nekonata projekto"
|
||||
msgstr "Nekonata eraro"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Invalid private code."
|
||||
msgstr "Nova privata kodo"
|
||||
msgstr "Nevalida privata kodo."
|
||||
|
||||
msgid "Get in"
|
||||
msgstr "Eniri"
|
||||
|
@ -175,30 +173,30 @@ msgstr "La retpoŝta adreso %(email)s ne validas"
|
|||
|
||||
#. List with two items only
|
||||
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
|
||||
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
|
||||
msgid "{previous_object}, {next_object}"
|
||||
msgstr ""
|
||||
msgstr "{previous_object}, {next_object}"
|
||||
|
||||
#. First two items of a list with more than 3 items
|
||||
msgid "{start_object}, {next_object}"
|
||||
msgstr ""
|
||||
msgstr "{start_object}, {next_object}"
|
||||
|
||||
msgid "No Currency"
|
||||
msgstr "Neniu valuto"
|
||||
|
||||
#. Form error with only one error
|
||||
msgid "{prefix}: {error}"
|
||||
msgstr ""
|
||||
msgstr "{prefix}: {error}"
|
||||
|
||||
#. Form error with a list of errors
|
||||
msgid "{prefix}:<br />{errors}"
|
||||
msgstr ""
|
||||
msgstr "{prefix}:<br />{errors}"
|
||||
|
||||
msgid "Too many failed login attempts, please retry later."
|
||||
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"
|
||||
msgstr "Elŝuti ĝin ĉe"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Are you sure?"
|
||||
msgstr "ĉu vi certas?"
|
||||
msgstr "Ĉu vi certas?"
|
||||
|
||||
msgid "Edit project"
|
||||
msgstr "Redakti projekton"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Delete project"
|
||||
msgstr "Redakti projekton"
|
||||
msgstr "Forviŝi projekton"
|
||||
|
||||
msgid "Import JSON"
|
||||
msgstr "Enporti JSON-dosieron"
|
||||
|
@ -453,7 +449,7 @@ msgid "Everyone"
|
|||
msgstr "Ĉiuj"
|
||||
|
||||
msgid "No one"
|
||||
msgstr ""
|
||||
msgstr "Neniu"
|
||||
|
||||
msgid "More options"
|
||||
msgstr ""
|
||||
|
|
BIN
ihatemoney/translations/kn/LC_MESSAGES/messages.mo
Normal file
BIN
ihatemoney/translations/kn/LC_MESSAGES/messages.mo
Normal file
Binary file not shown.
881
ihatemoney/translations/kn/LC_MESSAGES/messages.po
Normal file
881
ihatemoney/translations/kn/LC_MESSAGES/messages.po
Normal 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 ""
|
Binary file not shown.
|
@ -1,19 +1,19 @@
|
|||
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2021-07-17 17:31+0200\n"
|
||||
"PO-Revision-Date: 2021-05-10 11:33+0000\n"
|
||||
"Last-Translator: Vsevolod <sevauserg.com@gmail.com>\n"
|
||||
"PO-Revision-Date: 2021-09-20 12:38+0000\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-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"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
|
||||
"%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
|
||||
"X-Generator: Weblate 4.9-dev\n"
|
||||
"Generated-By: Babel 2.9.0\n"
|
||||
|
||||
msgid ""
|
||||
|
@ -26,9 +26,8 @@ msgstr ""
|
|||
msgid "Project name"
|
||||
msgstr "Имя проекта"
|
||||
|
||||
#, fuzzy
|
||||
msgid "New private code"
|
||||
msgstr "Приватный код"
|
||||
msgstr "Новый приватный код"
|
||||
|
||||
msgid "Enter a new code if you want to change it"
|
||||
msgstr ""
|
||||
|
|
BIN
ihatemoney/translations/th/LC_MESSAGES/messages.mo
Normal file
BIN
ihatemoney/translations/th/LC_MESSAGES/messages.mo
Normal file
Binary file not shown.
880
ihatemoney/translations/th/LC_MESSAGES/messages.po
Normal file
880
ihatemoney/translations/th/LC_MESSAGES/messages.po
Normal 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 ""
|
Binary file not shown.
|
@ -1,19 +1,18 @@
|
|||
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2021-07-17 17:31+0200\n"
|
||||
"PO-Revision-Date: 2020-10-12 04:47+0000\n"
|
||||
"Last-Translator: Jwen921 <yangjingwen0921@gmail.com>\n"
|
||||
"PO-Revision-Date: 2021-10-10 05:05+0000\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-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"
|
||||
"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"
|
||||
"Generated-By: Babel 2.9.0\n"
|
||||
|
||||
msgid ""
|
||||
|
@ -24,12 +23,11 @@ msgstr "金额或符号无效。仅限数字与+-*/符号。"
|
|||
msgid "Project name"
|
||||
msgstr "账目名称"
|
||||
|
||||
#, fuzzy
|
||||
msgid "New private code"
|
||||
msgstr "共享密钥"
|
||||
msgstr "新的私人代码"
|
||||
|
||||
msgid "Enter a new code if you want to change it"
|
||||
msgstr ""
|
||||
msgstr "如要更改,请输入新代码"
|
||||
|
||||
msgid "Email"
|
||||
msgstr "邮箱"
|
||||
|
@ -46,7 +44,7 @@ msgstr "默认货币"
|
|||
msgid ""
|
||||
"This project cannot be set to 'no currency' because it contains bills in "
|
||||
"multiple currencies."
|
||||
msgstr ""
|
||||
msgstr "此项目不能设置为“无货币”,因为它包含多种货币的账单。"
|
||||
|
||||
msgid "Import previously exported JSON file"
|
||||
msgstr "导入之前的JSON 文件"
|
||||
|
@ -70,15 +68,13 @@ msgid ""
|
|||
msgstr "账目(“%(project)s”)已存在,请选择一个新名称"
|
||||
|
||||
msgid "Enter private code to confirm deletion"
|
||||
msgstr ""
|
||||
msgstr "请输入专用代码以确认删除"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Unknown error"
|
||||
msgstr "未知项目"
|
||||
msgstr "未知错误"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Invalid private code."
|
||||
msgstr "共享密钥"
|
||||
msgstr "无效的私人代码。"
|
||||
|
||||
msgid "Get in"
|
||||
msgstr "进入"
|
||||
|
@ -171,40 +167,40 @@ msgstr "此邮箱%(email)s不存在"
|
|||
|
||||
#. List with two items only
|
||||
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
|
||||
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
|
||||
msgid "{previous_object}, {next_object}"
|
||||
msgstr ""
|
||||
msgstr "{previous_object},{next_object}"
|
||||
|
||||
#. First two items of a list with more than 3 items
|
||||
msgid "{start_object}, {next_object}"
|
||||
msgstr ""
|
||||
msgstr "{start_object}, {next_object}"
|
||||
|
||||
msgid "No Currency"
|
||||
msgstr "没有货币"
|
||||
msgstr "无货币"
|
||||
|
||||
#. Form error with only one error
|
||||
msgid "{prefix}: {error}"
|
||||
msgstr ""
|
||||
msgstr "{prefix}: {error}"
|
||||
|
||||
#. Form error with a list of errors
|
||||
msgid "{prefix}:<br />{errors}"
|
||||
msgstr ""
|
||||
msgstr "{prefix}:<br />{errors}"
|
||||
|
||||
msgid "Too many failed login attempts, please retry later."
|
||||
msgstr "输入错误太多次了,请稍后重试。"
|
||||
msgstr "登录失败次数过多,请稍后重试。"
|
||||
|
||||
#, python-format
|
||||
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."
|
||||
msgstr "你输入了错误的符号或没有项目标识符。"
|
||||
msgstr "你输入了错误的令牌或没有项目标识符。"
|
||||
|
||||
msgid "This private code is not the right one"
|
||||
msgstr "专用码不正确"
|
||||
|
@ -241,7 +237,7 @@ msgid "Unknown project"
|
|||
msgstr "未知项目"
|
||||
|
||||
msgid "Password successfully reset."
|
||||
msgstr "密码重置成功"
|
||||
msgstr "密码重置成功。"
|
||||
|
||||
msgid "Project successfully uploaded"
|
||||
msgstr "项目成功上传"
|
||||
|
@ -253,7 +249,7 @@ msgid "Project successfully deleted"
|
|||
msgstr "项目成功删除"
|
||||
|
||||
msgid "Error deleting project"
|
||||
msgstr ""
|
||||
msgstr "删除项目时出错"
|
||||
|
||||
#, python-format
|
||||
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"
|
||||
|
||||
msgid "Error activating member"
|
||||
msgstr ""
|
||||
msgstr "激活成员时出错"
|
||||
|
||||
#, python-format
|
||||
msgid "%(name)s is part of this project again"
|
||||
msgstr "%(name)s 已经在项目里了"
|
||||
|
||||
msgid "Error removing member"
|
||||
msgstr ""
|
||||
msgstr "删除成员时出错"
|
||||
|
||||
#, python-format
|
||||
msgid ""
|
||||
"User '%(name)s' has been deactivated. It will still appear in the users "
|
||||
"list until its balance becomes zero."
|
||||
msgstr "用户 '%(name)s'已被暂停,在余额为0之前会继续显示在用户列表里"
|
||||
msgstr "用户 '%(name)s'已被暂停,在余额为0之前会继续显示在用户列表里。"
|
||||
|
||||
#, python-format
|
||||
msgid "User '%(name)s' has been removed"
|
||||
|
@ -300,7 +296,7 @@ msgid "The bill has been added"
|
|||
msgstr "帐单已添加"
|
||||
|
||||
msgid "Error deleting bill"
|
||||
msgstr ""
|
||||
msgstr "删除账单时出错"
|
||||
|
||||
msgid "The bill has been deleted"
|
||||
msgstr "账单已删除"
|
||||
|
@ -308,26 +304,23 @@ msgstr "账单已删除"
|
|||
msgid "The bill has been modified"
|
||||
msgstr "帐单已修改"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Error deleting project history"
|
||||
msgstr "启用项目历史"
|
||||
msgstr "删除项目历史记录时出错"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Deleted project history."
|
||||
msgstr "启用项目历史"
|
||||
msgstr "已删除的项目历史记录。"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Error deleting recorded IP addresses"
|
||||
msgstr "删除已储存的IP地址"
|
||||
msgstr "删除记录的IP地址时出错"
|
||||
|
||||
msgid "Deleted recorded IP addresses in project history."
|
||||
msgstr ""
|
||||
msgstr "删除项目历史记录中的 IP 地址。"
|
||||
|
||||
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."
|
||||
msgstr "最好的办法是返回主页"
|
||||
msgstr "最好的办法是返回主页。"
|
||||
|
||||
msgid "Back to the list"
|
||||
msgstr "返回列表"
|
||||
|
@ -375,26 +368,22 @@ msgid "show"
|
|||
msgstr "显示"
|
||||
|
||||
msgid "The Dashboard is currently deactivated."
|
||||
msgstr "操作面板失效"
|
||||
msgstr "操作面板失效。"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download Mobile Application"
|
||||
msgstr "手机软件"
|
||||
msgstr "下载移动应用程序"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Get it on"
|
||||
msgstr "进入"
|
||||
msgstr "获取"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Are you sure?"
|
||||
msgstr "确定?"
|
||||
msgstr "是否确定?"
|
||||
|
||||
msgid "Edit project"
|
||||
msgstr "编辑项目"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Delete project"
|
||||
msgstr "编辑项目"
|
||||
msgstr "删除项目"
|
||||
|
||||
msgid "Import JSON"
|
||||
msgstr "导入json文件"
|
||||
|
@ -430,7 +419,7 @@ msgid "Edit the project"
|
|||
msgstr "编辑项目"
|
||||
|
||||
msgid "This will remove all bills and participants in this project!"
|
||||
msgstr ""
|
||||
msgstr "这将删除此项目的所有账单和参与者!"
|
||||
|
||||
msgid "Edit this bill"
|
||||
msgstr "编辑帐单"
|
||||
|
@ -442,10 +431,10 @@ msgid "Everyone"
|
|||
msgstr "每个人"
|
||||
|
||||
msgid "No one"
|
||||
msgstr ""
|
||||
msgstr "无人"
|
||||
|
||||
msgid "More options"
|
||||
msgstr ""
|
||||
msgstr "更多选项"
|
||||
|
||||
msgid "Add participant"
|
||||
msgstr "添加参与人"
|
||||
|
@ -485,11 +474,11 @@ msgstr "历史设置改变"
|
|||
|
||||
#, python-format
|
||||
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
|
||||
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"
|
||||
msgstr "确认移除IP地址"
|
||||
|
@ -503,7 +492,6 @@ msgstr ""
|
|||
"你确定要删除此项目里所有的IP地址吗?\n"
|
||||
"项目其他内容不受影响,此操作不可撤回。"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Confirm deletion"
|
||||
msgstr "确认删除"
|
||||
|
||||
|
@ -520,11 +508,11 @@ msgstr "确定删除此项目所有记录?此操作不可撤回。"
|
|||
|
||||
#, python-format
|
||||
msgid "Bill %(name)s: added %(owers_list_str)s to owers list"
|
||||
msgstr ""
|
||||
msgstr "帐单 %(name)s:将 %(owers_list_str)s 添加到所有者列表"
|
||||
|
||||
#, python-format
|
||||
msgid "Bill %(name)s: removed %(owers_list_str)s from owers list"
|
||||
msgstr ""
|
||||
msgstr "账单 %(name)s:从所有者列表中删除了 %(owers_list_str)s"
|
||||
|
||||
#, python-format
|
||||
msgid ""
|
||||
|
@ -590,51 +578,51 @@ msgstr "IP地址记录可在设置里禁用"
|
|||
msgid "From IP"
|
||||
msgstr "从IP"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid "Project %(name)s added"
|
||||
msgstr "账目名称"
|
||||
msgstr "项目 %(name)s 已添加"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid "Bill %(name)s added"
|
||||
msgstr "帐单已添加"
|
||||
msgstr "帐单 %(name)s 已添加"
|
||||
|
||||
#, python-format
|
||||
msgid "Participant %(name)s added"
|
||||
msgstr ""
|
||||
msgstr "成员 %(name)s 已添加"
|
||||
|
||||
msgid "Project private code changed"
|
||||
msgstr "项目专用码已更改"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
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"
|
||||
msgstr "项目联系邮箱更改为"
|
||||
msgstr "项目联系邮箱更改为 %(new_email)s"
|
||||
|
||||
msgid "Project settings modified"
|
||||
msgstr "项目设置已修改"
|
||||
|
||||
#, python-format
|
||||
msgid "Participant %(name)s deactivated"
|
||||
msgstr ""
|
||||
msgstr "成员 %(name)s 已停用"
|
||||
|
||||
#, python-format
|
||||
msgid "Participant %(name)s reactivated"
|
||||
msgstr ""
|
||||
msgstr "成员 %(name)s 被重新激活"
|
||||
|
||||
#, python-format
|
||||
msgid "Participant %(name)s renamed to %(new_name)s"
|
||||
msgstr ""
|
||||
msgstr "成员 %(name)s 重命名为 %(new_name)s"
|
||||
|
||||
#, python-format
|
||||
msgid "Bill %(name)s renamed to %(new_description)s"
|
||||
msgstr ""
|
||||
msgstr "账单 %(name)s 更名为 %(new_description)s"
|
||||
|
||||
#, python-format
|
||||
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"
|
||||
msgstr "数量"
|
||||
|
@ -643,33 +631,33 @@ msgstr "数量"
|
|||
msgid "Amount in %(currency)s"
|
||||
msgstr "%(currency)s的数量是"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid "Bill %(name)s modified"
|
||||
msgstr "帐单已修改"
|
||||
msgstr "帐单 %(name)s 已修改"
|
||||
|
||||
#, python-format
|
||||
msgid "Participant %(name)s modified"
|
||||
msgstr ""
|
||||
msgstr "成员 %(name)s 已修改"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid "Bill %(name)s removed"
|
||||
msgstr "用户 '%(name)s'已被移除"
|
||||
msgstr "账单 %(name)s 已被移除"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
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"
|
||||
msgstr "未知的改变"
|
||||
msgstr "项目 %(name)s 以未知方式更改"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
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"
|
||||
msgstr "未知的改变"
|
||||
msgstr "成员 %(name)s 以未知方式更改"
|
||||
|
||||
msgid "Nothing to list"
|
||||
msgstr "无列表"
|
||||
|
@ -815,7 +803,7 @@ msgid "No bills"
|
|||
msgstr "没有账单"
|
||||
|
||||
msgid "Nothing to list yet."
|
||||
msgstr "没有列表"
|
||||
msgstr "没有列表。"
|
||||
|
||||
msgid "You probably want to"
|
||||
msgstr "你想要"
|
||||
|
|
|
@ -271,7 +271,7 @@ def get_members(file):
|
|||
|
||||
|
||||
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:
|
||||
if bill1[a] != bill2[a]:
|
||||
return False
|
||||
|
@ -370,6 +370,7 @@ def localize_list(items, surround_with_em=True):
|
|||
|
||||
|
||||
def render_localized_currency(code, detailed=True):
|
||||
# We cannot use CurrencyConvertor.no_currency here because of circular dependencies
|
||||
if code == "XXX":
|
||||
return _("No Currency")
|
||||
locale = get_locale() or "en_US"
|
||||
|
|
|
@ -36,6 +36,7 @@ from sqlalchemy_continuum import Operation
|
|||
from werkzeug.exceptions import NotFound
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
from ihatemoney.currency_convertor import CurrencyConverter
|
||||
from ihatemoney.forms import (
|
||||
AdminAuthenticationForm,
|
||||
AuthenticationForm,
|
||||
|
@ -142,7 +143,8 @@ def pull_project(endpoint, values):
|
|||
raise Redirect303(url_for(".create_project", project_id=project_id))
|
||||
|
||||
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
|
||||
g.project = project
|
||||
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"])
|
||||
def authenticate(project_id=None):
|
||||
"""Authentication form"""
|
||||
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"):
|
||||
form.id.data = request.args["project_id"]
|
||||
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)
|
||||
if not form.id.data and request.args.get("project_id"):
|
||||
form.id.data = request.args["project_id"]
|
||||
project_id = form.id.data
|
||||
|
||||
project = Project.query.get(project_id) if project_id is not None else None
|
||||
if not project:
|
||||
# If the user try to connect to an unexisting project, we will
|
||||
# propose him a link to the creation form.
|
||||
|
@ -228,13 +240,9 @@ def authenticate(project_id=None):
|
|||
setattr(g, "project", project)
|
||||
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()
|
||||
if (
|
||||
is_post_auth
|
||||
and check_password_hash(project.password, form.password.data)
|
||||
or token_auth
|
||||
):
|
||||
if is_post_auth and check_password_hash(project.password, form.password.data):
|
||||
# maintain a list of visited projects
|
||||
if "projects" not in session:
|
||||
session["projects"] = []
|
||||
|
@ -253,9 +261,16 @@ def authenticate(project_id=None):
|
|||
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)
|
||||
def home():
|
||||
project_form = ProjectForm()
|
||||
project_form = get_project_form()
|
||||
auth_form = AuthenticationForm()
|
||||
is_demo_project_activated = current_app.config["ACTIVATE_DEMO_PROJECT"]
|
||||
is_public_project_creation_allowed = current_app.config[
|
||||
|
@ -280,7 +295,7 @@ def mobile():
|
|||
@main.route("/create", methods=["GET", "POST"])
|
||||
@requires_admin(bypass=("ALLOW_PUBLIC_PROJECT_CREATION", True))
|
||||
def create_project():
|
||||
form = ProjectForm()
|
||||
form = get_project_form()
|
||||
if request.method == "GET" and "project_id" in request.values:
|
||||
form.name.data = request.values["project_id"]
|
||||
|
||||
|
@ -380,7 +395,7 @@ def reset_password():
|
|||
return render_template(
|
||||
"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:
|
||||
return render_template(
|
||||
"reset_password.html", form=form, error=_("Invalid token")
|
||||
|
@ -412,8 +427,8 @@ def edit_project():
|
|||
flash(_("Project successfully uploaded"))
|
||||
|
||||
return redirect(url_for("main.list_bills"))
|
||||
except ValueError:
|
||||
flash(_("Invalid JSON"), category="danger")
|
||||
except ValueError as e:
|
||||
flash(e.args[0], category="danger")
|
||||
|
||||
# Edit form
|
||||
if edit_form.validate_on_submit():
|
||||
|
@ -447,17 +462,37 @@ def import_project(file, project):
|
|||
json_file = json.load(file)
|
||||
|
||||
# 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()
|
||||
currencies = set()
|
||||
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):
|
||||
raise ValueError
|
||||
raise ValueError(_("Invalid JSON"))
|
||||
list_attr = []
|
||||
for i in e:
|
||||
list_attr.append(i)
|
||||
list_attr.sort()
|
||||
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
|
||||
members_json = get_members(json_file)
|
||||
|
@ -505,10 +540,10 @@ def import_project(file, project):
|
|||
form = get_billform_for(project)
|
||||
form.what = b["what"]
|
||||
form.amount = b["amount"]
|
||||
form.original_currency = b["currency"]
|
||||
form.date = parse(b["date"])
|
||||
form.payer = id_dict[b["payer_name"]]
|
||||
form.payed_for = owers_id
|
||||
form.original_currency = b.get("original_currency")
|
||||
|
||||
db.session.add(form.fake_form(bill, project))
|
||||
|
||||
|
|
|
@ -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-RESTful>=0.3.9,<1
|
||||
Flask-SQLAlchemy>=2.4,<3
|
||||
Flask-Talisman>=0.8,<1
|
||||
Flask-WTF>=0.14.3,<1
|
||||
WTForms>=2.3.1,<2.4
|
||||
Flask>=2,<3
|
||||
|
@ -56,7 +57,7 @@ dev =
|
|||
PyMySQL>=0.9,<1.1
|
||||
|
||||
doc =
|
||||
Sphinx==4.1.2
|
||||
Sphinx==4.2.0
|
||||
docutils==0.17.1
|
||||
|
||||
[options.entry_points]
|
||||
|
|
Loading…
Reference in a new issue