Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Alessandro Marin's submission #139

Open
wants to merge 22 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
expenses
*.pyc
*.db
.DS_Store
37 changes: 37 additions & 0 deletions README.markdown
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
9 changes: 9 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
Binary file added app/__init__.pyc
Binary file not shown.
16 changes: 16 additions & 0 deletions app/models/Category.py
Original file line number Diff line number Diff line change
@@ -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 '<Category %r>' % self.name
20 changes: 20 additions & 0 deletions app/models/Employee.py
Original file line number Diff line number Diff line change
@@ -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 '<Employee %r>' % self.name
50 changes: 50 additions & 0 deletions app/models/Expense.py
Original file line number Diff line number Diff line change
@@ -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 '<Expense %r>' % self.description
20 changes: 20 additions & 0 deletions app/models/Tax.py
Original file line number Diff line number Diff line change
@@ -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 '<Tax %r>' % self.name
Binary file added app/models/Tax.pyc
Binary file not shown.
6 changes: 6 additions & 0 deletions app/models/__init__.py
Original file line number Diff line number Diff line change
@@ -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)]
Binary file added app/models/__init__.pyc
Binary file not shown.
Empty file added app/repositories/__init__.py
Empty file.
72 changes: 72 additions & 0 deletions app/repositories/expense.py
Original file line number Diff line number Diff line change
@@ -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()
36 changes: 36 additions & 0 deletions app/routes.py
Original file line number Diff line number Diff line change
@@ -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)
8 changes: 8 additions & 0 deletions app/templates/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{% extends "layout.html" %}

{% block title %}Expenses{% endblock %}

{% block body %}
<form action="/expenses" method="post" class="dropzone" enctype="multipart/form-data">
</form>
{% endblock %}
16 changes: 16 additions & 0 deletions app/templates/layout.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!doctype html>
<html>
<head>
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/dropzone/4.3.0/dropzone.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/dropzone/4.3.0/min/dropzone.min.js"></script>
<title>{% block title %}{% endblock %}</title>
{% endblock %}
</head>
<body>
<div class="container">
{% block body %}{% endblock %}
</div>
</body>
</html>
File renamed without changes.
32 changes: 32 additions & 0 deletions client/expenses.py
Original file line number Diff line number Diff line change
@@ -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()
10 changes: 10 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions run.py
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 5 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# -*- encoding: utf-8 -*-
from app import db
from app.models import *

db.create_all()