mirror of
https://github.com/spiral-project/ihatemoney.git
synced 2025-04-28 17:32:38 +02:00
Feature/currencies (#541)
Now each project can have a currency, default to None. Each bill can use a different currency, and a conversion to project default currency is done on settle. Fix #512
This commit is contained in:
parent
162193c787
commit
f389c56259
13 changed files with 313 additions and 11 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
@ -1,4 +1,3 @@
|
||||||
ihatemoney.cfg
|
|
||||||
*.pyc
|
*.pyc
|
||||||
*.egg-info
|
*.egg-info
|
||||||
dist
|
dist
|
||||||
|
@ -13,3 +12,8 @@ build
|
||||||
.pytest_cache
|
.pytest_cache
|
||||||
ihatemoney/budget.db
|
ihatemoney/budget.db
|
||||||
.idea/
|
.idea/
|
||||||
|
.envrc
|
||||||
|
.DS_Store
|
||||||
|
.idea
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
|
46
ihatemoney/currency_convertor.py
Normal file
46
ihatemoney/currency_convertor.py
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
from cachetools import TTLCache, cached
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
class Singleton(type):
|
||||||
|
_instances = {}
|
||||||
|
|
||||||
|
def __call__(cls, *args, **kwargs):
|
||||||
|
if cls not in cls._instances:
|
||||||
|
cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
|
||||||
|
return cls._instances[cls]
|
||||||
|
|
||||||
|
|
||||||
|
class CurrencyConverter(object, metaclass=Singleton):
|
||||||
|
# Get exchange rates
|
||||||
|
default = "No Currency"
|
||||||
|
api_url = "https://api.exchangeratesapi.io/latest?base=USD"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@cached(cache=TTLCache(maxsize=1, ttl=86400))
|
||||||
|
def get_rates(self):
|
||||||
|
rates = requests.get(self.api_url).json()["rates"]
|
||||||
|
rates[self.default] = 1.0
|
||||||
|
return rates
|
||||||
|
|
||||||
|
def get_currencies(self):
|
||||||
|
rates = [rate for rate in self.get_rates()]
|
||||||
|
rates.sort(key=lambda rate: "" if rate == self.default else rate)
|
||||||
|
return rates
|
||||||
|
|
||||||
|
def exchange_currency(self, amount, source_currency, dest_currency):
|
||||||
|
if (
|
||||||
|
source_currency == dest_currency
|
||||||
|
or source_currency == self.default
|
||||||
|
or dest_currency == self.default
|
||||||
|
):
|
||||||
|
return amount
|
||||||
|
|
||||||
|
rates = self.get_rates()
|
||||||
|
source_rate = rates[source_currency]
|
||||||
|
dest_rate = rates[dest_currency]
|
||||||
|
new_amount = (float(amount) / source_rate) * dest_rate
|
||||||
|
# round to two digits because we are dealing with money
|
||||||
|
return round(new_amount, 2)
|
|
@ -1,3 +1,4 @@
|
||||||
|
import copy
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from re import match
|
from re import match
|
||||||
|
|
||||||
|
@ -8,7 +9,7 @@ from flask_wtf.file import FileAllowed, FileField, FileRequired
|
||||||
from flask_wtf.form import FlaskForm
|
from flask_wtf.form import FlaskForm
|
||||||
from jinja2 import Markup
|
from jinja2 import Markup
|
||||||
from werkzeug.security import check_password_hash, generate_password_hash
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
from wtforms.fields.core import SelectField, SelectMultipleField
|
from wtforms.fields.core import Label, SelectField, SelectMultipleField
|
||||||
from wtforms.fields.html5 import DateField, DecimalField, URLField
|
from wtforms.fields.html5 import DateField, DecimalField, URLField
|
||||||
from wtforms.fields.simple import BooleanField, PasswordField, StringField, SubmitField
|
from wtforms.fields.simple import BooleanField, PasswordField, StringField, SubmitField
|
||||||
from wtforms.validators import (
|
from wtforms.validators import (
|
||||||
|
@ -20,6 +21,7 @@ from wtforms.validators import (
|
||||||
ValidationError,
|
ValidationError,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ihatemoney.currency_convertor import CurrencyConverter
|
||||||
from ihatemoney.models import LoggingMode, Person, Project
|
from ihatemoney.models import LoggingMode, Person, Project
|
||||||
from ihatemoney.utils import eval_arithmetic_expression, slugify
|
from ihatemoney.utils import eval_arithmetic_expression, slugify
|
||||||
|
|
||||||
|
@ -31,6 +33,18 @@ def strip_filter(string):
|
||||||
return string
|
return string
|
||||||
|
|
||||||
|
|
||||||
|
def get_editprojectform_for(project, **kwargs):
|
||||||
|
"""Return an instance of EditProjectForm configured for a particular project.
|
||||||
|
"""
|
||||||
|
form = EditProjectForm(**kwargs)
|
||||||
|
choices = copy.copy(form.default_currency.choices)
|
||||||
|
choices.sort(
|
||||||
|
key=lambda rates: "" if rates[0] == project.default_currency else rates[0]
|
||||||
|
)
|
||||||
|
form.default_currency.choices = choices
|
||||||
|
return form
|
||||||
|
|
||||||
|
|
||||||
def get_billform_for(project, set_default=True, **kwargs):
|
def get_billform_for(project, set_default=True, **kwargs):
|
||||||
"""Return an instance of BillForm configured for a particular project.
|
"""Return an instance of BillForm configured for a particular project.
|
||||||
|
|
||||||
|
@ -39,6 +53,23 @@ def get_billform_for(project, set_default=True, **kwargs):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
form = BillForm(**kwargs)
|
form = BillForm(**kwargs)
|
||||||
|
if form.original_currency.data == "None":
|
||||||
|
form.original_currency.data = project.default_currency
|
||||||
|
|
||||||
|
if form.original_currency.data != CurrencyConverter.default:
|
||||||
|
choices = copy.copy(form.original_currency.choices)
|
||||||
|
choices.remove((CurrencyConverter.default, CurrencyConverter.default))
|
||||||
|
choices.sort(
|
||||||
|
key=lambda rates: "" if rates[0] == project.default_currency else rates[0]
|
||||||
|
)
|
||||||
|
form.original_currency.choices = choices
|
||||||
|
else:
|
||||||
|
form.original_currency.render_kw = {"default": True}
|
||||||
|
form.original_currency.data = CurrencyConverter.default
|
||||||
|
|
||||||
|
form.original_currency.label = Label(
|
||||||
|
"original_currency", "Currency (Default: %s)" % (project.default_currency)
|
||||||
|
)
|
||||||
active_members = [(m.id, m.name) for m in project.active_members]
|
active_members = [(m.id, m.name) for m in project.active_members]
|
||||||
|
|
||||||
form.payed_for.choices = form.payer.choices = active_members
|
form.payed_for.choices = form.payer.choices = active_members
|
||||||
|
@ -89,6 +120,15 @@ class EditProjectForm(FlaskForm):
|
||||||
contact_email = StringField(_("Email"), validators=[DataRequired(), Email()])
|
contact_email = StringField(_("Email"), validators=[DataRequired(), Email()])
|
||||||
project_history = BooleanField(_("Enable project history"))
|
project_history = BooleanField(_("Enable project history"))
|
||||||
ip_recording = BooleanField(_("Use IP tracking for project history"))
|
ip_recording = BooleanField(_("Use IP tracking for project history"))
|
||||||
|
currency_helper = CurrencyConverter()
|
||||||
|
default_currency = SelectField(
|
||||||
|
_("Default Currency"),
|
||||||
|
choices=[
|
||||||
|
(currency_name, currency_name)
|
||||||
|
for currency_name in currency_helper.get_currencies()
|
||||||
|
],
|
||||||
|
validators=[DataRequired()],
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def logging_preference(self):
|
def logging_preference(self):
|
||||||
|
@ -112,6 +152,7 @@ class EditProjectForm(FlaskForm):
|
||||||
password=generate_password_hash(self.password.data),
|
password=generate_password_hash(self.password.data),
|
||||||
contact_email=self.contact_email.data,
|
contact_email=self.contact_email.data,
|
||||||
logging_preference=self.logging_preference,
|
logging_preference=self.logging_preference,
|
||||||
|
default_currency=self.default_currency.data,
|
||||||
)
|
)
|
||||||
return project
|
return project
|
||||||
|
|
||||||
|
@ -125,6 +166,7 @@ class EditProjectForm(FlaskForm):
|
||||||
|
|
||||||
project.contact_email = self.contact_email.data
|
project.contact_email = self.contact_email.data
|
||||||
project.logging_preference = self.logging_preference
|
project.logging_preference = self.logging_preference
|
||||||
|
project.default_currency = self.default_currency.data
|
||||||
|
|
||||||
return project
|
return project
|
||||||
|
|
||||||
|
@ -199,6 +241,15 @@ class BillForm(FlaskForm):
|
||||||
what = StringField(_("What?"), validators=[DataRequired()])
|
what = StringField(_("What?"), validators=[DataRequired()])
|
||||||
payer = SelectField(_("Payer"), validators=[DataRequired()], coerce=int)
|
payer = SelectField(_("Payer"), validators=[DataRequired()], coerce=int)
|
||||||
amount = CalculatorStringField(_("Amount paid"), validators=[DataRequired()])
|
amount = CalculatorStringField(_("Amount paid"), validators=[DataRequired()])
|
||||||
|
currency_helper = CurrencyConverter()
|
||||||
|
original_currency = SelectField(
|
||||||
|
_("Currency"),
|
||||||
|
choices=[
|
||||||
|
(currency_name, currency_name)
|
||||||
|
for currency_name in currency_helper.get_currencies()
|
||||||
|
],
|
||||||
|
validators=[DataRequired()],
|
||||||
|
)
|
||||||
external_link = URLField(
|
external_link = URLField(
|
||||||
_("External link"),
|
_("External link"),
|
||||||
validators=[Optional()],
|
validators=[Optional()],
|
||||||
|
@ -217,6 +268,10 @@ class BillForm(FlaskForm):
|
||||||
bill.external_link = self.external_link.data
|
bill.external_link = self.external_link.data
|
||||||
bill.date = self.date.data
|
bill.date = self.date.data
|
||||||
bill.owers = [Person.query.get(ower, project) for ower in self.payed_for.data]
|
bill.owers = [Person.query.get(ower, project) for ower in self.payed_for.data]
|
||||||
|
bill.original_currency = self.original_currency.data
|
||||||
|
bill.converted_amount = self.currency_helper.exchange_currency(
|
||||||
|
bill.amount, bill.original_currency, project.default_currency
|
||||||
|
)
|
||||||
return bill
|
return bill
|
||||||
|
|
||||||
def fake_form(self, bill, project):
|
def fake_form(self, bill, project):
|
||||||
|
@ -226,6 +281,10 @@ class BillForm(FlaskForm):
|
||||||
bill.external_link = ""
|
bill.external_link = ""
|
||||||
bill.date = self.date
|
bill.date = self.date
|
||||||
bill.owers = [Person.query.get(ower, project) for ower in self.payed_for]
|
bill.owers = [Person.query.get(ower, project) for ower in self.payed_for]
|
||||||
|
bill.original_currency = CurrencyConverter.default
|
||||||
|
bill.converted_amount = self.currency_helper.exchange_currency(
|
||||||
|
bill.amount, bill.original_currency, project.default_currency
|
||||||
|
)
|
||||||
|
|
||||||
return bill
|
return bill
|
||||||
|
|
||||||
|
@ -234,6 +293,7 @@ class BillForm(FlaskForm):
|
||||||
self.amount.data = bill.amount
|
self.amount.data = bill.amount
|
||||||
self.what.data = bill.what
|
self.what.data = bill.what
|
||||||
self.external_link.data = bill.external_link
|
self.external_link.data = bill.external_link
|
||||||
|
self.original_currency.data = bill.original_currency
|
||||||
self.date.data = bill.date
|
self.date.data = bill.date
|
||||||
self.payed_for.data = [int(ower.id) for ower in bill.owers]
|
self.payed_for.data = [int(ower.id) for ower in bill.owers]
|
||||||
|
|
||||||
|
|
|
@ -105,6 +105,14 @@ def get_history(project, human_readable_names=True):
|
||||||
if removed:
|
if removed:
|
||||||
changeset["owers_removed"] = (None, removed)
|
changeset["owers_removed"] = (None, removed)
|
||||||
|
|
||||||
|
# Remove converted_amount if amount changed in the same way.
|
||||||
|
if (
|
||||||
|
"amount" in changeset
|
||||||
|
and "converted_amount" in changeset
|
||||||
|
and changeset["amount"] == changeset["converted_amount"]
|
||||||
|
):
|
||||||
|
del changeset["converted_amount"]
|
||||||
|
|
||||||
for (prop, (val_before, val_after),) in changeset.items():
|
for (prop, (val_before, val_after),) in changeset.items():
|
||||||
if human_readable_names:
|
if human_readable_names:
|
||||||
if prop == "payer_id":
|
if prop == "payer_id":
|
||||||
|
|
|
@ -0,0 +1,73 @@
|
||||||
|
"""Add currencies
|
||||||
|
|
||||||
|
Revision ID: 927ed575acbd
|
||||||
|
Revises: cb038f79982e
|
||||||
|
Create Date: 2020-04-25 14:49:41.136602
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "927ed575acbd"
|
||||||
|
down_revision = "cb038f79982e"
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from ihatemoney.currency_convertor import CurrencyConverter
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column("bill", sa.Column("converted_amount", sa.Float(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"bill",
|
||||||
|
sa.Column(
|
||||||
|
"original_currency",
|
||||||
|
sa.String(length=3),
|
||||||
|
server_default=CurrencyConverter.default,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"bill_version",
|
||||||
|
sa.Column("converted_amount", sa.Float(), autoincrement=False, nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"bill_version",
|
||||||
|
sa.Column(
|
||||||
|
"original_currency", sa.String(length=3), autoincrement=False, nullable=True
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"project",
|
||||||
|
sa.Column(
|
||||||
|
"default_currency",
|
||||||
|
sa.String(length=3),
|
||||||
|
server_default=CurrencyConverter.default,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"project_version",
|
||||||
|
sa.Column(
|
||||||
|
"default_currency", sa.String(length=3), autoincrement=False, nullable=True
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE bill
|
||||||
|
SET converted_amount = amount
|
||||||
|
WHERE converted_amount IS NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column("project_version", "default_currency")
|
||||||
|
op.drop_column("project", "default_currency")
|
||||||
|
op.drop_column("bill_version", "original_currency")
|
||||||
|
op.drop_column("bill_version", "converted_amount")
|
||||||
|
op.drop_column("bill", "original_currency")
|
||||||
|
op.drop_column("bill", "converted_amount")
|
||||||
|
# ### end Alembic commands ###
|
|
@ -71,6 +71,7 @@ class Project(db.Model):
|
||||||
members = db.relationship("Person", backref="project")
|
members = db.relationship("Person", backref="project")
|
||||||
|
|
||||||
query_class = ProjectQuery
|
query_class = ProjectQuery
|
||||||
|
default_currency = db.Column(db.String(3))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _to_serialize(self):
|
def _to_serialize(self):
|
||||||
|
@ -80,6 +81,7 @@ class Project(db.Model):
|
||||||
"contact_email": self.contact_email,
|
"contact_email": self.contact_email,
|
||||||
"logging_preference": self.logging_preference.value,
|
"logging_preference": self.logging_preference.value,
|
||||||
"members": [],
|
"members": [],
|
||||||
|
"default_currency": self.default_currency,
|
||||||
}
|
}
|
||||||
|
|
||||||
balance = self.balance
|
balance = self.balance
|
||||||
|
@ -128,7 +130,10 @@ class Project(db.Model):
|
||||||
{
|
{
|
||||||
"member": member,
|
"member": member,
|
||||||
"paid": sum(
|
"paid": sum(
|
||||||
[bill.amount for bill in self.get_member_bills(member.id).all()]
|
[
|
||||||
|
bill.converted_amount
|
||||||
|
for bill in self.get_member_bills(member.id).all()
|
||||||
|
]
|
||||||
),
|
),
|
||||||
"spent": sum(
|
"spent": sum(
|
||||||
[
|
[
|
||||||
|
@ -151,7 +156,7 @@ class Project(db.Model):
|
||||||
"""
|
"""
|
||||||
monthly = defaultdict(lambda: defaultdict(float))
|
monthly = defaultdict(lambda: defaultdict(float))
|
||||||
for bill in self.get_bills().all():
|
for bill in self.get_bills().all():
|
||||||
monthly[bill.date.year][bill.date.month] += bill.amount
|
monthly[bill.date.year][bill.date.month] += bill.converted_amount
|
||||||
return monthly
|
return monthly
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -432,6 +437,9 @@ class Bill(db.Model):
|
||||||
what = db.Column(db.UnicodeText)
|
what = db.Column(db.UnicodeText)
|
||||||
external_link = db.Column(db.UnicodeText)
|
external_link = db.Column(db.UnicodeText)
|
||||||
|
|
||||||
|
original_currency = db.Column(db.String(3))
|
||||||
|
converted_amount = db.Column(db.Float)
|
||||||
|
|
||||||
archive = db.Column(db.Integer, db.ForeignKey("archive.id"))
|
archive = db.Column(db.Integer, db.ForeignKey("archive.id"))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -445,9 +453,11 @@ class Bill(db.Model):
|
||||||
"creation_date": self.creation_date,
|
"creation_date": self.creation_date,
|
||||||
"what": self.what,
|
"what": self.what,
|
||||||
"external_link": self.external_link,
|
"external_link": self.external_link,
|
||||||
|
"original_currency": self.original_currency,
|
||||||
|
"converted_amount": self.converted_amount,
|
||||||
}
|
}
|
||||||
|
|
||||||
def pay_each(self):
|
def pay_each_default(self, amount):
|
||||||
"""Compute what each share has to pay"""
|
"""Compute what each share has to pay"""
|
||||||
if self.owers:
|
if self.owers:
|
||||||
weights = (
|
weights = (
|
||||||
|
@ -455,13 +465,16 @@ class Bill(db.Model):
|
||||||
.join(billowers, Bill)
|
.join(billowers, Bill)
|
||||||
.filter(Bill.id == self.id)
|
.filter(Bill.id == self.id)
|
||||||
).scalar()
|
).scalar()
|
||||||
return self.amount / weights
|
return amount / weights
|
||||||
else:
|
else:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.what
|
return self.what
|
||||||
|
|
||||||
|
def pay_each(self):
|
||||||
|
return self.pay_each_default(self.converted_amount)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return (
|
return (
|
||||||
f"<Bill of {self.amount} from {self.payer} for "
|
f"<Bill of {self.amount} from {self.payer} for "
|
||||||
|
|
|
@ -10,6 +10,7 @@ from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
|
|
||||||
from ihatemoney import default_settings
|
from ihatemoney import default_settings
|
||||||
from ihatemoney.api.v1 import api as apiv1
|
from ihatemoney.api.v1 import api as apiv1
|
||||||
|
from ihatemoney.currency_convertor import CurrencyConverter
|
||||||
from ihatemoney.models import db
|
from ihatemoney.models import db
|
||||||
from ihatemoney.utils import (
|
from ihatemoney.utils import (
|
||||||
IhmJSONEncoder,
|
IhmJSONEncoder,
|
||||||
|
@ -137,6 +138,9 @@ def create_app(
|
||||||
# Configure the a, root="main"pplication
|
# Configure the a, root="main"pplication
|
||||||
setup_database(app)
|
setup_database(app)
|
||||||
|
|
||||||
|
# Setup Currency Cache
|
||||||
|
CurrencyConverter()
|
||||||
|
|
||||||
mail = Mail()
|
mail = Mail()
|
||||||
mail.init_app(app)
|
mail.init_app(app)
|
||||||
app.mail = mail
|
app.mail = mail
|
||||||
|
|
|
@ -75,6 +75,7 @@
|
||||||
{{ input(form.name) }}
|
{{ input(form.name) }}
|
||||||
{{ input(form.password) }}
|
{{ input(form.password) }}
|
||||||
{{ input(form.contact_email) }}
|
{{ input(form.contact_email) }}
|
||||||
|
{{ input(form.default_currency) }}
|
||||||
{% if not home %}
|
{% if not home %}
|
||||||
{{ submit(form.submit, home=True) }}
|
{{ submit(form.submit, home=True) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -96,6 +97,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{ input(form.default_currency) }}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn btn-primary">{{ _("Edit the project") }}</button>
|
<button class="btn btn-primary">{{ _("Edit the project") }}</button>
|
||||||
<a id="delete-project" style="color:red; margin-left:10px; cursor:pointer; ">{{ _("delete") }}</a>
|
<a id="delete-project" style="color:red; margin-left:10px; cursor:pointer; ">{{ _("delete") }}</a>
|
||||||
|
@ -122,6 +124,9 @@
|
||||||
{{ input(form.what, inline=True) }}
|
{{ input(form.what, inline=True) }}
|
||||||
{{ input(form.payer, inline=True, class="form-control custom-select") }}
|
{{ input(form.payer, inline=True, class="form-control custom-select") }}
|
||||||
{{ input(form.amount, inline=True) }}
|
{{ input(form.amount, inline=True) }}
|
||||||
|
{% if not form.original_currency.render_kw %}
|
||||||
|
{{ input(form.original_currency, inline=True) }}
|
||||||
|
{% endif %}
|
||||||
{{ input(form.external_link, inline=True) }}
|
{{ input(form.external_link, inline=True) }}
|
||||||
|
|
||||||
<div class="form-group row">
|
<div class="form-group row">
|
||||||
|
|
|
@ -225,6 +225,10 @@
|
||||||
{{ simple_property_change(event, _("Amount")) }}
|
{{ simple_property_change(event, _("Amount")) }}
|
||||||
{% elif event.prop_changed == "date" %}
|
{% elif event.prop_changed == "date" %}
|
||||||
{{ simple_property_change(event, _("Date")) }}
|
{{ simple_property_change(event, _("Date")) }}
|
||||||
|
{% elif event.prop_changed == "original_currency" %}
|
||||||
|
{{ simple_property_change(event, _("Currency")) }}
|
||||||
|
{% elif event.prop_changed == "converted_amount" %}
|
||||||
|
{{ simple_property_change(event, _("Amount in %(currency)s", currency=g.project.default_currency)) }}
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ describe_object(event) }} {{ _("modified") }}
|
{{ describe_object(event) }} {{ _("modified") }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -111,7 +111,19 @@
|
||||||
<div class="clearfix"></div>
|
<div class="clearfix"></div>
|
||||||
|
|
||||||
<table id="bill_table" class="col table table-striped table-hover table-responsive-sm">
|
<table id="bill_table" class="col table table-striped table-hover table-responsive-sm">
|
||||||
<thead><tr><th>{{ _("When?") }}</th><th>{{ _("Who paid?") }}</<th><th>{{ _("For what?") }}</th><th>{{ _("For whom?") }}</th><th>{{ _("How much?") }}</th><th>{{ _("Actions") }}</th></tr></thead>
|
<thead>
|
||||||
|
<tr><th>{{ _("When?") }}
|
||||||
|
</th><th>{{ _("Who paid?") }}
|
||||||
|
</th><th>{{ _("For what?") }}
|
||||||
|
</th><th>{{ _("For whom?") }}
|
||||||
|
</th><th>{{ _("How much?") }}
|
||||||
|
{% if g.project.default_currency != "No Currency" %}
|
||||||
|
</th><th>{{ _("Amount in %(currency)s", currency=g.project.default_currency) }}
|
||||||
|
{%- else -%}
|
||||||
|
</th><th>{{ _("Amount") }}
|
||||||
|
{% endif %}
|
||||||
|
</th><th>{{ _("Actions") }}</th></tr>
|
||||||
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for bill in bills.items %}
|
{% for bill in bills.items %}
|
||||||
<tr owers="{{bill.owers|join(',','id')}}" payer="{{bill.payer.id}}">
|
<tr owers="{{bill.owers|join(',','id')}}" payer="{{bill.payer.id}}">
|
||||||
|
@ -130,7 +142,14 @@
|
||||||
{%- else -%}
|
{%- else -%}
|
||||||
{{ bill.owers|join(', ', 'name') }}
|
{{ bill.owers|join(', ', 'name') }}
|
||||||
{%- endif %}</td>
|
{%- endif %}</td>
|
||||||
<td>{{ "%0.2f"|format(bill.amount) }} ({{ "%0.2f"|format(bill.pay_each()) }} {{ _("each") }})</td>
|
<td>
|
||||||
|
{% if bill.original_currency != "No Currency" %}
|
||||||
|
{{ "%0.2f"|format(bill.amount) }} {{bill.original_currency}} ({{ "%0.2f"|format(bill.pay_each_default(bill.amount)) }} {{bill.original_currency}} {{ _(" each") }})
|
||||||
|
{%- else -%}
|
||||||
|
{{ "%0.2f"|format(bill.amount) }} ({{ "%0.2f"|format(bill.pay_each_default(bill.amount)) }} {{ _(" each") }})
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ "%0.2f"|format(bill.converted_amount) }}</td>
|
||||||
<td class="bill-actions">
|
<td class="bill-actions">
|
||||||
<a class="edit" href="{{ url_for(".edit_bill", bill_id=bill.id) }}" title="{{ _("edit") }}">{{ _('edit') }}</a>
|
<a class="edit" href="{{ url_for(".edit_bill", bill_id=bill.id) }}" title="{{ _("edit") }}">{{ _('edit') }}</a>
|
||||||
<a class="delete" href="{{ url_for(".delete_bill", bill_id=bill.id) }}" title="{{ _("delete") }}">{{ _('delete') }}</a>
|
<a class="delete" href="{{ url_for(".delete_bill", bill_id=bill.id) }}" title="{{ _("delete") }}">{{ _('delete') }}</a>
|
||||||
|
|
|
@ -6,7 +6,7 @@ import json
|
||||||
import os
|
import os
|
||||||
from time import sleep
|
from time import sleep
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from flask import session
|
from flask import session
|
||||||
from flask_testing import TestCase
|
from flask_testing import TestCase
|
||||||
|
@ -14,6 +14,7 @@ from sqlalchemy import orm
|
||||||
from werkzeug.security import check_password_hash, generate_password_hash
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
from ihatemoney import history, models, utils
|
from ihatemoney import history, models, utils
|
||||||
|
from ihatemoney.currency_convertor import CurrencyConverter
|
||||||
from ihatemoney.manage import DeleteProject, GenerateConfig, GeneratePasswordHash
|
from ihatemoney.manage import DeleteProject, GenerateConfig, GeneratePasswordHash
|
||||||
from ihatemoney.run import create_app, db, load_configuration
|
from ihatemoney.run import create_app, db, load_configuration
|
||||||
from ihatemoney.versioning import LoggingMode
|
from ihatemoney.versioning import LoggingMode
|
||||||
|
@ -59,6 +60,7 @@ class BaseTestCase(TestCase):
|
||||||
"id": name,
|
"id": name,
|
||||||
"password": name,
|
"password": name,
|
||||||
"contact_email": f"{name}@notmyidea.org",
|
"contact_email": f"{name}@notmyidea.org",
|
||||||
|
"default_currency": "USD",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -68,6 +70,7 @@ class BaseTestCase(TestCase):
|
||||||
name=str(name),
|
name=str(name),
|
||||||
password=generate_password_hash(name),
|
password=generate_password_hash(name),
|
||||||
contact_email=f"{name}@notmyidea.org",
|
contact_email=f"{name}@notmyidea.org",
|
||||||
|
default_currency="USD",
|
||||||
)
|
)
|
||||||
models.db.session.add(project)
|
models.db.session.add(project)
|
||||||
models.db.session.commit()
|
models.db.session.commit()
|
||||||
|
@ -254,6 +257,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
||||||
"id": "raclette",
|
"id": "raclette",
|
||||||
"password": "party",
|
"password": "party",
|
||||||
"contact_email": "raclette@notmyidea.org",
|
"contact_email": "raclette@notmyidea.org",
|
||||||
|
"default_currency": "USD",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -273,6 +277,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
||||||
"id": "raclette", # already used !
|
"id": "raclette", # already used !
|
||||||
"password": "party",
|
"password": "party",
|
||||||
"contact_email": "raclette@notmyidea.org",
|
"contact_email": "raclette@notmyidea.org",
|
||||||
|
"default_currency": "USD",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -290,6 +295,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
||||||
"id": "raclette",
|
"id": "raclette",
|
||||||
"password": "party",
|
"password": "party",
|
||||||
"contact_email": "raclette@notmyidea.org",
|
"contact_email": "raclette@notmyidea.org",
|
||||||
|
"default_currency": "USD",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -310,6 +316,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
||||||
"id": "raclette",
|
"id": "raclette",
|
||||||
"password": "party",
|
"password": "party",
|
||||||
"contact_email": "raclette@notmyidea.org",
|
"contact_email": "raclette@notmyidea.org",
|
||||||
|
"default_currency": "USD",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -329,6 +336,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
||||||
"id": "raclette",
|
"id": "raclette",
|
||||||
"password": "party",
|
"password": "party",
|
||||||
"contact_email": "raclette@notmyidea.org",
|
"contact_email": "raclette@notmyidea.org",
|
||||||
|
"default_currency": "USD",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -841,6 +849,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
||||||
"contact_email": "alexis@notmyidea.org",
|
"contact_email": "alexis@notmyidea.org",
|
||||||
"password": "didoudida",
|
"password": "didoudida",
|
||||||
"logging_preference": LoggingMode.ENABLED.value,
|
"logging_preference": LoggingMode.ENABLED.value,
|
||||||
|
"default_currency": "USD",
|
||||||
}
|
}
|
||||||
|
|
||||||
resp = self.client.post("/raclette/edit", data=new_data, follow_redirects=True)
|
resp = self.client.post("/raclette/edit", data=new_data, follow_redirects=True)
|
||||||
|
@ -849,6 +858,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
||||||
|
|
||||||
self.assertEqual(project.name, new_data["name"])
|
self.assertEqual(project.name, new_data["name"])
|
||||||
self.assertEqual(project.contact_email, new_data["contact_email"])
|
self.assertEqual(project.contact_email, new_data["contact_email"])
|
||||||
|
self.assertEqual(project.default_currency, new_data["default_currency"])
|
||||||
self.assertTrue(check_password_hash(project.password, new_data["password"]))
|
self.assertTrue(check_password_hash(project.password, new_data["password"]))
|
||||||
|
|
||||||
# Editing a project with a wrong email address should fail
|
# Editing a project with a wrong email address should fail
|
||||||
|
@ -1099,6 +1109,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
||||||
"payer": 1,
|
"payer": 1,
|
||||||
"payed_for": [1, 2, 3, 4],
|
"payed_for": [1, 2, 3, 4],
|
||||||
"amount": "10.0",
|
"amount": "10.0",
|
||||||
|
"original_currency": "USD",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -1110,6 +1121,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
||||||
"payer": 2,
|
"payer": 2,
|
||||||
"payed_for": [1, 3],
|
"payed_for": [1, 3],
|
||||||
"amount": "200",
|
"amount": "200",
|
||||||
|
"original_currency": "USD",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -1121,6 +1133,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
||||||
"payer": 3,
|
"payer": 3,
|
||||||
"payed_for": [2],
|
"payed_for": [2],
|
||||||
"amount": "13.33",
|
"amount": "13.33",
|
||||||
|
"original_currency": "USD",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -1425,6 +1438,7 @@ class APITestCase(IhatemoneyTestCase):
|
||||||
"id": id,
|
"id": id,
|
||||||
"password": password,
|
"password": password,
|
||||||
"contact_email": contact,
|
"contact_email": contact,
|
||||||
|
"default_currency": "USD",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -1486,6 +1500,7 @@ class APITestCase(IhatemoneyTestCase):
|
||||||
"id": "raclette",
|
"id": "raclette",
|
||||||
"password": "raclette",
|
"password": "raclette",
|
||||||
"contact_email": "not-an-email",
|
"contact_email": "not-an-email",
|
||||||
|
"default_currency": "USD",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -1514,6 +1529,7 @@ class APITestCase(IhatemoneyTestCase):
|
||||||
"members": [],
|
"members": [],
|
||||||
"name": "raclette",
|
"name": "raclette",
|
||||||
"contact_email": "raclette@notmyidea.org",
|
"contact_email": "raclette@notmyidea.org",
|
||||||
|
"default_currency": "USD",
|
||||||
"id": "raclette",
|
"id": "raclette",
|
||||||
"logging_preference": 1,
|
"logging_preference": 1,
|
||||||
}
|
}
|
||||||
|
@ -1525,6 +1541,7 @@ class APITestCase(IhatemoneyTestCase):
|
||||||
"/api/projects/raclette",
|
"/api/projects/raclette",
|
||||||
data={
|
data={
|
||||||
"contact_email": "yeah@notmyidea.org",
|
"contact_email": "yeah@notmyidea.org",
|
||||||
|
"default_currency": "USD",
|
||||||
"password": "raclette",
|
"password": "raclette",
|
||||||
"name": "The raclette party",
|
"name": "The raclette party",
|
||||||
"project_history": "y",
|
"project_history": "y",
|
||||||
|
@ -1542,6 +1559,7 @@ class APITestCase(IhatemoneyTestCase):
|
||||||
expected = {
|
expected = {
|
||||||
"name": "The raclette party",
|
"name": "The raclette party",
|
||||||
"contact_email": "yeah@notmyidea.org",
|
"contact_email": "yeah@notmyidea.org",
|
||||||
|
"default_currency": "USD",
|
||||||
"members": [],
|
"members": [],
|
||||||
"id": "raclette",
|
"id": "raclette",
|
||||||
"logging_preference": 1,
|
"logging_preference": 1,
|
||||||
|
@ -1554,6 +1572,7 @@ class APITestCase(IhatemoneyTestCase):
|
||||||
"/api/projects/raclette",
|
"/api/projects/raclette",
|
||||||
data={
|
data={
|
||||||
"contact_email": "yeah@notmyidea.org",
|
"contact_email": "yeah@notmyidea.org",
|
||||||
|
"default_currency": "USD",
|
||||||
"password": "tartiflette",
|
"password": "tartiflette",
|
||||||
"name": "The raclette party",
|
"name": "The raclette party",
|
||||||
},
|
},
|
||||||
|
@ -1776,6 +1795,8 @@ class APITestCase(IhatemoneyTestCase):
|
||||||
"amount": 25.0,
|
"amount": 25.0,
|
||||||
"date": "2011-08-10",
|
"date": "2011-08-10",
|
||||||
"id": 1,
|
"id": 1,
|
||||||
|
"converted_amount": 25.0,
|
||||||
|
"original_currency": "USD",
|
||||||
"external_link": "https://raclette.fr",
|
"external_link": "https://raclette.fr",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1845,6 +1866,8 @@ class APITestCase(IhatemoneyTestCase):
|
||||||
"amount": 25.0,
|
"amount": 25.0,
|
||||||
"date": "2011-09-10",
|
"date": "2011-09-10",
|
||||||
"external_link": "https://raclette.fr",
|
"external_link": "https://raclette.fr",
|
||||||
|
"converted_amount": 25.0,
|
||||||
|
"original_currency": "USD",
|
||||||
"id": 1,
|
"id": 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1922,6 +1945,8 @@ class APITestCase(IhatemoneyTestCase):
|
||||||
"date": "2011-08-10",
|
"date": "2011-08-10",
|
||||||
"id": id,
|
"id": id,
|
||||||
"external_link": "",
|
"external_link": "",
|
||||||
|
"original_currency": "USD",
|
||||||
|
"converted_amount": expected_amount,
|
||||||
}
|
}
|
||||||
|
|
||||||
got = json.loads(req.data.decode("utf-8"))
|
got = json.loads(req.data.decode("utf-8"))
|
||||||
|
@ -2064,6 +2089,8 @@ class APITestCase(IhatemoneyTestCase):
|
||||||
"date": "2011-08-10",
|
"date": "2011-08-10",
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"external_link": "",
|
"external_link": "",
|
||||||
|
"converted_amount": 25.0,
|
||||||
|
"original_currency": "USD",
|
||||||
}
|
}
|
||||||
got = json.loads(req.data.decode("utf-8"))
|
got = json.loads(req.data.decode("utf-8"))
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
@ -2106,6 +2133,7 @@ class APITestCase(IhatemoneyTestCase):
|
||||||
"id": "raclette",
|
"id": "raclette",
|
||||||
"name": "raclette",
|
"name": "raclette",
|
||||||
"logging_preference": 1,
|
"logging_preference": 1,
|
||||||
|
"default_currency": "USD",
|
||||||
}
|
}
|
||||||
|
|
||||||
self.assertStatus(200, req)
|
self.assertStatus(200, req)
|
||||||
|
@ -2273,6 +2301,7 @@ class HistoryTestCase(IhatemoneyTestCase):
|
||||||
"name": "demo",
|
"name": "demo",
|
||||||
"contact_email": "demo@notmyidea.org",
|
"contact_email": "demo@notmyidea.org",
|
||||||
"password": "demo",
|
"password": "demo",
|
||||||
|
"default_currency": "USD",
|
||||||
}
|
}
|
||||||
|
|
||||||
if logging_preference != LoggingMode.DISABLED:
|
if logging_preference != LoggingMode.DISABLED:
|
||||||
|
@ -2327,6 +2356,7 @@ class HistoryTestCase(IhatemoneyTestCase):
|
||||||
"contact_email": "demo2@notmyidea.org",
|
"contact_email": "demo2@notmyidea.org",
|
||||||
"password": "123456",
|
"password": "123456",
|
||||||
"project_history": "y",
|
"project_history": "y",
|
||||||
|
"default_currency": "USD",
|
||||||
}
|
}
|
||||||
|
|
||||||
resp = self.client.post("/demo/edit", data=new_data, follow_redirects=True)
|
resp = self.client.post("/demo/edit", data=new_data, follow_redirects=True)
|
||||||
|
@ -2422,6 +2452,7 @@ class HistoryTestCase(IhatemoneyTestCase):
|
||||||
"name": "demo2",
|
"name": "demo2",
|
||||||
"contact_email": "demo2@notmyidea.org",
|
"contact_email": "demo2@notmyidea.org",
|
||||||
"password": "123456",
|
"password": "123456",
|
||||||
|
"default_currency": "USD",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Keep privacy settings where they were
|
# Keep privacy settings where they were
|
||||||
|
@ -2850,5 +2881,23 @@ class HistoryTestCase(IhatemoneyTestCase):
|
||||||
self.assertEqual(len(history_list), 6)
|
self.assertEqual(len(history_list), 6)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCurrencyConverter(unittest.TestCase):
|
||||||
|
converter = CurrencyConverter()
|
||||||
|
mock_data = {"USD": 1, "EUR": 0.8115}
|
||||||
|
converter.get_rates = MagicMock(return_value=mock_data)
|
||||||
|
|
||||||
|
def test_only_one_instance(self):
|
||||||
|
one = id(CurrencyConverter())
|
||||||
|
two = id(CurrencyConverter())
|
||||||
|
self.assertEqual(one, two)
|
||||||
|
|
||||||
|
def test_get_currencies(self):
|
||||||
|
self.assertCountEqual(self.converter.get_currencies(), ["USD", "EUR"])
|
||||||
|
|
||||||
|
def test_exchange_currency(self):
|
||||||
|
result = self.converter.exchange_currency(100, "USD", "EUR")
|
||||||
|
self.assertEqual(result, 81.15)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
@ -37,10 +37,10 @@ from sqlalchemy_continuum import Operation
|
||||||
from werkzeug.exceptions import NotFound
|
from werkzeug.exceptions import NotFound
|
||||||
from werkzeug.security import check_password_hash, generate_password_hash
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
|
from ihatemoney.currency_convertor import CurrencyConverter
|
||||||
from ihatemoney.forms import (
|
from ihatemoney.forms import (
|
||||||
AdminAuthenticationForm,
|
AdminAuthenticationForm,
|
||||||
AuthenticationForm,
|
AuthenticationForm,
|
||||||
EditProjectForm,
|
|
||||||
InviteForm,
|
InviteForm,
|
||||||
MemberForm,
|
MemberForm,
|
||||||
PasswordReminder,
|
PasswordReminder,
|
||||||
|
@ -48,6 +48,7 @@ from ihatemoney.forms import (
|
||||||
ResetPasswordForm,
|
ResetPasswordForm,
|
||||||
UploadForm,
|
UploadForm,
|
||||||
get_billform_for,
|
get_billform_for,
|
||||||
|
get_editprojectform_for,
|
||||||
)
|
)
|
||||||
from ihatemoney.history import get_history, get_history_queries
|
from ihatemoney.history import get_history, get_history_queries
|
||||||
from ihatemoney.models import Bill, LoggingMode, Person, Project, db
|
from ihatemoney.models import Bill, LoggingMode, Person, Project, db
|
||||||
|
@ -376,7 +377,7 @@ def reset_password():
|
||||||
|
|
||||||
@main.route("/<project_id>/edit", methods=["GET", "POST"])
|
@main.route("/<project_id>/edit", methods=["GET", "POST"])
|
||||||
def edit_project():
|
def edit_project():
|
||||||
edit_form = EditProjectForm()
|
edit_form = get_editprojectform_for(g.project)
|
||||||
import_form = UploadForm()
|
import_form = UploadForm()
|
||||||
# Import form
|
# Import form
|
||||||
if import_form.validate_on_submit():
|
if import_form.validate_on_submit():
|
||||||
|
@ -391,6 +392,18 @@ def edit_project():
|
||||||
# Edit form
|
# Edit form
|
||||||
if edit_form.validate_on_submit():
|
if edit_form.validate_on_submit():
|
||||||
project = edit_form.update(g.project)
|
project = edit_form.update(g.project)
|
||||||
|
# Update converted currency
|
||||||
|
if project.default_currency != CurrencyConverter.default:
|
||||||
|
for bill in project.get_bills():
|
||||||
|
|
||||||
|
if bill.original_currency == CurrencyConverter.default:
|
||||||
|
bill.original_currency = project.default_currency
|
||||||
|
|
||||||
|
bill.converted_amount = CurrencyConverter().exchange_currency(
|
||||||
|
bill.amount, bill.original_currency, project.default_currency
|
||||||
|
)
|
||||||
|
db.session.add(bill)
|
||||||
|
|
||||||
db.session.add(project)
|
db.session.add(project)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
@ -478,6 +491,7 @@ def import_project(file, project):
|
||||||
form.date = parse(b["date"])
|
form.date = parse(b["date"])
|
||||||
form.payer = id_dict[b["payer_name"]]
|
form.payer = id_dict[b["payer_name"]]
|
||||||
form.payed_for = owers_id
|
form.payed_for = owers_id
|
||||||
|
form.original_currency = b.get("original_currency")
|
||||||
|
|
||||||
db.session.add(form.fake_form(bill, project))
|
db.session.add(form.fake_form(bill, project))
|
||||||
|
|
||||||
|
@ -543,6 +557,7 @@ def demo():
|
||||||
name="demonstration",
|
name="demonstration",
|
||||||
password=generate_password_hash("demo"),
|
password=generate_password_hash("demo"),
|
||||||
contact_email="demo@notmyidea.org",
|
contact_email="demo@notmyidea.org",
|
||||||
|
default_currency="EUR",
|
||||||
)
|
)
|
||||||
db.session.add(project)
|
db.session.add(project)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
|
@ -23,6 +23,7 @@ include_package_data = True
|
||||||
zip_safe = False
|
zip_safe = False
|
||||||
install_requires =
|
install_requires =
|
||||||
blinker==1.4
|
blinker==1.4
|
||||||
|
cachetools==4.1.0
|
||||||
debts==0.5
|
debts==0.5
|
||||||
email_validator==1.0.5
|
email_validator==1.0.5
|
||||||
Flask-Babel==1.0.0
|
Flask-Babel==1.0.0
|
||||||
|
@ -37,6 +38,7 @@ install_requires =
|
||||||
Flask==1.1.2
|
Flask==1.1.2
|
||||||
itsdangerous==1.1.0
|
itsdangerous==1.1.0
|
||||||
Jinja2==2.11.2
|
Jinja2==2.11.2
|
||||||
|
requests==2.22.0
|
||||||
SQLAlchemy-Continuum==1.3.9
|
SQLAlchemy-Continuum==1.3.9
|
||||||
|
|
||||||
[options.extras_require]
|
[options.extras_require]
|
||||||
|
|
Loading…
Reference in a new issue