2011-07-31 23:55:18 +02:00
|
|
|
from collections import defaultdict
|
|
|
|
|
2011-07-23 18:45:40 +02:00
|
|
|
from datetime import datetime
|
2017-01-21 15:03:58 +01:00
|
|
|
from flask_sqlalchemy import SQLAlchemy, BaseQuery
|
2011-09-13 22:58:53 +02:00
|
|
|
from flask import g
|
2011-07-23 18:45:40 +02:00
|
|
|
|
2011-09-13 18:15:07 +02:00
|
|
|
from sqlalchemy import orm
|
|
|
|
|
2011-07-23 18:45:40 +02:00
|
|
|
db = SQLAlchemy()
|
|
|
|
|
2012-03-06 20:37:32 +01:00
|
|
|
|
2011-07-23 18:45:40 +02:00
|
|
|
# define models
|
2012-03-06 20:37:32 +01:00
|
|
|
|
|
|
|
|
2011-07-23 18:45:40 +02:00
|
|
|
class Project(db.Model):
|
2011-09-13 11:27:36 +02:00
|
|
|
|
2012-03-06 20:37:32 +01:00
|
|
|
_to_serialize = ("id", "name", "password", "contact_email",
|
2011-10-18 23:26:13 +02:00
|
|
|
"members", "active_members", "balance")
|
2011-09-13 11:27:36 +02:00
|
|
|
|
2015-05-01 18:28:40 +02:00
|
|
|
id = db.Column(db.String(64), primary_key=True)
|
2011-07-23 18:45:40 +02:00
|
|
|
|
|
|
|
name = db.Column(db.UnicodeText)
|
2015-05-01 18:28:40 +02:00
|
|
|
password = db.Column(db.String(128))
|
|
|
|
contact_email = db.Column(db.String(128))
|
2011-07-23 18:45:40 +02:00
|
|
|
members = db.relationship("Person", backref="project")
|
|
|
|
|
2011-07-31 23:55:18 +02:00
|
|
|
@property
|
|
|
|
def active_members(self):
|
|
|
|
return [m for m in self.members if m.activated]
|
|
|
|
|
2011-10-18 23:26:13 +02:00
|
|
|
@property
|
|
|
|
def balance(self):
|
2011-07-31 23:55:18 +02:00
|
|
|
|
2012-03-06 20:37:32 +01:00
|
|
|
balances, should_pay, should_receive = (defaultdict(int)
|
|
|
|
for time in (1, 2, 3))
|
2011-07-31 23:55:18 +02:00
|
|
|
|
|
|
|
# for each person
|
|
|
|
for person in self.members:
|
|
|
|
# get the list of bills he has to pay
|
Optimise SQL queries when computing balance
This avoids creating thousands of small SQL queries when computing the
balance of users. This significantly improves the performance of
displaying the main page of a project, since the balance of users is
displayed there:
Before this commit: 4004 SQL queries, 19793 ms elapsed time, 19753 ms CPU time, 2094 ms SQL time
After this commit: 12 SQL queries, 3688 ms elapsed time, 3753 ms CPU time, 50 ms SQL time
Measured request: display the sidebar with the balance of all users for the project (without displaying the list of bills)
This commit also greatly improves the performance of the "settle bills" page:
Before this commit: 8006 SQL queries, 39167 ms elapsed time, 39600 ms CPU time, 4141 ms SQL time
After this commit: 22 SQL queries, 7144 ms elapsed time, 7283 ms CPU time, 96 ms SQL time
Measured request: display the "Settle bills" page
Test setup to measure performance improvement:
- 5 users with various weights
- 1000 bills, each paid by a random user, each involving all 5 users
- laptop with Celeron N2830@2.16 GHz, SSD Samsung 850 EVO
- sqlite database on SSD, using sqlite 3.15.2
- python 2.7.13
- Flask-DebugToolbar 0.10.0 (to count SQL queries and loading time)
Performance measurements (using Flask-DebugToolbar on the second request,
to avoid measuring cold-cache performance):
- number of SQL queries
- elapsed time (from request to response)
- total CPU time consumed by the server handling the request
- total time spent on SQL queries (as reported by SQLAlchemy)
2017-01-01 22:32:32 +01:00
|
|
|
bills = Bill.query.options(orm.subqueryload(Bill.owers)).filter(Bill.owers.contains(person))
|
2011-07-31 23:55:18 +02:00
|
|
|
for bill in bills.all():
|
2012-03-06 20:37:32 +01:00
|
|
|
if person != bill.payer:
|
2015-08-20 10:33:43 +02:00
|
|
|
share = bill.pay_each() * person.weight
|
|
|
|
should_pay[person] += share
|
|
|
|
should_receive[bill.payer] += share
|
2011-07-31 23:55:18 +02:00
|
|
|
|
|
|
|
for person in self.members:
|
2012-03-06 20:37:32 +01:00
|
|
|
balance = should_receive[person] - should_pay[person]
|
2014-07-22 20:19:35 +02:00
|
|
|
balances[person.id] = balance
|
2011-07-31 23:55:18 +02:00
|
|
|
|
|
|
|
return balances
|
|
|
|
|
2015-08-23 11:58:27 +02:00
|
|
|
@property
|
|
|
|
def uses_weights(self):
|
|
|
|
return len([i for i in self.members if i.weight != 1]) > 0
|
|
|
|
|
2013-04-07 22:14:32 +02:00
|
|
|
def get_transactions_to_settle_bill(self):
|
2012-03-12 00:35:06 +01:00
|
|
|
"""Return a list of transactions that could be made to settle the bill"""
|
2013-05-09 23:23:23 +02:00
|
|
|
#cache value for better performance
|
|
|
|
balance = self.balance
|
2013-04-07 20:25:25 +02:00
|
|
|
credits, debts, transactions = [],[],[]
|
2012-03-12 00:35:06 +01:00
|
|
|
# Create lists of credits and debts
|
2013-04-07 20:25:25 +02:00
|
|
|
for person in self.members:
|
2017-01-02 14:52:59 +01:00
|
|
|
if round(balance[person.id], 2) > 0:
|
2013-05-09 23:23:23 +02:00
|
|
|
credits.append({"person": person, "balance": balance[person.id]})
|
2017-01-02 14:52:59 +01:00
|
|
|
elif round(balance[person.id], 2) < 0:
|
2013-05-09 23:23:23 +02:00
|
|
|
debts.append({"person": person, "balance": -balance[person.id]})
|
2012-03-12 00:35:06 +01:00
|
|
|
# Try and find exact matches
|
|
|
|
for credit in credits:
|
2015-07-05 22:16:38 +02:00
|
|
|
match = self.exactmatch(round(credit["balance"], 2), debts)
|
2012-03-12 00:35:06 +01:00
|
|
|
if match:
|
|
|
|
for m in match:
|
2013-04-07 22:14:32 +02:00
|
|
|
transactions.append({"ower": m["person"], "receiver": credit["person"], "amount": m["balance"]})
|
2012-03-12 00:35:06 +01:00
|
|
|
debts.remove(m)
|
|
|
|
credits.remove(credit)
|
|
|
|
# Split any remaining debts & credits
|
|
|
|
while credits and debts:
|
|
|
|
if credits[0]["balance"] > debts[0]["balance"]:
|
2013-04-07 22:14:32 +02:00
|
|
|
transactions.append({"ower": debts[0]["person"], "receiver": credits[0]["person"], "amount": debts[0]["balance"]})
|
2012-03-12 00:35:06 +01:00
|
|
|
credits[0]["balance"] = credits[0]["balance"] - debts[0]["balance"]
|
|
|
|
del debts[0]
|
|
|
|
else:
|
2013-04-07 22:14:32 +02:00
|
|
|
transactions.append({"ower": debts[0]["person"], "receiver": credits[0]["person"], "amount": credits[0]["balance"]})
|
2012-03-12 00:35:06 +01:00
|
|
|
debts[0]["balance"] = debts[0]["balance"] - credits[0]["balance"]
|
|
|
|
del credits[0]
|
|
|
|
return transactions
|
|
|
|
|
|
|
|
def exactmatch(self, credit, debts):
|
|
|
|
"""Recursively try and find subsets of 'debts' whose sum is equal to credit"""
|
|
|
|
if not debts:
|
2013-04-07 20:25:25 +02:00
|
|
|
return None
|
2012-03-12 00:35:06 +01:00
|
|
|
if debts[0]["balance"] > credit:
|
|
|
|
return self.exactmatch(credit, debts[1:])
|
|
|
|
elif debts[0]["balance"] == credit:
|
|
|
|
return [debts[0]]
|
|
|
|
else:
|
|
|
|
match = self.exactmatch(credit-debts[0]["balance"], debts[1:])
|
|
|
|
if match:
|
|
|
|
match.append(debts[0])
|
|
|
|
else:
|
|
|
|
match = self.exactmatch(credit, debts[1:])
|
|
|
|
return match
|
|
|
|
|
2011-12-03 22:25:19 +01:00
|
|
|
def has_bills(self):
|
|
|
|
"""return if the project do have bills or not"""
|
2012-03-06 20:37:32 +01:00
|
|
|
return self.get_bills().count() > 0
|
2011-12-03 22:25:19 +01:00
|
|
|
|
2011-09-09 19:57:28 +02:00
|
|
|
def get_bills(self):
|
|
|
|
"""Return the list of bills related to this project"""
|
|
|
|
return Bill.query.join(Person, Project)\
|
|
|
|
.filter(Bill.payer_id == Person.id)\
|
|
|
|
.filter(Person.project_id == Project.id)\
|
|
|
|
.filter(Project.id == self.id)\
|
2017-01-02 13:22:28 +01:00
|
|
|
.order_by(Bill.date.desc())\
|
|
|
|
.order_by(Bill.id.desc())
|
2011-09-09 19:57:28 +02:00
|
|
|
|
|
|
|
def remove_member(self, member_id):
|
|
|
|
"""Remove a member from the project.
|
|
|
|
|
|
|
|
If the member is not bound to a bill, then he is deleted, otherwise
|
|
|
|
he is only deactivated.
|
|
|
|
|
|
|
|
This method returns the status DELETED or DEACTIVATED regarding the
|
|
|
|
changes made.
|
|
|
|
"""
|
2013-04-08 11:29:31 +02:00
|
|
|
try:
|
|
|
|
person = Person.query.get(member_id, self)
|
|
|
|
except orm.exc.NoResultFound:
|
|
|
|
return None
|
2011-10-08 13:22:18 +02:00
|
|
|
if not person.has_bills():
|
|
|
|
db.session.delete(person)
|
|
|
|
db.session.commit()
|
|
|
|
else:
|
|
|
|
person.activated = False
|
|
|
|
db.session.commit()
|
2011-09-09 19:57:28 +02:00
|
|
|
return person
|
|
|
|
|
2011-11-02 12:16:01 +01:00
|
|
|
def remove_project(self):
|
|
|
|
db.session.delete(self)
|
|
|
|
db.session.commit()
|
|
|
|
|
2011-07-23 18:45:40 +02:00
|
|
|
def __repr__(self):
|
|
|
|
return "<Project %s>" % self.name
|
|
|
|
|
|
|
|
|
|
|
|
class Person(db.Model):
|
2011-09-13 11:27:36 +02:00
|
|
|
|
2011-09-13 22:58:53 +02:00
|
|
|
class PersonQuery(BaseQuery):
|
|
|
|
def get_by_name(self, name, project):
|
|
|
|
return Person.query.filter(Person.name == name)\
|
|
|
|
.filter(Project.id == project.id).one()
|
|
|
|
|
|
|
|
def get(self, id, project=None):
|
|
|
|
if not project:
|
|
|
|
project = g.project
|
|
|
|
return Person.query.filter(Person.id == id)\
|
|
|
|
.filter(Project.id == project.id).one()
|
|
|
|
|
|
|
|
query_class = PersonQuery
|
|
|
|
|
2015-08-20 11:11:09 +02:00
|
|
|
_to_serialize = ("id", "name", "weight", "activated")
|
2011-09-13 11:27:36 +02:00
|
|
|
|
2011-07-23 18:45:40 +02:00
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
2015-05-01 18:28:40 +02:00
|
|
|
project_id = db.Column(db.String(64), db.ForeignKey("project.id"))
|
2011-07-23 18:45:40 +02:00
|
|
|
bills = db.relationship("Bill", backref="payer")
|
|
|
|
|
|
|
|
name = db.Column(db.UnicodeText)
|
2015-08-20 10:33:43 +02:00
|
|
|
weight = db.Column(db.Float, default=1)
|
2011-07-31 23:55:18 +02:00
|
|
|
activated = db.Column(db.Boolean, default=True)
|
2011-07-23 18:45:40 +02:00
|
|
|
|
2011-08-10 17:49:35 +02:00
|
|
|
def has_bills(self):
|
2011-10-18 17:45:24 +02:00
|
|
|
"""return if the user do have bills or not"""
|
|
|
|
bills_as_ower_number = db.session.query(billowers)\
|
2012-01-28 12:40:10 +01:00
|
|
|
.filter(billowers.columns.get("person_id") == self.id)\
|
2011-08-09 19:34:46 +02:00
|
|
|
.count()
|
|
|
|
return bills_as_ower_number != 0 or len(self.bills) != 0
|
|
|
|
|
2011-07-31 00:41:28 +02:00
|
|
|
def __str__(self):
|
|
|
|
return self.name
|
|
|
|
|
2011-07-23 18:45:40 +02:00
|
|
|
def __repr__(self):
|
|
|
|
return "<Person %s for project %s>" % (self.name, self.project.name)
|
|
|
|
|
|
|
|
# We need to manually define a join table for m2m relations
|
|
|
|
billowers = db.Table('billowers',
|
|
|
|
db.Column('bill_id', db.Integer, db.ForeignKey('bill.id')),
|
|
|
|
db.Column('person_id', db.Integer, db.ForeignKey('person.id')),
|
|
|
|
)
|
|
|
|
|
2012-03-06 20:37:32 +01:00
|
|
|
|
2011-07-23 18:45:40 +02:00
|
|
|
class Bill(db.Model):
|
2011-09-13 18:15:07 +02:00
|
|
|
|
2011-09-13 22:58:53 +02:00
|
|
|
class BillQuery(BaseQuery):
|
2011-09-13 18:15:07 +02:00
|
|
|
|
|
|
|
def get(self, project, id):
|
|
|
|
try:
|
|
|
|
return self.join(Person, Project)\
|
|
|
|
.filter(Bill.payer_id == Person.id)\
|
|
|
|
.filter(Person.project_id == Project.id)\
|
|
|
|
.filter(Project.id == project.id)\
|
|
|
|
.filter(Bill.id == id).one()
|
|
|
|
except orm.exc.NoResultFound:
|
|
|
|
return None
|
|
|
|
|
|
|
|
def delete(self, project, id):
|
|
|
|
bill = self.get(project, id)
|
|
|
|
if bill:
|
|
|
|
db.session.delete(bill)
|
|
|
|
return bill
|
|
|
|
|
|
|
|
query_class = BillQuery
|
|
|
|
|
|
|
|
_to_serialize = ("id", "payer_id", "owers", "amount", "date", "what")
|
|
|
|
|
2011-07-23 18:45:40 +02:00
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
|
|
|
|
|
|
payer_id = db.Column(db.Integer, db.ForeignKey("person.id"))
|
|
|
|
owers = db.relationship(Person, secondary=billowers)
|
|
|
|
|
|
|
|
amount = db.Column(db.Float)
|
|
|
|
date = db.Column(db.Date, default=datetime.now)
|
|
|
|
what = db.Column(db.UnicodeText)
|
|
|
|
|
2011-09-09 19:14:19 +02:00
|
|
|
archive = db.Column(db.Integer, db.ForeignKey("archive.id"))
|
|
|
|
|
2011-07-23 18:45:40 +02:00
|
|
|
def pay_each(self):
|
2015-08-20 11:11:09 +02:00
|
|
|
"""Compute what each share has to pay"""
|
2013-10-12 17:28:15 +02:00
|
|
|
if self.owers:
|
2015-08-20 10:33:43 +02:00
|
|
|
# FIXME: SQL might dot that more efficiently
|
|
|
|
return self.amount / sum(i.weight for i in self.owers)
|
2013-10-12 17:28:15 +02:00
|
|
|
else:
|
|
|
|
return 0
|
2011-07-23 18:45:40 +02:00
|
|
|
|
|
|
|
def __repr__(self):
|
|
|
|
return "<Bill of %s from %s for %s>" % (self.amount,
|
|
|
|
self.payer, ", ".join([o.name for o in self.owers]))
|
|
|
|
|
2012-03-06 20:37:32 +01:00
|
|
|
|
2011-09-09 19:14:19 +02:00
|
|
|
class Archive(db.Model):
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
2015-05-01 18:28:40 +02:00
|
|
|
project_id = db.Column(db.String(64), db.ForeignKey("project.id"))
|
2011-09-09 19:14:19 +02:00
|
|
|
name = db.Column(db.UnicodeText)
|
|
|
|
|
|
|
|
@property
|
|
|
|
def start_date(self):
|
|
|
|
pass
|
|
|
|
|
|
|
|
@property
|
|
|
|
def end_date(self):
|
|
|
|
pass
|
|
|
|
|
|
|
|
def __repr__(self):
|
|
|
|
return "<Archive>"
|