diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..09904a317 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +expenses +*.pyc +*.db +.DS_Store \ No newline at end of file diff --git a/README.markdown b/README.markdown index f93d526ae..8511b65c5 100644 --- a/README.markdown +++ b/README.markdown @@ -1,3 +1,40 @@ +# Setup + +### Prerequisites + + 1. Python 2.7 + 2. Virtualenv + +### Run the demo + + 0. clone the repository and cd into it + 1. `virtualenv expenses` + 2. `source expenses/bin/activate` + 3. `pip install -r requirements.txt` + 4. `python setup.py` + 5. `python run.py` + 6. from another terminal window run + * `source expenses/bin/activate` + * `python client/expenses.py --upload ./data_examples.csv` to upload and view a summary of the expenses + * `python client/expenses.py` to only view the summary + +### Comments + +The project **lacks a web interface**, and needs to be interacted with through the terminal. I apologize for the incompleteness and lack of tests, but please do note that this solution was build 1/3 on a plane, 1/3 in a car and 1/3 in a room full of christmas guests. + +What I'm particularily proud of in my implementation: when I started working on this, I had grand ideas, which sadly didn't materialize. Now, although my pride is being overshadowed by a sense of forfeit, I can say that I'm satisfied with the derived database schema, which allows, in combination with a nice ORM, to generate complex queries while maintaining a decent level of extensibility. This might sound silly but I'm also satisfied with the folder structure for the project and the concerns separation into files, as it's intuitive and clean. + +### Implementation notes + + 1. **Models**: under the `models` folder you will find some Alchemy models representing the data in the csv in such a way to be easily extended in the future. In particular note that `categories`, `employees` and `taxes` have their own table. + + 2. **Seeding / Uploading**: the .csv is lacking unique ids, thus when creating records for categories, employees and taxes, I had to assume the uniqueness of some of its data, which isn't necessarily true. + + 3. **Testing**: Initially I wanted to use `pytest` to do some basic testing on the csv parsing and model generation results, and the server response, but I did not have enough time available. + + 4. **Templates**: I started building a web interface, but ultimately resorted to a vintage terminal-based solution due to the little time available, but I hear that's all the rage anyway. + + # Wave Software Development Challenge Applicants for the [Software developer](https://wave.bamboohr.co.uk/jobs/view.php?id=1) role at Wave must complete the following challenge, and submit a solution prior to the onsite interview. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 000000000..fbaced3b7 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,9 @@ +# -*- encoding: utf-8 -*- +from flask import Flask +from flask_sqlalchemy import SQLAlchemy + + +app = Flask(__name__) +app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///expenses.db' +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +db = SQLAlchemy(app) diff --git a/app/__init__.pyc b/app/__init__.pyc new file mode 100644 index 000000000..20079caea Binary files /dev/null and b/app/__init__.pyc differ diff --git a/app/models/Category.py b/app/models/Category.py new file mode 100644 index 000000000..c88d29cf1 --- /dev/null +++ b/app/models/Category.py @@ -0,0 +1,16 @@ +# -*- encoding: utf-8 -*- +from app import db + + +class Category(db.Model): + # id + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + + # name of category + name = db.Column(db.String(255)) + + def __init__(self, name): + self.name = name + + def __repr__(self): + return '' % self.name \ No newline at end of file diff --git a/app/models/Employee.py b/app/models/Employee.py new file mode 100644 index 000000000..b89ac0745 --- /dev/null +++ b/app/models/Employee.py @@ -0,0 +1,20 @@ +# -*- encoding: utf-8 -*- +from app import db + + +class Employee(db.Model): + # id + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + + # full name of employee + name = db.Column(db.String(255)) + + # employee's full address + address = db.Column(db.String(255)) + + def __init__(self, name, address): + self.name = name + self.address = address + + def __repr__(self): + return '' % self.name diff --git a/app/models/Expense.py b/app/models/Expense.py new file mode 100644 index 000000000..59125b93b --- /dev/null +++ b/app/models/Expense.py @@ -0,0 +1,50 @@ +# -*- encoding: utf-8 -*- +from app import db +from sqlalchemy.ext.hybrid import hybrid_property + + +class Expense(db.Model): + # id + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + + # date + date = db.Column(db.Date) + + # category + category_id = db.Column(db.Integer, db.ForeignKey('category.id')) + category = db.relationship( + 'Category', + backref=db.backref('expenses', lazy='dynamic') + ) + + # employee + employee_id = db.Column(db.Integer, db.ForeignKey('employee.id')) + employee = db.relationship( + 'Employee', + backref=db.backref('expenses', lazy='dynamic') + ) + + # tax + tax_id = db.Column(db.Integer, db.ForeignKey('tax.id')) + tax = db.relationship('Tax') + + # expense description + description = db.Column(db.String(255)) + + # pre-tax amount + pre_tax_amount = db.Column(db.Float) + + post_tax_amount = db.Column(db.Float) + + def __init__(self, date, category, employee, tax, description, + pre_tax_amount, post_tax_amount): + self.date = date + self.category = category + self.employee = employee + self.tax = tax + self.description = description + self.pre_tax_amount = pre_tax_amount + self.post_tax_amount = post_tax_amount + + def __repr__(self): + return '' % self.description diff --git a/app/models/Tax.py b/app/models/Tax.py new file mode 100644 index 000000000..b0f22e6a2 --- /dev/null +++ b/app/models/Tax.py @@ -0,0 +1,20 @@ +# -*- encoding: utf-8 -*- +from app import db + + +class Tax(db.Model): + # id + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + + # name of tax + name = db.Column(db.String(255)) + + # tax amount as a float + amount = db.Column(db.Float) + + def __init__(self, name, amount): + self.name = name + self.amount = amount + + def __repr__(self): + return '' % self.name diff --git a/app/models/Tax.pyc b/app/models/Tax.pyc new file mode 100644 index 000000000..0df085130 Binary files /dev/null and b/app/models/Tax.pyc differ diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 000000000..aac0fb452 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,6 @@ +from os.path import dirname, basename, isfile +import glob + + +modules = glob.glob(dirname(__file__)+"/*.py") +__all__ = [basename(f)[:-3] for f in modules if isfile(f)] diff --git a/app/models/__init__.pyc b/app/models/__init__.pyc new file mode 100644 index 000000000..661da3d87 Binary files /dev/null and b/app/models/__init__.pyc differ diff --git a/app/repositories/__init__.py b/app/repositories/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/repositories/expense.py b/app/repositories/expense.py new file mode 100644 index 000000000..e37f00e10 --- /dev/null +++ b/app/repositories/expense.py @@ -0,0 +1,72 @@ +# -*- encoding: utf-8 -*- +import csv +from datetime import datetime +from sqlalchemy import extract +from sqlalchemy.sql import func + +from app import db +from app.models.category import Category +from app.models.employee import Employee +from app.models.expense import Expense +from app.models.tax import Tax + + +class ExpenseRepository(): + """Repository to create and retrieve Expenses""" + def by_month(self): + year = extract('year', Expense.date) + month = extract('month', Expense.date) + + expenses = db.session.query( + year, + month, + func.sum(Expense.pre_tax_amount), + func.sum(Expense.post_tax_amount) + ).group_by( + year, + month + ).all() + + return expenses + + def from_csv(self, csvfile): + categories = {} + employees = {} + taxes = {} + + data = csv.DictReader(csvfile) + + for row in data: + if not row['category'] in categories: + new_category = Category(row['category']) + categories[row['category']] = new_category + db.session.add(new_category) + + if not row['employee name'] in employees: + new_employee = Employee(row['employee name'], + row['employee address']) + employees[row['employee name']] = new_employee + db.session.add(new_employee) + + pre_tax_amount = float(row['pre-tax amount'].replace(',', '')) + tax_amount = float(row['tax amount'].replace(',', '')) + tax_percentage = tax_amount / pre_tax_amount + + if not row['tax name'] in taxes: + new_tax = Tax(row['tax name'], tax_percentage) + taxes[row['tax name']] = new_tax + db.session.add(new_tax) + + # date, category, employee, tax, description, pre_tax_amount + new_expense = Expense( + datetime.strptime(row['date'], "%m/%d/%Y").date(), + categories[row['category']], + employees[row['employee name']], + taxes[row['tax name']], + row['expense description'], + pre_tax_amount, + tax_amount + ) + db.session.add(new_expense) + + db.session.commit() diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 000000000..10d25af93 --- /dev/null +++ b/app/routes.py @@ -0,0 +1,36 @@ +# -*- encoding: utf-8 -*- +import json +from app import app +from app.repositories.expense import ExpenseRepository +from flask import render_template, request, jsonify + + +def render_report(expenses): + table_row = "Period %d/%d Total Expenses: %.2f, Total Taxes: %.2f\n" + by_month = reduce( + lambda res, (y, m, e, t): res + (table_row % (m, y, e, t)), + expenses, + "" + ) + + +@app.route('/expenses', methods=['GET', 'POST']) +def upload(): + expense = ExpenseRepository() + if request.method == 'POST': + # check if the post request has the file part + if 'file' not in request.files: + return redirect(request.url) + + file = request.files['file'] + # if user does not select file, browser also + # submit a empty part without filename + if file.filename == '': + return redirect(request.url) + + expense.from_csv(file) + return jsonify({'success': True}) + else: + return jsonify(expense.by_month()) + + return make_response(by_month) diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 000000000..89551618c --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,8 @@ +{% extends "layout.html" %} + +{% block title %}Expenses{% endblock %} + +{% block body %} +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/layout.html b/app/templates/layout.html new file mode 100644 index 000000000..a4387e062 --- /dev/null +++ b/app/templates/layout.html @@ -0,0 +1,16 @@ + + + + {% block head %} + + + + {% block title %}{% endblock %} + {% endblock %} + + +
+ {% block body %}{% endblock %} +
+ + \ No newline at end of file diff --git a/data_example.csv b/client/data_example.csv similarity index 100% rename from data_example.csv rename to client/data_example.csv diff --git a/client/expenses.py b/client/expenses.py new file mode 100644 index 000000000..5ba2881fd --- /dev/null +++ b/client/expenses.py @@ -0,0 +1,32 @@ +import os.path +import click +import requests +from terminaltables import AsciiTable + + +ENDPOINT = "http://localhost:5000" + + +@click.command() +@click.option('--upload', default=False, help='Path to a well-formatted .csv file') +def expenses(upload): + """Simple querying CLI to upload and display expenses from a csv file""" + if upload: + if os.path.isfile(upload): + r = requests.post("%s/expenses" % ENDPOINT, files={ + 'file': open(upload, 'rb') + }) + if r.status_code == 200: + print "Successfully uploaded %s" % upload + else: + raise "The file %s does not exist, check the given path" % upload + + result = requests.get("%s/expenses" % ENDPOINT) + header = ['Year', 'Month', 'Total Expenses', 'Total Tax'] + expenses = result.json() + expenses.insert(0, header) + table = AsciiTable(expenses) + print table.table + +if __name__ == '__main__': + expenses() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..f8256f2c4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +click==6.6 +Flask==0.11.1 +Flask-SQLAlchemy==2.1 +itsdangerous==0.24 +Jinja2==2.8 +MarkupSafe==0.23 +requests==2.12.4 +SQLAlchemy==1.1.4 +terminaltables==3.1.0 +Werkzeug==0.11.11 diff --git a/run.py b/run.py new file mode 100644 index 000000000..961ed38af --- /dev/null +++ b/run.py @@ -0,0 +1,8 @@ +# -*- encoding: utf-8 -*- +import os +from app import routes +from app import app + +if __name__ == "__main__": + port = int(os.environ.get("PORT", 5000)) + app.run(host='0.0.0.0', port=port) diff --git a/setup.py b/setup.py new file mode 100644 index 000000000..ce64fd542 --- /dev/null +++ b/setup.py @@ -0,0 +1,5 @@ +# -*- encoding: utf-8 -*- +from app import db +from app.models import * + +db.create_all()