diff options
| author | Alexis Metaireau <alexis@notmyidea.org> | 2017-07-07 00:06:56 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2017-07-07 00:06:56 +0200 |
| commit | 3a4282fd75e3b3317b2b08b4aa2e6ac154310e73 (patch) | |
| tree | 9470c907ba1f884246af87d26d55c3aaac6d6dc5 /ihatemoney/models.py | |
| parent | 0e374cd5e0ef5a9be67084365f91de2ab84f636c (diff) | |
| download | ihatemoney-mirror-3a4282fd75e3b3317b2b08b4aa2e6ac154310e73.zip ihatemoney-mirror-3a4282fd75e3b3317b2b08b4aa2e6ac154310e73.tar.gz ihatemoney-mirror-3a4282fd75e3b3317b2b08b4aa2e6ac154310e73.tar.bz2 | |
Absolute imports & some other improvements (#243)
* Use absolute imports and rename package to ihatemoney
* Add a ihatemoney command
* Factorize application creation logic
* Refactor the tests
* Update the wsgi.py module with the new create_app() function
* Fix some styling thanks to Flake8.
* Automate Flake8 check in the CI.
Diffstat (limited to 'ihatemoney/models.py')
| -rw-r--r-- | ihatemoney/models.py | 308 |
1 files changed, 308 insertions, 0 deletions
diff --git a/ihatemoney/models.py b/ihatemoney/models.py new file mode 100644 index 0000000..6c71a57 --- /dev/null +++ b/ihatemoney/models.py @@ -0,0 +1,308 @@ +from collections import defaultdict + +from datetime import datetime +from flask_sqlalchemy import SQLAlchemy, BaseQuery +from flask import g + +from sqlalchemy import orm + +db = SQLAlchemy() + + +class Project(db.Model): + + _to_serialize = ( + "id", "name", "password", "contact_email", "members", "active_members", + "balance" + ) + + id = db.Column(db.String(64), primary_key=True) + + name = db.Column(db.UnicodeText) + password = db.Column(db.String(128)) + contact_email = db.Column(db.String(128)) + members = db.relationship("Person", backref="project") + + @property + def active_members(self): + return [m for m in self.members if m.activated] + + @property + def balance(self): + + balances, should_pay, should_receive = (defaultdict(int) + for time in (1, 2, 3)) + + # for each person + for person in self.members: + # get the list of bills he has to pay + bills = Bill.query.options(orm.subqueryload(Bill.owers)).filter( + Bill.owers.contains(person)) + for bill in bills.all(): + if person != bill.payer: + share = bill.pay_each() * person.weight + should_pay[person] += share + should_receive[bill.payer] += share + + for person in self.members: + balance = should_receive[person] - should_pay[person] + balances[person.id] = balance + + return balances + + @property + def uses_weights(self): + return len([i for i in self.members if i.weight != 1]) > 0 + + def get_transactions_to_settle_bill(self, pretty_output=False): + """Return a list of transactions that could be made to settle the bill""" + + def prettify(transactions, pretty_output): + """ Return pretty transactions + """ + if not pretty_output: + return transactions + pretty_transactions = [] + for transaction in transactions: + pretty_transactions.append({ + 'ower': transaction['ower'].name, + 'receiver': transaction['receiver'].name, + 'amount': round(transaction['amount'], 2) + }) + return pretty_transactions + + # cache value for better performance + balance = self.balance + credits, debts, transactions = [], [], [] + # Create lists of credits and debts + for person in self.members: + if round(balance[person.id], 2) > 0: + credits.append({"person": person, "balance": balance[person.id]}) + elif round(balance[person.id], 2) < 0: + debts.append({"person": person, "balance": -balance[person.id]}) + + # Try and find exact matches + for credit in credits: + match = self.exactmatch(round(credit["balance"], 2), debts) + if match: + for m in match: + transactions.append({ + "ower": m["person"], + "receiver": credit["person"], + "amount": m["balance"] + }) + debts.remove(m) + credits.remove(credit) + # Split any remaining debts & credits + while credits and debts: + + if credits[0]["balance"] > 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"] + del debts[0] + else: + transactions.append({ + "ower": debts[0]["person"], + "receiver": credits[0]["person"], + "amount": credits[0]["balance"] + }) + debts[0]["balance"] = debts[0]["balance"] - credits[0]["balance"] + del credits[0] + + return prettify(transactions, pretty_output) + + def exactmatch(self, credit, debts): + """Recursively try and find subsets of 'debts' whose sum is equal to credit""" + if not debts: + return None + 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 + + def has_bills(self): + """return if the project do have bills or not""" + return self.get_bills().count() > 0 + + 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)\ + .order_by(Bill.date.desc())\ + .order_by(Bill.id.desc()) + + def get_pretty_bills(self, export_format="json"): + """Return a list of project's bills with pretty formatting""" + bills = self.get_bills() + pretty_bills = [] + for bill in bills: + if export_format == "json": + owers = [ower.name for ower in bill.owers] + else: + owers = ', '.join([ower.name for ower in bill.owers]) + + pretty_bills.append({ + "what": bill.what, + "amount": round(bill.amount, 2), + "date": str(bill.date), + "payer_name": Person.query.get(bill.payer_id).name, + "payer_weight": Person.query.get(bill.payer_id).weight, + "owers": owers + }) + return pretty_bills + + 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. + """ + try: + person = Person.query.get(member_id, self) + except orm.exc.NoResultFound: + return None + if not person.has_bills(): + db.session.delete(person) + db.session.commit() + else: + person.activated = False + db.session.commit() + return person + + def remove_project(self): + db.session.delete(self) + db.session.commit() + + def __repr__(self): + return "<Project %s>" % self.name + + +class Person(db.Model): + + 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 + + _to_serialize = ("id", "name", "weight", "activated") + + id = db.Column(db.Integer, primary_key=True) + project_id = db.Column(db.String(64), db.ForeignKey("project.id")) + bills = db.relationship("Bill", backref="payer") + + name = db.Column(db.UnicodeText) + weight = db.Column(db.Float, default=1) + activated = db.Column(db.Boolean, default=True) + + def has_bills(self): + """return if the user do have bills or not""" + bills_as_ower_number = db.session.query(billowers)\ + .filter(billowers.columns.get("person_id") == self.id)\ + .count() + return bills_as_ower_number != 0 or len(self.bills) != 0 + + def __str__(self): + return self.name + + 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')), +) + + +class Bill(db.Model): + + class BillQuery(BaseQuery): + + 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") + + 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) + + archive = db.Column(db.Integer, db.ForeignKey("archive.id")) + + def pay_each(self): + """Compute what each share has to pay""" + if self.owers: + # FIXME: SQL might dot that more efficiently + return self.amount / sum(i.weight for i in self.owers) + else: + return 0 + + def __repr__(self): + return "<Bill of %s from %s for %s>" % ( + self.amount, + self.payer, ", ".join([o.name for o in self.owers]) + ) + + +class Archive(db.Model): + id = db.Column(db.Integer, primary_key=True) + project_id = db.Column(db.String(64), db.ForeignKey("project.id")) + name = db.Column(db.UnicodeText) + + @property + def start_date(self): + pass + + @property + def end_date(self): + pass + + def __repr__(self): + return "<Archive>" |
