mirror of
https://github.com/spiral-project/ihatemoney.git
synced 2025-05-05 20:51:49 +02:00
Merge e70bd029b4
into 0e374cd5e0
This commit is contained in:
commit
9918b5afb6
9 changed files with 204 additions and 149 deletions
|
@ -1,14 +1,31 @@
|
||||||
|
# You can find more information about what these settings mean in the
|
||||||
|
# documentation, available online at
|
||||||
|
# http://ihatemoney.readthedocs.io/en/latest/installation.html#configuration
|
||||||
|
|
||||||
|
# Turn this on if you want to have more output on what's happening under the
|
||||||
|
# hood.
|
||||||
DEBUG = False
|
DEBUG = False
|
||||||
|
|
||||||
|
# The database URI, reprensenting the type of database and how to connect to it.
|
||||||
|
# Enter an absolute path here.
|
||||||
SQLALCHEMY_DATABASE_URI = 'sqlite:///budget.db'
|
SQLALCHEMY_DATABASE_URI = 'sqlite:///budget.db'
|
||||||
SQLACHEMY_ECHO = DEBUG
|
SQLACHEMY_ECHO = DEBUG
|
||||||
|
|
||||||
# Will likely become the default value in flask-sqlalchemy >=3 ; could be removed
|
# Will likely become the default value in flask-sqlalchemy >=3 ; could be removed
|
||||||
# then:
|
# then:
|
||||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
|
||||||
|
# You need to change this secret key, otherwise bad things might happen to your
|
||||||
|
# users.
|
||||||
SECRET_KEY = "tralala"
|
SECRET_KEY = "tralala"
|
||||||
|
|
||||||
|
# A python tuple describing the name and email adress of the sender of the mails.
|
||||||
MAIL_DEFAULT_SENDER = ("Budget manager", "budget@notmyidea.org")
|
MAIL_DEFAULT_SENDER = ("Budget manager", "budget@notmyidea.org")
|
||||||
|
|
||||||
|
# If set to True, a demonstration project will be activated.
|
||||||
ACTIVATE_DEMO_PROJECT = True
|
ACTIVATE_DEMO_PROJECT = True
|
||||||
|
|
||||||
|
# If not empty, the specified password must be entered to create new projects.
|
||||||
|
# DO NOT enter the password in cleartext. Generate a password hash with
|
||||||
|
# "ihatemoney generate_password_hash" instead.
|
||||||
ADMIN_PASSWORD = ""
|
ADMIN_PASSWORD = ""
|
||||||
|
|
|
@ -13,6 +13,7 @@ from jinja2 import Markup
|
||||||
from .models import Project, Person
|
from .models import Project, Person
|
||||||
from .utils import slugify
|
from .utils import slugify
|
||||||
|
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
@ -21,8 +22,9 @@ def get_billform_for(project, set_default=True, **kwargs):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
form = BillForm(**kwargs)
|
form = BillForm(**kwargs)
|
||||||
form.payed_for.choices = form.payer.choices = [(m.id, m.name)
|
active_members = [(m.id, m.name) for m in project.active_members]
|
||||||
for m in project.active_members]
|
|
||||||
|
form.payed_for.choices = form.payer.choices = active_members
|
||||||
form.payed_for.default = [m.id for m in project.active_members]
|
form.payed_for.default = [m.id for m in project.active_members]
|
||||||
|
|
||||||
if set_default and request.method == "GET":
|
if set_default and request.method == "GET":
|
||||||
|
@ -31,7 +33,9 @@ def get_billform_for(project, set_default=True, **kwargs):
|
||||||
|
|
||||||
|
|
||||||
class CommaDecimalField(DecimalField):
|
class CommaDecimalField(DecimalField):
|
||||||
|
|
||||||
"""A class to deal with comma in Decimal Field"""
|
"""A class to deal with comma in Decimal Field"""
|
||||||
|
|
||||||
def process_formdata(self, value):
|
def process_formdata(self, value):
|
||||||
if value:
|
if value:
|
||||||
value[0] = str(value[0]).replace(',', '.')
|
value[0] = str(value[0]).replace(',', '.')
|
||||||
|
@ -49,8 +53,8 @@ class EditProjectForm(FlaskForm):
|
||||||
Returns the created instance
|
Returns the created instance
|
||||||
"""
|
"""
|
||||||
project = Project(name=self.name.data, id=self.id.data,
|
project = Project(name=self.name.data, id=self.id.data,
|
||||||
password=self.password.data,
|
password=self.password.data,
|
||||||
contact_email=self.contact_email.data)
|
contact_email=self.contact_email.data)
|
||||||
return project
|
return project
|
||||||
|
|
||||||
def update(self, project):
|
def update(self, project):
|
||||||
|
@ -70,12 +74,13 @@ class ProjectForm(EditProjectForm):
|
||||||
def validate_id(form, field):
|
def validate_id(form, field):
|
||||||
form.id.data = slugify(field.data)
|
form.id.data = slugify(field.data)
|
||||||
if (form.id.data == "dashboard") or Project.query.get(form.id.data):
|
if (form.id.data == "dashboard") or Project.query.get(form.id.data):
|
||||||
raise ValidationError(Markup(_("The project identifier is used "
|
message = _("The project identifier is used to log in and for the "
|
||||||
"to log in and for the URL of the project. "
|
"URL of the project. "
|
||||||
"We tried to generate an identifier for you but a project "
|
"We tried to generate an identifier for you but a "
|
||||||
"with this identifier already exists. "
|
"project with this identifier already exists. "
|
||||||
"Please create a new identifier "
|
"Please create a new identifier that you will be able "
|
||||||
"that you will be able to remember.")))
|
"to remember")
|
||||||
|
raise ValidationError(Markup(message))
|
||||||
|
|
||||||
|
|
||||||
class AuthenticationForm(FlaskForm):
|
class AuthenticationForm(FlaskForm):
|
||||||
|
@ -104,7 +109,7 @@ class BillForm(FlaskForm):
|
||||||
payer = SelectField(_("Payer"), validators=[Required()], coerce=int)
|
payer = SelectField(_("Payer"), validators=[Required()], coerce=int)
|
||||||
amount = CommaDecimalField(_("Amount paid"), validators=[Required()])
|
amount = CommaDecimalField(_("Amount paid"), validators=[Required()])
|
||||||
payed_for = SelectMultipleField(_("For whom?"),
|
payed_for = SelectMultipleField(_("For whom?"),
|
||||||
validators=[Required()], coerce=int)
|
validators=[Required()], coerce=int)
|
||||||
submit = SubmitField(_("Submit"))
|
submit = SubmitField(_("Submit"))
|
||||||
submit2 = SubmitField(_("Submit and add a new one"))
|
submit2 = SubmitField(_("Submit and add a new one"))
|
||||||
|
|
||||||
|
@ -114,7 +119,7 @@ class BillForm(FlaskForm):
|
||||||
bill.what = self.what.data
|
bill.what = self.what.data
|
||||||
bill.date = self.date.data
|
bill.date = self.date.data
|
||||||
bill.owers = [Person.query.get(ower, project)
|
bill.owers = [Person.query.get(ower, project)
|
||||||
for ower in self.payed_for.data]
|
for ower in self.payed_for.data]
|
||||||
|
|
||||||
return bill
|
return bill
|
||||||
|
|
||||||
|
@ -175,17 +180,17 @@ class InviteForm(FlaskForm):
|
||||||
for email in [email.strip() for email in form.emails.data.split(",")]:
|
for email in [email.strip() for email in form.emails.data.split(",")]:
|
||||||
if not validator.regex.match(email):
|
if not validator.regex.match(email):
|
||||||
raise ValidationError(_("The email %(email)s is not valid",
|
raise ValidationError(_("The email %(email)s is not valid",
|
||||||
email=email))
|
email=email))
|
||||||
|
|
||||||
|
|
||||||
class ExportForm(FlaskForm):
|
class ExportForm(FlaskForm):
|
||||||
export_type = SelectField(_("What do you want to download ?"),
|
export_type = SelectField(
|
||||||
validators=[Required()],
|
_("What do you want to download ?"),
|
||||||
coerce=str,
|
validators=[Required()],
|
||||||
choices=[("bills", _("bills")), ("transactions", _("transactions"))]
|
coerce=str,
|
||||||
)
|
choices=[("bills", _("bills")), ("transactions", _("transactions"))])
|
||||||
export_format = SelectField(_("Export file format"),
|
export_format = SelectField(
|
||||||
validators=[Required()],
|
_("Export file format"),
|
||||||
coerce=str,
|
validators=[Required()],
|
||||||
choices=[("csv", "csv"), ("json", "json")]
|
coerce=str,
|
||||||
)
|
choices=[("csv", "csv"), ("json", "json")])
|
||||||
|
|
|
@ -10,7 +10,8 @@ from .models import db
|
||||||
|
|
||||||
|
|
||||||
class GeneratePasswordHash(Command):
|
class GeneratePasswordHash(Command):
|
||||||
"Get password from user and hash it without printing it in clear text"
|
|
||||||
|
"""Get password from user and hash it without printing it in clear text."""
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
password = getpass(prompt='Password: ')
|
password = getpass(prompt='Password: ')
|
||||||
|
|
|
@ -9,13 +9,10 @@ from sqlalchemy import orm
|
||||||
db = SQLAlchemy()
|
db = SQLAlchemy()
|
||||||
|
|
||||||
|
|
||||||
# define models
|
|
||||||
|
|
||||||
|
|
||||||
class Project(db.Model):
|
class Project(db.Model):
|
||||||
|
|
||||||
_to_serialize = ("id", "name", "password", "contact_email",
|
_to_serialize = ("id", "name", "password", "contact_email",
|
||||||
"members", "active_members", "balance")
|
"members", "active_members", "balance")
|
||||||
|
|
||||||
id = db.Column(db.String(64), primary_key=True)
|
id = db.Column(db.String(64), primary_key=True)
|
||||||
|
|
||||||
|
@ -32,12 +29,13 @@ class Project(db.Model):
|
||||||
def balance(self):
|
def balance(self):
|
||||||
|
|
||||||
balances, should_pay, should_receive = (defaultdict(int)
|
balances, should_pay, should_receive = (defaultdict(int)
|
||||||
for time in (1, 2, 3))
|
for time in (1, 2, 3))
|
||||||
|
|
||||||
# for each person
|
# for each person
|
||||||
for person in self.members:
|
for person in self.members:
|
||||||
# get the list of bills he has to pay
|
# get the list of bills he has to pay
|
||||||
bills = Bill.query.options(orm.subqueryload(Bill.owers)).filter(Bill.owers.contains(person))
|
bills = Bill.query.options(orm.subqueryload(Bill.owers)).filter(
|
||||||
|
Bill.owers.contains(person))
|
||||||
for bill in bills.all():
|
for bill in bills.all():
|
||||||
if person != bill.payer:
|
if person != bill.payer:
|
||||||
share = bill.pay_each() * person.weight
|
share = bill.pay_each() * person.weight
|
||||||
|
@ -56,6 +54,7 @@ class Project(db.Model):
|
||||||
|
|
||||||
def get_transactions_to_settle_bill(self, pretty_output=False):
|
def get_transactions_to_settle_bill(self, pretty_output=False):
|
||||||
"""Return a list of transactions that could be made to settle the bill"""
|
"""Return a list of transactions that could be made to settle the bill"""
|
||||||
|
|
||||||
def prettify(transactions, pretty_output):
|
def prettify(transactions, pretty_output):
|
||||||
""" Return pretty transactions
|
""" Return pretty transactions
|
||||||
"""
|
"""
|
||||||
|
@ -63,36 +62,52 @@ class Project(db.Model):
|
||||||
return transactions
|
return transactions
|
||||||
pretty_transactions = []
|
pretty_transactions = []
|
||||||
for transaction in transactions:
|
for transaction in transactions:
|
||||||
pretty_transactions.append({'ower': transaction['ower'].name,
|
pretty_transactions.append({
|
||||||
'receiver': transaction['receiver'].name,
|
'ower': transaction['ower'].name,
|
||||||
'amount': round(transaction['amount'], 2)})
|
'receiver': transaction['receiver'].name,
|
||||||
|
'amount': round(transaction['amount'], 2)
|
||||||
|
})
|
||||||
return pretty_transactions
|
return pretty_transactions
|
||||||
|
|
||||||
#cache value for better performance
|
# cache value for better performance
|
||||||
balance = self.balance
|
balance = self.balance
|
||||||
credits, debts, transactions = [],[],[]
|
credits, debts, transactions = [], [], []
|
||||||
# Create lists of credits and debts
|
# Create lists of credits and debts
|
||||||
for person in self.members:
|
for person in self.members:
|
||||||
if round(balance[person.id], 2) > 0:
|
if round(balance[person.id], 2) > 0:
|
||||||
credits.append({"person": person, "balance": balance[person.id]})
|
credits.append({"person": person, "balance": balance[person.id]})
|
||||||
elif round(balance[person.id], 2) < 0:
|
elif round(balance[person.id], 2) < 0:
|
||||||
debts.append({"person": person, "balance": -balance[person.id]})
|
debts.append({"person": person, "balance": -balance[person.id]})
|
||||||
|
|
||||||
# Try and find exact matches
|
# Try and find exact matches
|
||||||
for credit in credits:
|
for credit in credits:
|
||||||
match = self.exactmatch(round(credit["balance"], 2), debts)
|
match = self.exactmatch(round(credit["balance"], 2), debts)
|
||||||
if match:
|
if match:
|
||||||
for m in match:
|
for m in match:
|
||||||
transactions.append({"ower": m["person"], "receiver": credit["person"], "amount": m["balance"]})
|
transactions.append({
|
||||||
|
"ower": m["person"],
|
||||||
|
"receiver": credit["person"],
|
||||||
|
"amount": m["balance"]
|
||||||
|
})
|
||||||
debts.remove(m)
|
debts.remove(m)
|
||||||
credits.remove(credit)
|
credits.remove(credit)
|
||||||
# Split any remaining debts & credits
|
# Split any remaining debts & credits
|
||||||
while credits and debts:
|
while credits and debts:
|
||||||
|
|
||||||
if credits[0]["balance"] > debts[0]["balance"]:
|
if credits[0]["balance"] > debts[0]["balance"]:
|
||||||
transactions.append({"ower": debts[0]["person"], "receiver": credits[0]["person"], "amount": debts[0]["balance"]})
|
transactions.append({
|
||||||
|
"ower": debts[0]["person"],
|
||||||
|
"receiver": credits[0]["person"],
|
||||||
|
"amount": debts[0]["balance"]
|
||||||
|
})
|
||||||
credits[0]["balance"] = credits[0]["balance"] - debts[0]["balance"]
|
credits[0]["balance"] = credits[0]["balance"] - debts[0]["balance"]
|
||||||
del debts[0]
|
del debts[0]
|
||||||
else:
|
else:
|
||||||
transactions.append({"ower": debts[0]["person"], "receiver": credits[0]["person"], "amount": credits[0]["balance"]})
|
transactions.append({
|
||||||
|
"ower": debts[0]["person"],
|
||||||
|
"receiver": credits[0]["person"],
|
||||||
|
"amount": credits[0]["balance"]
|
||||||
|
})
|
||||||
debts[0]["balance"] = debts[0]["balance"] - credits[0]["balance"]
|
debts[0]["balance"] = debts[0]["balance"] - credits[0]["balance"]
|
||||||
del credits[0]
|
del credits[0]
|
||||||
|
|
||||||
|
@ -107,7 +122,7 @@ class Project(db.Model):
|
||||||
elif debts[0]["balance"] == credit:
|
elif debts[0]["balance"] == credit:
|
||||||
return [debts[0]]
|
return [debts[0]]
|
||||||
else:
|
else:
|
||||||
match = self.exactmatch(credit-debts[0]["balance"], debts[1:])
|
match = self.exactmatch(credit - debts[0]["balance"], debts[1:])
|
||||||
if match:
|
if match:
|
||||||
match.append(debts[0])
|
match.append(debts[0])
|
||||||
else:
|
else:
|
||||||
|
@ -136,12 +151,15 @@ class Project(db.Model):
|
||||||
owers = [ower.name for ower in bill.owers]
|
owers = [ower.name for ower in bill.owers]
|
||||||
else:
|
else:
|
||||||
owers = ', '.join([ower.name for ower in bill.owers])
|
owers = ', '.join([ower.name for ower in bill.owers])
|
||||||
pretty_bills.append({"what": bill.what,
|
|
||||||
"amount": round(bill.amount, 2),
|
pretty_bills.append({
|
||||||
"date": str(bill.date),
|
"what": bill.what,
|
||||||
"payer_name": Person.query.get(bill.payer_id).name,
|
"amount": round(bill.amount, 2),
|
||||||
"payer_weight": Person.query.get(bill.payer_id).weight,
|
"date": str(bill.date),
|
||||||
"owers": owers})
|
"payer_name": Person.query.get(bill.payer_id).name,
|
||||||
|
"payer_weight": Person.query.get(bill.payer_id).weight,
|
||||||
|
"owers": owers
|
||||||
|
})
|
||||||
return pretty_bills
|
return pretty_bills
|
||||||
|
|
||||||
def remove_member(self, member_id):
|
def remove_member(self, member_id):
|
||||||
|
@ -176,6 +194,7 @@ class Project(db.Model):
|
||||||
class Person(db.Model):
|
class Person(db.Model):
|
||||||
|
|
||||||
class PersonQuery(BaseQuery):
|
class PersonQuery(BaseQuery):
|
||||||
|
|
||||||
def get_by_name(self, name, project):
|
def get_by_name(self, name, project):
|
||||||
return Person.query.filter(Person.name == name)\
|
return Person.query.filter(Person.name == name)\
|
||||||
.filter(Project.id == project.id).one()
|
.filter(Project.id == project.id).one()
|
||||||
|
@ -212,7 +231,8 @@ class Person(db.Model):
|
||||||
return "<Person %s for project %s>" % (self.name, self.project.name)
|
return "<Person %s for project %s>" % (self.name, self.project.name)
|
||||||
|
|
||||||
# We need to manually define a join table for m2m relations
|
# We need to manually define a join table for m2m relations
|
||||||
billowers = db.Table('billowers',
|
billowers = db.Table(
|
||||||
|
'billowers',
|
||||||
db.Column('bill_id', db.Integer, db.ForeignKey('bill.id')),
|
db.Column('bill_id', db.Integer, db.ForeignKey('bill.id')),
|
||||||
db.Column('person_id', db.Integer, db.ForeignKey('person.id')),
|
db.Column('person_id', db.Integer, db.ForeignKey('person.id')),
|
||||||
)
|
)
|
||||||
|
@ -224,11 +244,11 @@ class Bill(db.Model):
|
||||||
|
|
||||||
def get(self, project, id):
|
def get(self, project, id):
|
||||||
try:
|
try:
|
||||||
return self.join(Person, Project)\
|
return (self.join(Person, Project)
|
||||||
.filter(Bill.payer_id == Person.id)\
|
.filter(Bill.payer_id == Person.id)
|
||||||
.filter(Person.project_id == Project.id)\
|
.filter(Person.project_id == Project.id)
|
||||||
.filter(Project.id == project.id)\
|
.filter(Project.id == project.id)
|
||||||
.filter(Bill.id == id).one()
|
.filter(Bill.id == id).one())
|
||||||
except orm.exc.NoResultFound:
|
except orm.exc.NoResultFound:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@ -262,8 +282,10 @@ class Bill(db.Model):
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "<Bill of %s from %s for %s>" % (self.amount,
|
return "<Bill of %s from %s for %s>" % (
|
||||||
self.payer, ", ".join([o.name for o in self.owers]))
|
self.amount,
|
||||||
|
self.payer, ", ".join([o.name for o in self.owers])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Archive(db.Model):
|
class Archive(db.Model):
|
||||||
|
|
|
@ -64,10 +64,10 @@ def configure():
|
||||||
# Since 2.0
|
# Since 2.0
|
||||||
warnings.warn(
|
warnings.warn(
|
||||||
"The way Ihatemoney stores your ADMIN_PASSWORD has changed. You are using an unhashed"
|
"The way Ihatemoney stores your ADMIN_PASSWORD has changed. You are using an unhashed"
|
||||||
+" ADMIN_PASSWORD, which is not supported anymore and won't let you access your admin"
|
+ " ADMIN_PASSWORD, which is not supported anymore and won't let you access your admin"
|
||||||
+" endpoints. Please use the command './budget/manage.py generate_password_hash'"
|
+ " endpoints. Please use the command './budget/manage.py generate_password_hash'"
|
||||||
+" to generate a proper password HASH and copy the output to the value of"
|
+ " to generate a proper password HASH and copy the output to the value of"
|
||||||
+" ADMIN_PASSWORD in your settings file.",
|
+ " ADMIN_PASSWORD in your settings file.",
|
||||||
UserWarning
|
UserWarning
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -106,6 +106,7 @@ babel = Babel(app)
|
||||||
# sentry
|
# sentry
|
||||||
sentry = Sentry(app)
|
sentry = Sentry(app)
|
||||||
|
|
||||||
|
|
||||||
@babel.localeselector
|
@babel.localeselector
|
||||||
def get_locale():
|
def get_locale():
|
||||||
# get the lang from the session if defined, fallback on the browser "accept
|
# get the lang from the session if defined, fallback on the browser "accept
|
||||||
|
@ -114,6 +115,7 @@ def get_locale():
|
||||||
setattr(g, 'lang', lang)
|
setattr(g, 'lang', lang)
|
||||||
return lang
|
return lang
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
app.run(host="0.0.0.0", debug=True)
|
app.run(host="0.0.0.0", debug=True)
|
||||||
|
|
||||||
|
|
|
@ -58,7 +58,7 @@ class TestCase(unittest.TestCase):
|
||||||
"""Create a fake project"""
|
"""Create a fake project"""
|
||||||
# create the project
|
# create the project
|
||||||
self.app.post("/create", data={
|
self.app.post("/create", data={
|
||||||
'name': name,
|
'name': name,
|
||||||
'id': name,
|
'id': name,
|
||||||
'password': name,
|
'password': name,
|
||||||
'contact_email': '%s@notmyidea.org' % name
|
'contact_email': '%s@notmyidea.org' % name
|
||||||
|
@ -66,7 +66,7 @@ class TestCase(unittest.TestCase):
|
||||||
|
|
||||||
def create_project(self, name):
|
def create_project(self, name):
|
||||||
models.db.session.add(models.Project(id=name, name=six.text_type(name),
|
models.db.session.add(models.Project(id=name, name=six.text_type(name),
|
||||||
password=name, contact_email="%s@notmyidea.org" % name))
|
password=name, contact_email="%s@notmyidea.org" % name))
|
||||||
models.db.session.commit()
|
models.db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@ -127,12 +127,12 @@ class BudgetTestCase(TestCase):
|
||||||
# sending a message to multiple persons
|
# sending a message to multiple persons
|
||||||
with run.mail.record_messages() as outbox:
|
with run.mail.record_messages() as outbox:
|
||||||
self.app.post("/raclette/invite",
|
self.app.post("/raclette/invite",
|
||||||
data={"emails": 'alexis@notmyidea.org, toto@notmyidea.org'})
|
data={"emails": 'alexis@notmyidea.org, toto@notmyidea.org'})
|
||||||
|
|
||||||
# only one message is sent to multiple persons
|
# only one message is sent to multiple persons
|
||||||
self.assertEqual(len(outbox), 1)
|
self.assertEqual(len(outbox), 1)
|
||||||
self.assertEqual(outbox[0].recipients,
|
self.assertEqual(outbox[0].recipients,
|
||||||
["alexis@notmyidea.org", "toto@notmyidea.org"])
|
["alexis@notmyidea.org", "toto@notmyidea.org"])
|
||||||
|
|
||||||
# mail address checking
|
# mail address checking
|
||||||
with run.mail.record_messages() as outbox:
|
with run.mail.record_messages() as outbox:
|
||||||
|
@ -144,7 +144,7 @@ class BudgetTestCase(TestCase):
|
||||||
# mixing good and wrong adresses shouldn't send any messages
|
# mixing good and wrong adresses shouldn't send any messages
|
||||||
with run.mail.record_messages() as outbox:
|
with run.mail.record_messages() as outbox:
|
||||||
self.app.post("/raclette/invite",
|
self.app.post("/raclette/invite",
|
||||||
data={"emails": 'alexis@notmyidea.org, alexis'}) # not valid
|
data={"emails": 'alexis@notmyidea.org, alexis'}) # not valid
|
||||||
|
|
||||||
# only one message is sent to multiple persons
|
# only one message is sent to multiple persons
|
||||||
self.assertEqual(len(outbox), 0)
|
self.assertEqual(len(outbox), 0)
|
||||||
|
@ -239,7 +239,7 @@ class BudgetTestCase(TestCase):
|
||||||
|
|
||||||
# remove fred
|
# remove fred
|
||||||
self.app.post("/raclette/members/%s/delete" %
|
self.app.post("/raclette/members/%s/delete" %
|
||||||
models.Project.query.get("raclette").members[-1].id)
|
models.Project.query.get("raclette").members[-1].id)
|
||||||
|
|
||||||
# as fred is not bound to any bill, he is removed
|
# as fred is not bound to any bill, he is removed
|
||||||
self.assertEqual(len(models.Project.query.get("raclette").members), 1)
|
self.assertEqual(len(models.Project.query.get("raclette").members), 1)
|
||||||
|
@ -263,7 +263,7 @@ class BudgetTestCase(TestCase):
|
||||||
# he is still in the database, but is deactivated
|
# he is still in the database, but is deactivated
|
||||||
self.assertEqual(len(models.Project.query.get("raclette").members), 2)
|
self.assertEqual(len(models.Project.query.get("raclette").members), 2)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
len(models.Project.query.get("raclette").active_members), 1)
|
len(models.Project.query.get("raclette").active_members), 1)
|
||||||
|
|
||||||
# as fred is now deactivated, check that he is not listed when adding
|
# as fred is now deactivated, check that he is not listed when adding
|
||||||
# a bill or displaying the balance
|
# a bill or displaying the balance
|
||||||
|
@ -276,7 +276,7 @@ class BudgetTestCase(TestCase):
|
||||||
# adding him again should reactivate him
|
# adding him again should reactivate him
|
||||||
self.app.post("/raclette/members/add", data={'name': 'fred'})
|
self.app.post("/raclette/members/add", data={'name': 'fred'})
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
len(models.Project.query.get("raclette").active_members), 2)
|
len(models.Project.query.get("raclette").active_members), 2)
|
||||||
|
|
||||||
# adding an user with the same name as another user from a different
|
# adding an user with the same name as another user from a different
|
||||||
# project should not cause any troubles
|
# project should not cause any troubles
|
||||||
|
@ -284,7 +284,7 @@ class BudgetTestCase(TestCase):
|
||||||
self.login("randomid")
|
self.login("randomid")
|
||||||
self.app.post("/randomid/members/add", data={'name': 'fred'})
|
self.app.post("/randomid/members/add", data={'name': 'fred'})
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
len(models.Project.query.get("randomid").active_members), 1)
|
len(models.Project.query.get("randomid").active_members), 1)
|
||||||
|
|
||||||
def test_person_model(self):
|
def test_person_model(self):
|
||||||
self.post_project("raclette")
|
self.post_project("raclette")
|
||||||
|
@ -321,11 +321,11 @@ class BudgetTestCase(TestCase):
|
||||||
response = self.app.get("/raclette/members/1/delete")
|
response = self.app.get("/raclette/members/1/delete")
|
||||||
self.assertEqual(response.status_code, 405)
|
self.assertEqual(response.status_code, 405)
|
||||||
|
|
||||||
#delete user using POST method
|
# delete user using POST method
|
||||||
self.app.post("/raclette/members/1/delete")
|
self.app.post("/raclette/members/1/delete")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
len(models.Project.query.get("raclette").active_members), 0)
|
len(models.Project.query.get("raclette").active_members), 0)
|
||||||
#try to delete an user already deleted
|
# try to delete an user already deleted
|
||||||
self.app.post("/raclette/members/1/delete")
|
self.app.post("/raclette/members/1/delete")
|
||||||
|
|
||||||
def test_demo(self):
|
def test_demo(self):
|
||||||
|
@ -358,7 +358,7 @@ class BudgetTestCase(TestCase):
|
||||||
# try to connect with wrong credentials should not work
|
# try to connect with wrong credentials should not work
|
||||||
with run.app.test_client() as c:
|
with run.app.test_client() as c:
|
||||||
resp = c.post("/authenticate",
|
resp = c.post("/authenticate",
|
||||||
data={'id': 'raclette', 'password': 'nope'})
|
data={'id': 'raclette', 'password': 'nope'})
|
||||||
|
|
||||||
self.assertIn("Authentication", resp.data.decode('utf-8'))
|
self.assertIn("Authentication", resp.data.decode('utf-8'))
|
||||||
self.assertNotIn('raclette', session)
|
self.assertNotIn('raclette', session)
|
||||||
|
@ -366,7 +366,7 @@ class BudgetTestCase(TestCase):
|
||||||
# try to connect with the right credentials should work
|
# try to connect with the right credentials should work
|
||||||
with run.app.test_client() as c:
|
with run.app.test_client() as c:
|
||||||
resp = c.post("/authenticate",
|
resp = c.post("/authenticate",
|
||||||
data={'id': 'raclette', 'password': 'raclette'})
|
data={'id': 'raclette', 'password': 'raclette'})
|
||||||
|
|
||||||
self.assertNotIn("Authentication", resp.data.decode('utf-8'))
|
self.assertNotIn("Authentication", resp.data.decode('utf-8'))
|
||||||
self.assertIn('raclette', session)
|
self.assertIn('raclette', session)
|
||||||
|
@ -461,7 +461,7 @@ class BudgetTestCase(TestCase):
|
||||||
balance = models.Project.query.get("raclette").balance
|
balance = models.Project.query.get("raclette").balance
|
||||||
self.assertEqual(set(balance.values()), set([19.0, -19.0]))
|
self.assertEqual(set(balance.values()), set([19.0, -19.0]))
|
||||||
|
|
||||||
#Bill with negative amount
|
# Bill with negative amount
|
||||||
self.app.post("/raclette/add", data={
|
self.app.post("/raclette/add", data={
|
||||||
'date': '2011-08-12',
|
'date': '2011-08-12',
|
||||||
'what': 'fromage à raclette',
|
'what': 'fromage à raclette',
|
||||||
|
@ -472,7 +472,7 @@ class BudgetTestCase(TestCase):
|
||||||
bill = models.Bill.query.filter(models.Bill.date == '2011-08-12')[0]
|
bill = models.Bill.query.filter(models.Bill.date == '2011-08-12')[0]
|
||||||
self.assertEqual(bill.amount, -25)
|
self.assertEqual(bill.amount, -25)
|
||||||
|
|
||||||
#add a bill with a comma
|
# add a bill with a comma
|
||||||
self.app.post("/raclette/add", data={
|
self.app.post("/raclette/add", data={
|
||||||
'date': '2011-08-01',
|
'date': '2011-08-01',
|
||||||
'what': 'fromage à raclette',
|
'what': 'fromage à raclette',
|
||||||
|
@ -520,15 +520,14 @@ class BudgetTestCase(TestCase):
|
||||||
self.app.post("/raclette/members/add", data={'name': 'alexis'})
|
self.app.post("/raclette/members/add", data={'name': 'alexis'})
|
||||||
self.app.post("/raclette/members/add", data={'name': 'tata', 'weight': 1})
|
self.app.post("/raclette/members/add", data={'name': 'tata', 'weight': 1})
|
||||||
|
|
||||||
resp = self.app.get("/raclette/")
|
resp = self.app.get("/raclette/")
|
||||||
self.assertIn('extra-info', resp.data.decode('utf-8'))
|
self.assertIn('extra-info', resp.data.decode('utf-8'))
|
||||||
|
|
||||||
self.app.post("/raclette/members/add", data={'name': 'freddy familly', 'weight': 4})
|
self.app.post("/raclette/members/add", data={'name': 'freddy familly', 'weight': 4})
|
||||||
|
|
||||||
resp = self.app.get("/raclette/")
|
resp = self.app.get("/raclette/")
|
||||||
self.assertNotIn('extra-info', resp.data.decode('utf-8'))
|
self.assertNotIn('extra-info', resp.data.decode('utf-8'))
|
||||||
|
|
||||||
|
|
||||||
def test_rounding(self):
|
def test_rounding(self):
|
||||||
self.post_project("raclette")
|
self.post_project("raclette")
|
||||||
|
|
||||||
|
@ -568,7 +567,8 @@ class BudgetTestCase(TestCase):
|
||||||
result[models.Project.query.get("raclette").members[1].id] = 0.0
|
result[models.Project.query.get("raclette").members[1].id] = 0.0
|
||||||
result[models.Project.query.get("raclette").members[2].id] = -8.12
|
result[models.Project.query.get("raclette").members[2].id] = -8.12
|
||||||
# Since we're using floating point to store currency, we can have some rounding issues that prevent test from working.
|
# Since we're using floating point to store currency, we can have some rounding issues that prevent test from working.
|
||||||
# However, we should obtain the same values as the theorical ones if we round to 2 decimals, like in the UI.
|
# However, we should obtain the same values as the theorical ones if we
|
||||||
|
# round to 2 decimals, like in the UI.
|
||||||
for key, value in six.iteritems(balance):
|
for key, value in six.iteritems(balance):
|
||||||
self.assertEqual(round(value, 2), result[key])
|
self.assertEqual(round(value, 2), result[key])
|
||||||
|
|
||||||
|
@ -583,7 +583,7 @@ class BudgetTestCase(TestCase):
|
||||||
}
|
}
|
||||||
|
|
||||||
resp = self.app.post("/raclette/edit", data=new_data,
|
resp = self.app.post("/raclette/edit", data=new_data,
|
||||||
follow_redirects=True)
|
follow_redirects=True)
|
||||||
self.assertEqual(resp.status_code, 200)
|
self.assertEqual(resp.status_code, 200)
|
||||||
project = models.Project.query.get("raclette")
|
project = models.Project.query.get("raclette")
|
||||||
|
|
||||||
|
@ -594,7 +594,7 @@ class BudgetTestCase(TestCase):
|
||||||
new_data['contact_email'] = 'wrong_email'
|
new_data['contact_email'] = 'wrong_email'
|
||||||
|
|
||||||
resp = self.app.post("/raclette/edit", data=new_data,
|
resp = self.app.post("/raclette/edit", data=new_data,
|
||||||
follow_redirects=True)
|
follow_redirects=True)
|
||||||
self.assertIn("Invalid email address", resp.data.decode('utf-8'))
|
self.assertIn("Invalid email address", resp.data.decode('utf-8'))
|
||||||
|
|
||||||
def test_dashboard(self):
|
def test_dashboard(self):
|
||||||
|
@ -613,7 +613,7 @@ class BudgetTestCase(TestCase):
|
||||||
self.app.post("/raclette/members/add", data={'name': 'alexis'})
|
self.app.post("/raclette/members/add", data={'name': 'alexis'})
|
||||||
self.app.post("/raclette/members/add", data={'name': 'fred'})
|
self.app.post("/raclette/members/add", data={'name': 'fred'})
|
||||||
self.app.post("/raclette/members/add", data={'name': 'tata'})
|
self.app.post("/raclette/members/add", data={'name': 'tata'})
|
||||||
#Add a member with a balance=0 :
|
# Add a member with a balance=0 :
|
||||||
self.app.post("/raclette/members/add", data={'name': 'toto'})
|
self.app.post("/raclette/members/add", data={'name': 'toto'})
|
||||||
|
|
||||||
# create bills
|
# create bills
|
||||||
|
@ -640,13 +640,13 @@ class BudgetTestCase(TestCase):
|
||||||
'payed_for': [1, 2],
|
'payed_for': [1, 2],
|
||||||
'amount': '10',
|
'amount': '10',
|
||||||
})
|
})
|
||||||
project = models.Project.query.get('raclette')
|
project = models.Project.query.get('raclette')
|
||||||
transactions = project.get_transactions_to_settle_bill()
|
transactions = project.get_transactions_to_settle_bill()
|
||||||
members = defaultdict(int)
|
members = defaultdict(int)
|
||||||
#We should have the same values between transactions and project balances
|
# We should have the same values between transactions and project balances
|
||||||
for t in transactions:
|
for t in transactions:
|
||||||
members[t['ower']]-=t['amount']
|
members[t['ower']] -= t['amount']
|
||||||
members[t['receiver']]+=t['amount']
|
members[t['receiver']] += t['amount']
|
||||||
balance = models.Project.query.get("raclette").balance
|
balance = models.Project.query.get("raclette").balance
|
||||||
for m, a in members.items():
|
for m, a in members.items():
|
||||||
self.assertEqual(a, balance[m.id])
|
self.assertEqual(a, balance[m.id])
|
||||||
|
@ -684,7 +684,7 @@ class BudgetTestCase(TestCase):
|
||||||
'payed_for': [2],
|
'payed_for': [2],
|
||||||
'amount': '13.33',
|
'amount': '13.33',
|
||||||
})
|
})
|
||||||
project = models.Project.query.get('raclette')
|
project = models.Project.query.get('raclette')
|
||||||
transactions = project.get_transactions_to_settle_bill()
|
transactions = project.get_transactions_to_settle_bill()
|
||||||
members = defaultdict(int)
|
members = defaultdict(int)
|
||||||
# There should not be any zero-amount transfer after rounding
|
# There should not be any zero-amount transfer after rounding
|
||||||
|
@ -805,6 +805,7 @@ class BudgetTestCase(TestCase):
|
||||||
|
|
||||||
|
|
||||||
class APITestCase(TestCase):
|
class APITestCase(TestCase):
|
||||||
|
|
||||||
"""Tests the API"""
|
"""Tests the API"""
|
||||||
|
|
||||||
def api_create(self, name, id=None, password=None, contact=None):
|
def api_create(self, name, id=None, password=None, contact=None):
|
||||||
|
@ -833,7 +834,7 @@ class APITestCase(TestCase):
|
||||||
def assertStatus(self, expected, resp, url=""):
|
def assertStatus(self, expected, resp, url=""):
|
||||||
|
|
||||||
return self.assertEqual(expected, resp.status_code,
|
return self.assertEqual(expected, resp.status_code,
|
||||||
"%s expected %s, got %s" % (url, expected, resp.status_code))
|
"%s expected %s, got %s" % (url, expected, resp.status_code))
|
||||||
|
|
||||||
def test_basic_auth(self):
|
def test_basic_auth(self):
|
||||||
# create a project
|
# create a project
|
||||||
|
@ -850,15 +851,15 @@ class APITestCase(TestCase):
|
||||||
for resource in ("/raclette/members", "/raclette/bills"):
|
for resource in ("/raclette/members", "/raclette/bills"):
|
||||||
url = "/api/projects" + resource
|
url = "/api/projects" + resource
|
||||||
self.assertStatus(401, getattr(self.app, verb)(url),
|
self.assertStatus(401, getattr(self.app, verb)(url),
|
||||||
verb + resource)
|
verb + resource)
|
||||||
|
|
||||||
for verb in ('get', 'delete', 'put'):
|
for verb in ('get', 'delete', 'put'):
|
||||||
for resource in ("/raclette", "/raclette/members/1",
|
for resource in ("/raclette", "/raclette/members/1",
|
||||||
"/raclette/bills/1"):
|
"/raclette/bills/1"):
|
||||||
url = "/api/projects" + resource
|
url = "/api/projects" + resource
|
||||||
|
|
||||||
self.assertStatus(401, getattr(self.app, verb)(url),
|
self.assertStatus(401, getattr(self.app, verb)(url),
|
||||||
verb + resource)
|
verb + resource)
|
||||||
|
|
||||||
def test_project(self):
|
def test_project(self):
|
||||||
# wrong email should return an error
|
# wrong email should return an error
|
||||||
|
@ -885,7 +886,7 @@ class APITestCase(TestCase):
|
||||||
|
|
||||||
# get information about it
|
# get information about it
|
||||||
resp = self.app.get("/api/projects/raclette",
|
resp = self.app.get("/api/projects/raclette",
|
||||||
headers=self.get_auth("raclette"))
|
headers=self.get_auth("raclette"))
|
||||||
|
|
||||||
self.assertTrue(200, resp.status_code)
|
self.assertTrue(200, resp.status_code)
|
||||||
expected = {
|
expected = {
|
||||||
|
@ -904,12 +905,12 @@ class APITestCase(TestCase):
|
||||||
"contact_email": "yeah@notmyidea.org",
|
"contact_email": "yeah@notmyidea.org",
|
||||||
"password": "raclette",
|
"password": "raclette",
|
||||||
"name": "The raclette party",
|
"name": "The raclette party",
|
||||||
}, headers=self.get_auth("raclette"))
|
}, headers=self.get_auth("raclette"))
|
||||||
|
|
||||||
self.assertEqual(200, resp.status_code)
|
self.assertEqual(200, resp.status_code)
|
||||||
|
|
||||||
resp = self.app.get("/api/projects/raclette",
|
resp = self.app.get("/api/projects/raclette",
|
||||||
headers=self.get_auth("raclette"))
|
headers=self.get_auth("raclette"))
|
||||||
|
|
||||||
self.assertEqual(200, resp.status_code)
|
self.assertEqual(200, resp.status_code)
|
||||||
expected = {
|
expected = {
|
||||||
|
@ -925,13 +926,13 @@ class APITestCase(TestCase):
|
||||||
|
|
||||||
# delete should work
|
# delete should work
|
||||||
resp = self.app.delete("/api/projects/raclette",
|
resp = self.app.delete("/api/projects/raclette",
|
||||||
headers=self.get_auth("raclette"))
|
headers=self.get_auth("raclette"))
|
||||||
|
|
||||||
self.assertEqual(200, resp.status_code)
|
self.assertEqual(200, resp.status_code)
|
||||||
|
|
||||||
# get should return a 401 on an unknown resource
|
# get should return a 401 on an unknown resource
|
||||||
resp = self.app.get("/api/projects/raclette",
|
resp = self.app.get("/api/projects/raclette",
|
||||||
headers=self.get_auth("raclette"))
|
headers=self.get_auth("raclette"))
|
||||||
self.assertEqual(401, resp.status_code)
|
self.assertEqual(401, resp.status_code)
|
||||||
|
|
||||||
def test_member(self):
|
def test_member(self):
|
||||||
|
@ -940,15 +941,15 @@ class APITestCase(TestCase):
|
||||||
|
|
||||||
# get the list of members (should be empty)
|
# get the list of members (should be empty)
|
||||||
req = self.app.get("/api/projects/raclette/members",
|
req = self.app.get("/api/projects/raclette/members",
|
||||||
headers=self.get_auth("raclette"))
|
headers=self.get_auth("raclette"))
|
||||||
|
|
||||||
self.assertStatus(200, req)
|
self.assertStatus(200, req)
|
||||||
self.assertEqual('[]', req.data.decode('utf-8'))
|
self.assertEqual('[]', req.data.decode('utf-8'))
|
||||||
|
|
||||||
# add a member
|
# add a member
|
||||||
req = self.app.post("/api/projects/raclette/members", data={
|
req = self.app.post("/api/projects/raclette/members", data={
|
||||||
"name": "Alexis"
|
"name": "Alexis"
|
||||||
}, headers=self.get_auth("raclette"))
|
}, headers=self.get_auth("raclette"))
|
||||||
|
|
||||||
# the id of the new member should be returned
|
# the id of the new member should be returned
|
||||||
self.assertStatus(201, req)
|
self.assertStatus(201, req)
|
||||||
|
@ -956,21 +957,21 @@ class APITestCase(TestCase):
|
||||||
|
|
||||||
# the list of members should contain one member
|
# the list of members should contain one member
|
||||||
req = self.app.get("/api/projects/raclette/members",
|
req = self.app.get("/api/projects/raclette/members",
|
||||||
headers=self.get_auth("raclette"))
|
headers=self.get_auth("raclette"))
|
||||||
|
|
||||||
self.assertStatus(200, req)
|
self.assertStatus(200, req)
|
||||||
self.assertEqual(len(json.loads(req.data.decode('utf-8'))), 1)
|
self.assertEqual(len(json.loads(req.data.decode('utf-8'))), 1)
|
||||||
|
|
||||||
# edit this member
|
# edit this member
|
||||||
req = self.app.put("/api/projects/raclette/members/1", data={
|
req = self.app.put("/api/projects/raclette/members/1", data={
|
||||||
"name": "Fred"
|
"name": "Fred"
|
||||||
}, headers=self.get_auth("raclette"))
|
}, headers=self.get_auth("raclette"))
|
||||||
|
|
||||||
self.assertStatus(200, req)
|
self.assertStatus(200, req)
|
||||||
|
|
||||||
# get should return the new name
|
# get should return the new name
|
||||||
req = self.app.get("/api/projects/raclette/members/1",
|
req = self.app.get("/api/projects/raclette/members/1",
|
||||||
headers=self.get_auth("raclette"))
|
headers=self.get_auth("raclette"))
|
||||||
|
|
||||||
self.assertStatus(200, req)
|
self.assertStatus(200, req)
|
||||||
self.assertEqual("Fred", json.loads(req.data.decode('utf-8'))["name"])
|
self.assertEqual("Fred", json.loads(req.data.decode('utf-8'))["name"])
|
||||||
|
@ -978,14 +979,14 @@ class APITestCase(TestCase):
|
||||||
# delete a member
|
# delete a member
|
||||||
|
|
||||||
req = self.app.delete("/api/projects/raclette/members/1",
|
req = self.app.delete("/api/projects/raclette/members/1",
|
||||||
headers=self.get_auth("raclette"))
|
headers=self.get_auth("raclette"))
|
||||||
|
|
||||||
self.assertStatus(200, req)
|
self.assertStatus(200, req)
|
||||||
|
|
||||||
# the list of members should be empty
|
# the list of members should be empty
|
||||||
# get the list of members (should be empty)
|
# get the list of members (should be empty)
|
||||||
req = self.app.get("/api/projects/raclette/members",
|
req = self.app.get("/api/projects/raclette/members",
|
||||||
headers=self.get_auth("raclette"))
|
headers=self.get_auth("raclette"))
|
||||||
|
|
||||||
self.assertStatus(200, req)
|
self.assertStatus(200, req)
|
||||||
self.assertEqual('[]', req.data.decode('utf-8'))
|
self.assertEqual('[]', req.data.decode('utf-8'))
|
||||||
|
@ -1001,7 +1002,7 @@ class APITestCase(TestCase):
|
||||||
|
|
||||||
# get the list of bills (should be empty)
|
# get the list of bills (should be empty)
|
||||||
req = self.app.get("/api/projects/raclette/bills",
|
req = self.app.get("/api/projects/raclette/bills",
|
||||||
headers=self.get_auth("raclette"))
|
headers=self.get_auth("raclette"))
|
||||||
self.assertStatus(200, req)
|
self.assertStatus(200, req)
|
||||||
|
|
||||||
self.assertEqual("[]", req.data.decode('utf-8'))
|
self.assertEqual("[]", req.data.decode('utf-8'))
|
||||||
|
@ -1013,7 +1014,7 @@ class APITestCase(TestCase):
|
||||||
'payer': "1",
|
'payer': "1",
|
||||||
'payed_for': ["1", "2"],
|
'payed_for': ["1", "2"],
|
||||||
'amount': '25',
|
'amount': '25',
|
||||||
}, headers=self.get_auth("raclette"))
|
}, headers=self.get_auth("raclette"))
|
||||||
|
|
||||||
# should return the id
|
# should return the id
|
||||||
self.assertStatus(201, req)
|
self.assertStatus(201, req)
|
||||||
|
@ -1021,7 +1022,7 @@ class APITestCase(TestCase):
|
||||||
|
|
||||||
# get this bill details
|
# get this bill details
|
||||||
req = self.app.get("/api/projects/raclette/bills/1",
|
req = self.app.get("/api/projects/raclette/bills/1",
|
||||||
headers=self.get_auth("raclette"))
|
headers=self.get_auth("raclette"))
|
||||||
|
|
||||||
# compare with the added info
|
# compare with the added info
|
||||||
self.assertStatus(200, req)
|
self.assertStatus(200, req)
|
||||||
|
@ -1039,7 +1040,7 @@ class APITestCase(TestCase):
|
||||||
|
|
||||||
# the list of bills should lenght 1
|
# the list of bills should lenght 1
|
||||||
req = self.app.get("/api/projects/raclette/bills",
|
req = self.app.get("/api/projects/raclette/bills",
|
||||||
headers=self.get_auth("raclette"))
|
headers=self.get_auth("raclette"))
|
||||||
self.assertStatus(200, req)
|
self.assertStatus(200, req)
|
||||||
self.assertEqual(1, len(json.loads(req.data.decode('utf-8'))))
|
self.assertEqual(1, len(json.loads(req.data.decode('utf-8'))))
|
||||||
|
|
||||||
|
@ -1050,7 +1051,7 @@ class APITestCase(TestCase):
|
||||||
'payer': "1",
|
'payer': "1",
|
||||||
'payed_for': ["1", "2"],
|
'payed_for': ["1", "2"],
|
||||||
'amount': '25',
|
'amount': '25',
|
||||||
}, headers=self.get_auth("raclette"))
|
}, headers=self.get_auth("raclette"))
|
||||||
|
|
||||||
self.assertStatus(400, req)
|
self.assertStatus(400, req)
|
||||||
self.assertEqual('{"date": ["This field is required."]}', req.data.decode('utf-8'))
|
self.assertEqual('{"date": ["This field is required."]}', req.data.decode('utf-8'))
|
||||||
|
@ -1062,11 +1063,11 @@ class APITestCase(TestCase):
|
||||||
'payer': "2",
|
'payer': "2",
|
||||||
'payed_for': ["1", "2"],
|
'payed_for': ["1", "2"],
|
||||||
'amount': '25',
|
'amount': '25',
|
||||||
}, headers=self.get_auth("raclette"))
|
}, headers=self.get_auth("raclette"))
|
||||||
|
|
||||||
# check its fields
|
# check its fields
|
||||||
req = self.app.get("/api/projects/raclette/bills/1",
|
req = self.app.get("/api/projects/raclette/bills/1",
|
||||||
headers=self.get_auth("raclette"))
|
headers=self.get_auth("raclette"))
|
||||||
|
|
||||||
expected = {
|
expected = {
|
||||||
"what": "beer",
|
"what": "beer",
|
||||||
|
@ -1082,17 +1083,17 @@ class APITestCase(TestCase):
|
||||||
|
|
||||||
# delete a bill
|
# delete a bill
|
||||||
req = self.app.delete("/api/projects/raclette/bills/1",
|
req = self.app.delete("/api/projects/raclette/bills/1",
|
||||||
headers=self.get_auth("raclette"))
|
headers=self.get_auth("raclette"))
|
||||||
self.assertStatus(200, req)
|
self.assertStatus(200, req)
|
||||||
|
|
||||||
# getting it should return a 404
|
# getting it should return a 404
|
||||||
req = self.app.get("/api/projects/raclette/bills/1",
|
req = self.app.get("/api/projects/raclette/bills/1",
|
||||||
headers=self.get_auth("raclette"))
|
headers=self.get_auth("raclette"))
|
||||||
self.assertStatus(404, req)
|
self.assertStatus(404, req)
|
||||||
|
|
||||||
def test_username_xss(self):
|
def test_username_xss(self):
|
||||||
# create a project
|
# create a project
|
||||||
#self.api_create("raclette")
|
# self.api_create("raclette")
|
||||||
self.post_project("raclette")
|
self.post_project("raclette")
|
||||||
self.login("raclette")
|
self.login("raclette")
|
||||||
|
|
||||||
|
@ -1118,11 +1119,11 @@ class APITestCase(TestCase):
|
||||||
'payer': "1",
|
'payer': "1",
|
||||||
'payed_for': ["1", "2"],
|
'payed_for': ["1", "2"],
|
||||||
'amount': '25',
|
'amount': '25',
|
||||||
}, headers=self.get_auth("raclette"))
|
}, headers=self.get_auth("raclette"))
|
||||||
|
|
||||||
# get this bill details
|
# get this bill details
|
||||||
req = self.app.get("/api/projects/raclette/bills/1",
|
req = self.app.get("/api/projects/raclette/bills/1",
|
||||||
headers=self.get_auth("raclette"))
|
headers=self.get_auth("raclette"))
|
||||||
|
|
||||||
# compare with the added info
|
# compare with the added info
|
||||||
self.assertStatus(200, req)
|
self.assertStatus(200, req)
|
||||||
|
@ -1139,7 +1140,7 @@ class APITestCase(TestCase):
|
||||||
|
|
||||||
# getting it should return a 404
|
# getting it should return a 404
|
||||||
req = self.app.get("/api/projects/raclette",
|
req = self.app.get("/api/projects/raclette",
|
||||||
headers=self.get_auth("raclette"))
|
headers=self.get_auth("raclette"))
|
||||||
|
|
||||||
expected = {
|
expected = {
|
||||||
"active_members": [
|
"active_members": [
|
||||||
|
@ -1162,7 +1163,9 @@ class APITestCase(TestCase):
|
||||||
self.assertStatus(200, req)
|
self.assertStatus(200, req)
|
||||||
self.assertEqual(expected, json.loads(req.data.decode('utf-8')))
|
self.assertEqual(expected, json.loads(req.data.decode('utf-8')))
|
||||||
|
|
||||||
|
|
||||||
class ServerTestCase(APITestCase):
|
class ServerTestCase(APITestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
run.configure()
|
run.configure()
|
||||||
super(ServerTestCase, self).setUp()
|
super(ServerTestCase, self).setUp()
|
||||||
|
|
|
@ -26,7 +26,9 @@ def slugify(value):
|
||||||
value = six.text_type(re.sub('[^\w\s-]', '', value).strip().lower())
|
value = six.text_type(re.sub('[^\w\s-]', '', value).strip().lower())
|
||||||
return re.sub('[-\s]+', '-', value)
|
return re.sub('[-\s]+', '-', value)
|
||||||
|
|
||||||
|
|
||||||
class Redirect303(HTTPException, RoutingException):
|
class Redirect303(HTTPException, RoutingException):
|
||||||
|
|
||||||
"""Raise if the map requests a redirect. This is for example the case if
|
"""Raise if the map requests a redirect. This is for example the case if
|
||||||
`strict_slashes` are activated and an url that requires a trailing slash.
|
`strict_slashes` are activated and an url that requires a trailing slash.
|
||||||
|
|
||||||
|
@ -43,6 +45,7 @@ class Redirect303(HTTPException, RoutingException):
|
||||||
|
|
||||||
|
|
||||||
class PrefixedWSGI(object):
|
class PrefixedWSGI(object):
|
||||||
|
|
||||||
'''
|
'''
|
||||||
Wrap the application in this middleware and configure the
|
Wrap the application in this middleware and configure the
|
||||||
front-end server to add these headers, to let you quietly bind
|
front-end server to add these headers, to let you quietly bind
|
||||||
|
@ -55,6 +58,7 @@ class PrefixedWSGI(object):
|
||||||
|
|
||||||
:param app: the WSGI application
|
:param app: the WSGI application
|
||||||
'''
|
'''
|
||||||
|
|
||||||
def __init__(self, app):
|
def __init__(self, app):
|
||||||
self.app = app
|
self.app = app
|
||||||
self.wsgi_app = app.wsgi_app
|
self.wsgi_app = app.wsgi_app
|
||||||
|
@ -85,12 +89,14 @@ def minimal_round(*args, **kw):
|
||||||
ires = int(res)
|
ires = int(res)
|
||||||
return (res if res != ires else ires)
|
return (res if res != ires else ires)
|
||||||
|
|
||||||
|
|
||||||
def list_of_dicts2json(dict_to_convert):
|
def list_of_dicts2json(dict_to_convert):
|
||||||
"""Take a list of dictionnaries and turns it into
|
"""Take a list of dictionnaries and turns it into
|
||||||
a json in-memory file
|
a json in-memory file
|
||||||
"""
|
"""
|
||||||
return BytesIO(dumps(dict_to_convert).encode('utf-8'))
|
return BytesIO(dumps(dict_to_convert).encode('utf-8'))
|
||||||
|
|
||||||
|
|
||||||
def list_of_dicts2csv(dict_to_convert):
|
def list_of_dicts2csv(dict_to_convert):
|
||||||
"""Take a list of dictionnaries and turns it into
|
"""Take a list of dictionnaries and turns it into
|
||||||
a csv in-memory file, assume all dict have the same keys
|
a csv in-memory file, assume all dict have the same keys
|
||||||
|
@ -110,9 +116,10 @@ def list_of_dicts2csv(dict_to_convert):
|
||||||
csv_data = []
|
csv_data = []
|
||||||
csv_data.append([key.encode('utf-8') for key in dict_to_convert[0].keys()])
|
csv_data.append([key.encode('utf-8') for key in dict_to_convert[0].keys()])
|
||||||
for dic in dict_to_convert:
|
for dic in dict_to_convert:
|
||||||
csv_data.append([dic[h].encode('utf8')
|
csv_data.append(
|
||||||
if isinstance(dic[h], unicode) else str(dic[h]).encode('utf8')
|
[dic[h].encode('utf8')
|
||||||
for h in dict_to_convert[0].keys()])
|
if isinstance(dic[h], unicode) else str(dic[h]).encode('utf8')
|
||||||
|
for h in dict_to_convert[0].keys()])
|
||||||
except (KeyError, IndexError):
|
except (KeyError, IndexError):
|
||||||
csv_data = []
|
csv_data = []
|
||||||
writer = csv.writer(csv_file)
|
writer = csv.writer(csv_file)
|
||||||
|
@ -123,4 +130,4 @@ def list_of_dicts2csv(dict_to_convert):
|
||||||
return csv_file
|
return csv_file
|
||||||
|
|
||||||
# base64 encoding that works with both py2 and py3 and yield no warning
|
# base64 encoding that works with both py2 and py3 and yield no warning
|
||||||
base64_encode = base64.encodestring if six.PY2 else base64.encodebytes
|
base64_encode = base64.encodestring if six.PY2 else base64.encodebytes
|
||||||
|
|
|
@ -15,8 +15,7 @@ from flask import (
|
||||||
)
|
)
|
||||||
from flask_mail import Mail, Message
|
from flask_mail import Mail, Message
|
||||||
from flask_babel import get_locale, gettext as _
|
from flask_babel import get_locale, gettext as _
|
||||||
from werkzeug.security import generate_password_hash, \
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
check_password_hash
|
|
||||||
from smtplib import SMTPRecipientsRefused
|
from smtplib import SMTPRecipientsRefused
|
||||||
import werkzeug
|
import werkzeug
|
||||||
from sqlalchemy import orm
|
from sqlalchemy import orm
|
||||||
|
@ -75,14 +74,14 @@ def pull_project(endpoint, values):
|
||||||
project = Project.query.get(project_id)
|
project = Project.query.get(project_id)
|
||||||
if not project:
|
if not project:
|
||||||
raise Redirect303(url_for(".create_project",
|
raise Redirect303(url_for(".create_project",
|
||||||
project_id=project_id))
|
project_id=project_id))
|
||||||
if project.id in session and session[project.id] == project.password:
|
if project.id in session and session[project.id] == project.password:
|
||||||
# add project into kwargs and call the original function
|
# add project into kwargs and call the original function
|
||||||
g.project = project
|
g.project = project
|
||||||
else:
|
else:
|
||||||
# redirect to authentication page
|
# redirect to authentication page
|
||||||
raise Redirect303(
|
raise Redirect303(
|
||||||
url_for(".authenticate", project_id=project_id))
|
url_for(".authenticate", project_id=project_id))
|
||||||
|
|
||||||
|
|
||||||
@main.route("/admin", methods=["GET", "POST"])
|
@main.route("/admin", methods=["GET", "POST"])
|
||||||
|
@ -110,7 +109,7 @@ def authenticate(project_id=None):
|
||||||
form.id.data = request.args['project_id']
|
form.id.data = request.args['project_id']
|
||||||
project_id = form.id.data
|
project_id = form.id.data
|
||||||
if project_id is None:
|
if project_id is None:
|
||||||
#User doesn't provide project identifier, return to authenticate form
|
# User doesn't provide project identifier, return to authenticate form
|
||||||
msg = _("You need to enter a project identifier")
|
msg = _("You need to enter a project identifier")
|
||||||
form.errors["id"] = [msg]
|
form.errors["id"] = [msg]
|
||||||
return render_template("authenticate.html", form=form)
|
return render_template("authenticate.html", form=form)
|
||||||
|
@ -150,7 +149,7 @@ def authenticate(project_id=None):
|
||||||
return redirect(url_for(".list_bills"))
|
return redirect(url_for(".list_bills"))
|
||||||
|
|
||||||
return render_template("authenticate.html", form=form,
|
return render_template("authenticate.html", form=form,
|
||||||
create_project=create_project)
|
create_project=create_project)
|
||||||
|
|
||||||
|
|
||||||
@main.route("/")
|
@main.route("/")
|
||||||
|
@ -196,14 +195,14 @@ def create_project():
|
||||||
g.project = project
|
g.project = project
|
||||||
|
|
||||||
message_title = _("You have just created '%(project)s' "
|
message_title = _("You have just created '%(project)s' "
|
||||||
"to share your expenses", project=g.project.name)
|
"to share your expenses", project=g.project.name)
|
||||||
|
|
||||||
message_body = render_template("reminder_mail.%s" %
|
message_body = render_template("reminder_mail.%s" %
|
||||||
get_locale().language)
|
get_locale().language)
|
||||||
|
|
||||||
msg = Message(message_title,
|
msg = Message(message_title,
|
||||||
body=message_body,
|
body=message_body,
|
||||||
recipients=[project.contact_email])
|
recipients=[project.contact_email])
|
||||||
try:
|
try:
|
||||||
mail.send(msg)
|
mail.send(msg)
|
||||||
except SMTPRecipientsRefused:
|
except SMTPRecipientsRefused:
|
||||||
|
@ -214,7 +213,7 @@ def create_project():
|
||||||
|
|
||||||
# redirect the user to the next step (invite)
|
# redirect the user to the next step (invite)
|
||||||
flash(_("%(msg_compl)sThe project identifier is %(project)s",
|
flash(_("%(msg_compl)sThe project identifier is %(project)s",
|
||||||
msg_compl=msg_compl, project=project.id))
|
msg_compl=msg_compl, project=project.id))
|
||||||
return redirect(url_for(".invite", project_id=project.id))
|
return redirect(url_for(".invite", project_id=project.id))
|
||||||
|
|
||||||
return render_template("create_project.html", form=form)
|
return render_template("create_project.html", form=form)
|
||||||
|
@ -231,8 +230,8 @@ def remind_password():
|
||||||
# send the password reminder
|
# send the password reminder
|
||||||
password_reminder = "password_reminder.%s" % get_locale().language
|
password_reminder = "password_reminder.%s" % get_locale().language
|
||||||
mail.send(Message("password recovery",
|
mail.send(Message("password recovery",
|
||||||
body=render_template(password_reminder, project=project),
|
body=render_template(password_reminder, project=project),
|
||||||
recipients=[project.contact_email]))
|
recipients=[project.contact_email]))
|
||||||
flash(_("a mail has been sent to you with the password"))
|
flash(_("a mail has been sent to you with the password"))
|
||||||
|
|
||||||
return render_template("password_reminder.html", form=form)
|
return render_template("password_reminder.html", form=form)
|
||||||
|
@ -270,7 +269,7 @@ def edit_project():
|
||||||
attachment_filename="%s-%s.%s" %
|
attachment_filename="%s-%s.%s" %
|
||||||
(g.project.id, export_type, export_format),
|
(g.project.id, export_type, export_format),
|
||||||
as_attachment=True
|
as_attachment=True
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
edit_form.name.data = g.project.name
|
edit_form.name.data = g.project.name
|
||||||
edit_form.password.data = g.project.password
|
edit_form.password.data = g.project.password
|
||||||
|
@ -311,7 +310,7 @@ def demo():
|
||||||
project_id='demo'))
|
project_id='demo'))
|
||||||
if not project and is_demo_project_activated:
|
if not project and is_demo_project_activated:
|
||||||
project = Project(id="demo", name=u"demonstration", password="demo",
|
project = Project(id="demo", name=u"demonstration", password="demo",
|
||||||
contact_email="demo@notmyidea.org")
|
contact_email="demo@notmyidea.org")
|
||||||
db.session.add(project)
|
db.session.add(project)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
session[project.id] = project.password
|
session[project.id] = project.password
|
||||||
|
@ -329,14 +328,14 @@ def invite():
|
||||||
# send the email
|
# send the email
|
||||||
|
|
||||||
message_body = render_template("invitation_mail.%s" %
|
message_body = render_template("invitation_mail.%s" %
|
||||||
get_locale().language)
|
get_locale().language)
|
||||||
|
|
||||||
message_title = _("You have been invited to share your "
|
message_title = _("You have been invited to share your "
|
||||||
"expenses for %(project)s", project=g.project.name)
|
"expenses for %(project)s", project=g.project.name)
|
||||||
msg = Message(message_title,
|
msg = Message(message_title,
|
||||||
body=message_body,
|
body=message_body,
|
||||||
recipients=[email.strip()
|
recipients=[email.strip()
|
||||||
for email in form.emails.data.split(",")])
|
for email in form.emails.data.split(",")])
|
||||||
mail.send(msg)
|
mail.send(msg)
|
||||||
flash(_("Your invitations have been sent"))
|
flash(_("Your invitations have been sent"))
|
||||||
return redirect(url_for(".list_bills"))
|
return redirect(url_for(".list_bills"))
|
||||||
|
@ -354,11 +353,11 @@ def list_bills():
|
||||||
bills = g.project.get_bills().options(orm.subqueryload(Bill.owers))
|
bills = g.project.get_bills().options(orm.subqueryload(Bill.owers))
|
||||||
|
|
||||||
return render_template("list_bills.html",
|
return render_template("list_bills.html",
|
||||||
bills=bills, member_form=MemberForm(g.project),
|
bills=bills, member_form=MemberForm(g.project),
|
||||||
bill_form=bill_form,
|
bill_form=bill_form,
|
||||||
add_bill=request.values.get('add_bill', False),
|
add_bill=request.values.get('add_bill', False),
|
||||||
current_view="list_bills",
|
current_view="list_bills",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@main.route("/<project_id>/members/add", methods=["GET", "POST"])
|
@main.route("/<project_id>/members/add", methods=["GET", "POST"])
|
||||||
|
@ -378,7 +377,7 @@ def add_member():
|
||||||
@main.route("/<project_id>/members/<member_id>/reactivate", methods=["POST"])
|
@main.route("/<project_id>/members/<member_id>/reactivate", methods=["POST"])
|
||||||
def reactivate(member_id):
|
def reactivate(member_id):
|
||||||
person = Person.query.filter(Person.id == member_id)\
|
person = Person.query.filter(Person.id == member_id)\
|
||||||
.filter(Project.id == g.project.id).all()
|
.filter(Project.id == g.project.id).all()
|
||||||
if person:
|
if person:
|
||||||
person[0].activated = True
|
person[0].activated = True
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
|
@ -76,7 +76,8 @@ properly.
|
||||||
| Setting name | Default | What does it do? |
|
| Setting name | Default | What does it do? |
|
||||||
+============================+===========================+========================================================================================+
|
+============================+===========================+========================================================================================+
|
||||||
| SQLALCHEMY_DATABASE_URI | ``sqlite:///budget.db`` | Specifies the type of backend to use and its location. More information |
|
| SQLALCHEMY_DATABASE_URI | ``sqlite:///budget.db`` | Specifies the type of backend to use and its location. More information |
|
||||||
| | | on the format used can be found on `the SQLAlchemy documentation`. |
|
| | | on the format used can be found on `the SQLAlchemy documentation |
|
||||||
|
| | | <http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls>`_. |
|
||||||
+----------------------------+---------------------------+----------------------------------------------------------------------------------------+
|
+----------------------------+---------------------------+----------------------------------------------------------------------------------------+
|
||||||
| SECRET_KEY | ``tralala`` | The secret key used to encrypt the cookies. **This needs to be changed**. |
|
| SECRET_KEY | ``tralala`` | The secret key used to encrypt the cookies. **This needs to be changed**. |
|
||||||
+----------------------------+---------------------------+----------------------------------------------------------------------------------------+
|
+----------------------------+---------------------------+----------------------------------------------------------------------------------------+
|
||||||
|
@ -90,8 +91,6 @@ properly.
|
||||||
| | | and copy its output into the value of *ADMIN_PASSWORD*. |
|
| | | and copy its output into the value of *ADMIN_PASSWORD*. |
|
||||||
+----------------------------+---------------------------+----------------------------------------------------------------------------------------+
|
+----------------------------+---------------------------+----------------------------------------------------------------------------------------+
|
||||||
|
|
||||||
.. _`the SQLAlechemy documentation`: http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls
|
|
||||||
|
|
||||||
In a production environment
|
In a production environment
|
||||||
---------------------------
|
---------------------------
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue