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:
dark0dave 2020-04-29 21:57:08 +01:00 committed by GitHub
parent 162193c787
commit f389c56259
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 313 additions and 11 deletions

6
.gitignore vendored
View file

@ -1,4 +1,3 @@
ihatemoney.cfg
*.pyc
*.egg-info
dist
@ -13,3 +12,8 @@ build
.pytest_cache
ihatemoney/budget.db
.idea/
.envrc
.DS_Store
.idea
.python-version

View 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)

View file

@ -1,3 +1,4 @@
import copy
from datetime import datetime
from re import match
@ -8,7 +9,7 @@ from flask_wtf.file import FileAllowed, FileField, FileRequired
from flask_wtf.form import FlaskForm
from jinja2 import Markup
from werkzeug.security import check_password_hash, generate_password_hash
from wtforms.fields.core import SelectField, SelectMultipleField
from wtforms.fields.core import Label, SelectField, SelectMultipleField
from wtforms.fields.html5 import DateField, DecimalField, URLField
from wtforms.fields.simple import BooleanField, PasswordField, StringField, SubmitField
from wtforms.validators import (
@ -20,6 +21,7 @@ from wtforms.validators import (
ValidationError,
)
from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.models import LoggingMode, Person, Project
from ihatemoney.utils import eval_arithmetic_expression, slugify
@ -31,6 +33,18 @@ def strip_filter(string):
return string
def get_editprojectform_for(project, **kwargs):
"""Return an instance of EditProjectForm configured for a particular project.
"""
form = EditProjectForm(**kwargs)
choices = copy.copy(form.default_currency.choices)
choices.sort(
key=lambda rates: "" if rates[0] == project.default_currency else rates[0]
)
form.default_currency.choices = choices
return form
def get_billform_for(project, set_default=True, **kwargs):
"""Return an instance of BillForm configured for a particular project.
@ -39,6 +53,23 @@ def get_billform_for(project, set_default=True, **kwargs):
"""
form = BillForm(**kwargs)
if form.original_currency.data == "None":
form.original_currency.data = project.default_currency
if form.original_currency.data != CurrencyConverter.default:
choices = copy.copy(form.original_currency.choices)
choices.remove((CurrencyConverter.default, CurrencyConverter.default))
choices.sort(
key=lambda rates: "" if rates[0] == project.default_currency else rates[0]
)
form.original_currency.choices = choices
else:
form.original_currency.render_kw = {"default": True}
form.original_currency.data = CurrencyConverter.default
form.original_currency.label = Label(
"original_currency", "Currency (Default: %s)" % (project.default_currency)
)
active_members = [(m.id, m.name) for m in project.active_members]
form.payed_for.choices = form.payer.choices = active_members
@ -89,6 +120,15 @@ class EditProjectForm(FlaskForm):
contact_email = StringField(_("Email"), validators=[DataRequired(), Email()])
project_history = BooleanField(_("Enable project history"))
ip_recording = BooleanField(_("Use IP tracking for project history"))
currency_helper = CurrencyConverter()
default_currency = SelectField(
_("Default Currency"),
choices=[
(currency_name, currency_name)
for currency_name in currency_helper.get_currencies()
],
validators=[DataRequired()],
)
@property
def logging_preference(self):
@ -112,6 +152,7 @@ class EditProjectForm(FlaskForm):
password=generate_password_hash(self.password.data),
contact_email=self.contact_email.data,
logging_preference=self.logging_preference,
default_currency=self.default_currency.data,
)
return project
@ -125,6 +166,7 @@ class EditProjectForm(FlaskForm):
project.contact_email = self.contact_email.data
project.logging_preference = self.logging_preference
project.default_currency = self.default_currency.data
return project
@ -199,6 +241,15 @@ class BillForm(FlaskForm):
what = StringField(_("What?"), validators=[DataRequired()])
payer = SelectField(_("Payer"), validators=[DataRequired()], coerce=int)
amount = CalculatorStringField(_("Amount paid"), validators=[DataRequired()])
currency_helper = CurrencyConverter()
original_currency = SelectField(
_("Currency"),
choices=[
(currency_name, currency_name)
for currency_name in currency_helper.get_currencies()
],
validators=[DataRequired()],
)
external_link = URLField(
_("External link"),
validators=[Optional()],
@ -217,6 +268,10 @@ class BillForm(FlaskForm):
bill.external_link = self.external_link.data
bill.date = self.date.data
bill.owers = [Person.query.get(ower, project) for ower in self.payed_for.data]
bill.original_currency = self.original_currency.data
bill.converted_amount = self.currency_helper.exchange_currency(
bill.amount, bill.original_currency, project.default_currency
)
return bill
def fake_form(self, bill, project):
@ -226,6 +281,10 @@ class BillForm(FlaskForm):
bill.external_link = ""
bill.date = self.date
bill.owers = [Person.query.get(ower, project) for ower in self.payed_for]
bill.original_currency = CurrencyConverter.default
bill.converted_amount = self.currency_helper.exchange_currency(
bill.amount, bill.original_currency, project.default_currency
)
return bill
@ -234,6 +293,7 @@ class BillForm(FlaskForm):
self.amount.data = bill.amount
self.what.data = bill.what
self.external_link.data = bill.external_link
self.original_currency.data = bill.original_currency
self.date.data = bill.date
self.payed_for.data = [int(ower.id) for ower in bill.owers]

View file

@ -105,6 +105,14 @@ def get_history(project, human_readable_names=True):
if removed:
changeset["owers_removed"] = (None, removed)
# Remove converted_amount if amount changed in the same way.
if (
"amount" in changeset
and "converted_amount" in changeset
and changeset["amount"] == changeset["converted_amount"]
):
del changeset["converted_amount"]
for (prop, (val_before, val_after),) in changeset.items():
if human_readable_names:
if prop == "payer_id":

View file

@ -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 ###

View file

@ -71,6 +71,7 @@ class Project(db.Model):
members = db.relationship("Person", backref="project")
query_class = ProjectQuery
default_currency = db.Column(db.String(3))
@property
def _to_serialize(self):
@ -80,6 +81,7 @@ class Project(db.Model):
"contact_email": self.contact_email,
"logging_preference": self.logging_preference.value,
"members": [],
"default_currency": self.default_currency,
}
balance = self.balance
@ -128,7 +130,10 @@ class Project(db.Model):
{
"member": member,
"paid": sum(
[bill.amount for bill in self.get_member_bills(member.id).all()]
[
bill.converted_amount
for bill in self.get_member_bills(member.id).all()
]
),
"spent": sum(
[
@ -151,7 +156,7 @@ class Project(db.Model):
"""
monthly = defaultdict(lambda: defaultdict(float))
for bill in self.get_bills().all():
monthly[bill.date.year][bill.date.month] += bill.amount
monthly[bill.date.year][bill.date.month] += bill.converted_amount
return monthly
@property
@ -432,6 +437,9 @@ class Bill(db.Model):
what = db.Column(db.UnicodeText)
external_link = db.Column(db.UnicodeText)
original_currency = db.Column(db.String(3))
converted_amount = db.Column(db.Float)
archive = db.Column(db.Integer, db.ForeignKey("archive.id"))
@property
@ -445,9 +453,11 @@ class Bill(db.Model):
"creation_date": self.creation_date,
"what": self.what,
"external_link": self.external_link,
"original_currency": self.original_currency,
"converted_amount": self.converted_amount,
}
def pay_each(self):
def pay_each_default(self, amount):
"""Compute what each share has to pay"""
if self.owers:
weights = (
@ -455,13 +465,16 @@ class Bill(db.Model):
.join(billowers, Bill)
.filter(Bill.id == self.id)
).scalar()
return self.amount / weights
return amount / weights
else:
return 0
def __str__(self):
return self.what
def pay_each(self):
return self.pay_each_default(self.converted_amount)
def __repr__(self):
return (
f"<Bill of {self.amount} from {self.payer} for "

View file

@ -10,6 +10,7 @@ from werkzeug.middleware.proxy_fix import ProxyFix
from ihatemoney import default_settings
from ihatemoney.api.v1 import api as apiv1
from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.models import db
from ihatemoney.utils import (
IhmJSONEncoder,
@ -137,6 +138,9 @@ def create_app(
# Configure the a, root="main"pplication
setup_database(app)
# Setup Currency Cache
CurrencyConverter()
mail = Mail()
mail.init_app(app)
app.mail = mail

View file

@ -75,6 +75,7 @@
{{ input(form.name) }}
{{ input(form.password) }}
{{ input(form.contact_email) }}
{{ input(form.default_currency) }}
{% if not home %}
{{ submit(form.submit, home=True) }}
{% endif %}
@ -96,6 +97,7 @@
</div>
</div>
{{ input(form.default_currency) }}
<div class="actions">
<button class="btn btn-primary">{{ _("Edit the project") }}</button>
<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.payer, inline=True, class="form-control custom-select") }}
{{ input(form.amount, inline=True) }}
{% if not form.original_currency.render_kw %}
{{ input(form.original_currency, inline=True) }}
{% endif %}
{{ input(form.external_link, inline=True) }}
<div class="form-group row">

View file

@ -225,6 +225,10 @@
{{ simple_property_change(event, _("Amount")) }}
{% elif event.prop_changed == "date" %}
{{ simple_property_change(event, _("Date")) }}
{% elif event.prop_changed == "original_currency" %}
{{ simple_property_change(event, _("Currency")) }}
{% elif event.prop_changed == "converted_amount" %}
{{ simple_property_change(event, _("Amount in %(currency)s", currency=g.project.default_currency)) }}
{% else %}
{{ describe_object(event) }} {{ _("modified") }}
{% endif %}

View file

@ -111,7 +111,19 @@
<div class="clearfix"></div>
<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>
{% for bill in bills.items %}
<tr owers="{{bill.owers|join(',','id')}}" payer="{{bill.payer.id}}">
@ -130,7 +142,14 @@
{%- else -%}
{{ bill.owers|join(', ', 'name') }}
{%- 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">
<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>

View file

@ -6,7 +6,7 @@ import json
import os
from time import sleep
import unittest
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from flask import session
from flask_testing import TestCase
@ -14,6 +14,7 @@ from sqlalchemy import orm
from werkzeug.security import check_password_hash, generate_password_hash
from ihatemoney import history, models, utils
from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.manage import DeleteProject, GenerateConfig, GeneratePasswordHash
from ihatemoney.run import create_app, db, load_configuration
from ihatemoney.versioning import LoggingMode
@ -59,6 +60,7 @@ class BaseTestCase(TestCase):
"id": name,
"password": name,
"contact_email": f"{name}@notmyidea.org",
"default_currency": "USD",
},
)
@ -68,6 +70,7 @@ class BaseTestCase(TestCase):
name=str(name),
password=generate_password_hash(name),
contact_email=f"{name}@notmyidea.org",
default_currency="USD",
)
models.db.session.add(project)
models.db.session.commit()
@ -254,6 +257,7 @@ class BudgetTestCase(IhatemoneyTestCase):
"id": "raclette",
"password": "party",
"contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
},
)
@ -273,6 +277,7 @@ class BudgetTestCase(IhatemoneyTestCase):
"id": "raclette", # already used !
"password": "party",
"contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
},
)
@ -290,6 +295,7 @@ class BudgetTestCase(IhatemoneyTestCase):
"id": "raclette",
"password": "party",
"contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
},
)
@ -310,6 +316,7 @@ class BudgetTestCase(IhatemoneyTestCase):
"id": "raclette",
"password": "party",
"contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
},
)
@ -329,6 +336,7 @@ class BudgetTestCase(IhatemoneyTestCase):
"id": "raclette",
"password": "party",
"contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
},
)
@ -841,6 +849,7 @@ class BudgetTestCase(IhatemoneyTestCase):
"contact_email": "alexis@notmyidea.org",
"password": "didoudida",
"logging_preference": LoggingMode.ENABLED.value,
"default_currency": "USD",
}
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.contact_email, new_data["contact_email"])
self.assertEqual(project.default_currency, new_data["default_currency"])
self.assertTrue(check_password_hash(project.password, new_data["password"]))
# Editing a project with a wrong email address should fail
@ -1099,6 +1109,7 @@ class BudgetTestCase(IhatemoneyTestCase):
"payer": 1,
"payed_for": [1, 2, 3, 4],
"amount": "10.0",
"original_currency": "USD",
},
)
@ -1110,6 +1121,7 @@ class BudgetTestCase(IhatemoneyTestCase):
"payer": 2,
"payed_for": [1, 3],
"amount": "200",
"original_currency": "USD",
},
)
@ -1121,6 +1133,7 @@ class BudgetTestCase(IhatemoneyTestCase):
"payer": 3,
"payed_for": [2],
"amount": "13.33",
"original_currency": "USD",
},
)
@ -1425,6 +1438,7 @@ class APITestCase(IhatemoneyTestCase):
"id": id,
"password": password,
"contact_email": contact,
"default_currency": "USD",
},
)
@ -1486,6 +1500,7 @@ class APITestCase(IhatemoneyTestCase):
"id": "raclette",
"password": "raclette",
"contact_email": "not-an-email",
"default_currency": "USD",
},
)
@ -1514,6 +1529,7 @@ class APITestCase(IhatemoneyTestCase):
"members": [],
"name": "raclette",
"contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
"id": "raclette",
"logging_preference": 1,
}
@ -1525,6 +1541,7 @@ class APITestCase(IhatemoneyTestCase):
"/api/projects/raclette",
data={
"contact_email": "yeah@notmyidea.org",
"default_currency": "USD",
"password": "raclette",
"name": "The raclette party",
"project_history": "y",
@ -1542,6 +1559,7 @@ class APITestCase(IhatemoneyTestCase):
expected = {
"name": "The raclette party",
"contact_email": "yeah@notmyidea.org",
"default_currency": "USD",
"members": [],
"id": "raclette",
"logging_preference": 1,
@ -1554,6 +1572,7 @@ class APITestCase(IhatemoneyTestCase):
"/api/projects/raclette",
data={
"contact_email": "yeah@notmyidea.org",
"default_currency": "USD",
"password": "tartiflette",
"name": "The raclette party",
},
@ -1776,6 +1795,8 @@ class APITestCase(IhatemoneyTestCase):
"amount": 25.0,
"date": "2011-08-10",
"id": 1,
"converted_amount": 25.0,
"original_currency": "USD",
"external_link": "https://raclette.fr",
}
@ -1845,6 +1866,8 @@ class APITestCase(IhatemoneyTestCase):
"amount": 25.0,
"date": "2011-09-10",
"external_link": "https://raclette.fr",
"converted_amount": 25.0,
"original_currency": "USD",
"id": 1,
}
@ -1922,6 +1945,8 @@ class APITestCase(IhatemoneyTestCase):
"date": "2011-08-10",
"id": id,
"external_link": "",
"original_currency": "USD",
"converted_amount": expected_amount,
}
got = json.loads(req.data.decode("utf-8"))
@ -2064,6 +2089,8 @@ class APITestCase(IhatemoneyTestCase):
"date": "2011-08-10",
"id": 1,
"external_link": "",
"converted_amount": 25.0,
"original_currency": "USD",
}
got = json.loads(req.data.decode("utf-8"))
self.assertEqual(
@ -2106,6 +2133,7 @@ class APITestCase(IhatemoneyTestCase):
"id": "raclette",
"name": "raclette",
"logging_preference": 1,
"default_currency": "USD",
}
self.assertStatus(200, req)
@ -2273,6 +2301,7 @@ class HistoryTestCase(IhatemoneyTestCase):
"name": "demo",
"contact_email": "demo@notmyidea.org",
"password": "demo",
"default_currency": "USD",
}
if logging_preference != LoggingMode.DISABLED:
@ -2327,6 +2356,7 @@ class HistoryTestCase(IhatemoneyTestCase):
"contact_email": "demo2@notmyidea.org",
"password": "123456",
"project_history": "y",
"default_currency": "USD",
}
resp = self.client.post("/demo/edit", data=new_data, follow_redirects=True)
@ -2422,6 +2452,7 @@ class HistoryTestCase(IhatemoneyTestCase):
"name": "demo2",
"contact_email": "demo2@notmyidea.org",
"password": "123456",
"default_currency": "USD",
}
# Keep privacy settings where they were
@ -2850,5 +2881,23 @@ class HistoryTestCase(IhatemoneyTestCase):
self.assertEqual(len(history_list), 6)
class TestCurrencyConverter(unittest.TestCase):
converter = CurrencyConverter()
mock_data = {"USD": 1, "EUR": 0.8115}
converter.get_rates = MagicMock(return_value=mock_data)
def test_only_one_instance(self):
one = id(CurrencyConverter())
two = id(CurrencyConverter())
self.assertEqual(one, two)
def test_get_currencies(self):
self.assertCountEqual(self.converter.get_currencies(), ["USD", "EUR"])
def test_exchange_currency(self):
result = self.converter.exchange_currency(100, "USD", "EUR")
self.assertEqual(result, 81.15)
if __name__ == "__main__":
unittest.main()

View file

@ -37,10 +37,10 @@ from sqlalchemy_continuum import Operation
from werkzeug.exceptions import NotFound
from werkzeug.security import check_password_hash, generate_password_hash
from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.forms import (
AdminAuthenticationForm,
AuthenticationForm,
EditProjectForm,
InviteForm,
MemberForm,
PasswordReminder,
@ -48,6 +48,7 @@ from ihatemoney.forms import (
ResetPasswordForm,
UploadForm,
get_billform_for,
get_editprojectform_for,
)
from ihatemoney.history import get_history, get_history_queries
from ihatemoney.models import Bill, LoggingMode, Person, Project, db
@ -376,7 +377,7 @@ def reset_password():
@main.route("/<project_id>/edit", methods=["GET", "POST"])
def edit_project():
edit_form = EditProjectForm()
edit_form = get_editprojectform_for(g.project)
import_form = UploadForm()
# Import form
if import_form.validate_on_submit():
@ -391,6 +392,18 @@ def edit_project():
# Edit form
if edit_form.validate_on_submit():
project = edit_form.update(g.project)
# Update converted currency
if project.default_currency != CurrencyConverter.default:
for bill in project.get_bills():
if bill.original_currency == CurrencyConverter.default:
bill.original_currency = project.default_currency
bill.converted_amount = CurrencyConverter().exchange_currency(
bill.amount, bill.original_currency, project.default_currency
)
db.session.add(bill)
db.session.add(project)
db.session.commit()
@ -478,6 +491,7 @@ def import_project(file, project):
form.date = parse(b["date"])
form.payer = id_dict[b["payer_name"]]
form.payed_for = owers_id
form.original_currency = b.get("original_currency")
db.session.add(form.fake_form(bill, project))
@ -543,6 +557,7 @@ def demo():
name="demonstration",
password=generate_password_hash("demo"),
contact_email="demo@notmyidea.org",
default_currency="EUR",
)
db.session.add(project)
db.session.commit()

View file

@ -23,6 +23,7 @@ include_package_data = True
zip_safe = False
install_requires =
blinker==1.4
cachetools==4.1.0
debts==0.5
email_validator==1.0.5
Flask-Babel==1.0.0
@ -37,6 +38,7 @@ install_requires =
Flask==1.1.2
itsdangerous==1.1.0
Jinja2==2.11.2
requests==2.22.0
SQLAlchemy-Continuum==1.3.9
[options.extras_require]