summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRené 'Necoro' Neumann <necoro@necoro.net>2013-11-01 22:57:17 +0100
committerRené 'Necoro' Neumann <necoro@necoro.net>2013-11-01 22:57:17 +0100
commit2bbf3bf2d37b319f06bc9e3a1c6d9097aa287bfa (patch)
treeecb227370aadc3e6256e074bdb72c00b1f141b94
parentec69bf1dc3be9eb2ebf971943539d77fb70f83c6 (diff)
downloadkosten-2bbf3bf2d37b319f06bc9e3a1c6d9097aa287bfa.tar.gz
kosten-2bbf3bf2d37b319f06bc9e3a1c6d9097aa287bfa.tar.bz2
kosten-2bbf3bf2d37b319f06bc9e3a1c6d9097aa287bfa.zip
Fixes and cleanup and documentation
-rw-r--r--app/forms.py3
-rw-r--r--app/login.py8
-rw-r--r--app/model.py25
-rw-r--r--app/utils.py41
-rw-r--r--app/views/categories.py6
-rw-r--r--app/views/consts.py57
-rw-r--r--app/views/expenses.py62
-rw-r--r--app/views/user.py37
-rw-r--r--templates/expenses/show.jinja2
9 files changed, 176 insertions, 65 deletions
diff --git a/app/forms.py b/app/forms.py
index b4d7427..6d7abfb 100644
--- a/app/forms.py
+++ b/app/forms.py
@@ -2,8 +2,7 @@
import flask
from flask.ext.wtf import Form as _Form
from wtforms.fields import DateField, IntegerField, StringField, HiddenField, PasswordField
-from wtforms import validators
-from wtforms import fields, ValidationError
+from wtforms import fields, validators, ValidationError
from wtforms.ext.sqlalchemy.fields import QuerySelectField
from wtforms.ext.i18n.form import Form as i18nForm
diff --git a/app/login.py b/app/login.py
index 7098db4..93b4a9d 100644
--- a/app/login.py
+++ b/app/login.py
@@ -1,19 +1,15 @@
from flask.ext.login import LoginManager, UserMixin
+from passlib.apps import custom_app_context as pwd_context
+from . import app, model
# just for exporting
from flask.ext.login import login_user, logout_user, login_required, current_user
-from passlib.apps import custom_app_context as pwd_context
-
-from . import app
-from . import model
-
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_message = u"Bitte einloggen!"
class User (model.User, UserMixin):
-
def check_password(self, pwd):
return pwd_context.verify(pwd, self.pwd)
diff --git a/app/model.py b/app/model.py
index aa9b666..ec555d3 100644
--- a/app/model.py
+++ b/app/model.py
@@ -25,7 +25,12 @@ def to_exp(d):
#
# Database Entities
+#
class Model (db.Model):
+ """Abstract base class for all models.
+ Adds an id PK and several convenience accessors.
+ """
+
__abstract__ = True
id = Column(db.Integer, primary_key=True)
@@ -50,13 +55,18 @@ class Model (db.Model):
def get_or_404 (cls, *args, **kwargs):
return cls.query.get_or_404(*args, **kwargs)
+
class User (Model):
+ # NB: This is abstract, the flesh is added in login.py
+
__abstract__ = True
name = ReqColumn(db.Unicode(50), unique = True)
pwd = ReqColumn(db.Unicode(255))
description = Column(db.Unicode(100))
+
class UserModel (Model):
+ """Abstract base class for tables that have a user column."""
__abstract__ = True
@declared_attr
@@ -71,6 +81,7 @@ class UserModel (Model):
def of (cls, user):
return cls.query.filter_by(user = user)
+
class Category (UserModel):
name = ReqColumn(db.Unicode(50))
parent_id = Column(db.Integer, db.ForeignKey('category.id'))
@@ -90,7 +101,11 @@ class Category (UserModel):
else:
return '<Category "%s">' % self.name
+
class Expense (UserModel):
+ """Abstract base class for expenses: Adds the common fields
+ and establishes the connection to `Category`.
+ """
__abstract__ = True
description = Column(db.Unicode(50))
@@ -104,6 +119,7 @@ class Expense (UserModel):
def category(cls):
return db.relationship(Category, innerjoin = True)
+
class SingleExpense (Expense):
year = ReqColumn(db.Integer)
month = ReqColumn(db.SmallInteger)
@@ -123,6 +139,7 @@ class SingleExpense (Expense):
self.month = d.month
self.day = d.day
+
class ConstExpense (Expense):
months = ReqColumn(db.SmallInteger)
start = ReqColumn(db.Date, index = True)
@@ -144,7 +161,7 @@ class ConstExpense (Expense):
#
# Work entities (not stored in DB)
#
-class CatExpense (namedtuple('CatExpense', 'cat expense exps')):
+class CatExpense (namedtuple('CatExpense', 'cat sum exps')):
__slots__ = ()
@property
@@ -152,7 +169,7 @@ class CatExpense (namedtuple('CatExpense', 'cat expense exps')):
return self.exps.order_by(SingleExpense.day).all()
class MonthExpense (namedtuple('MonthExpense', 'user date catexps')):
-
+
def __init__ (self, *args, **kwargs):
self._consts = None
super(MonthExpense, self).__init__(*args, **kwargs)
@@ -171,7 +188,7 @@ class MonthExpense (namedtuple('MonthExpense', 'user date catexps')):
@property
def sum (self):
- return self.constsum + sum(x.expense for x in self.catexps)
+ return self.constsum + sum(x.sum for x in self.catexps)
@property
def all (self):
@@ -181,7 +198,7 @@ class MonthExpense (namedtuple('MonthExpense', 'user date catexps')):
return '<MonthExpense (user: %s) of "%s": %s>' % (self.user.name, self.date, self.sum)
#
-# Extra indizes have to be here
+# Extra indices have to be here
#
db.Index('idx_single_date', SingleExpense.user_id, SingleExpense.year, SingleExpense.month)
diff --git a/app/utils.py b/app/utils.py
index 2180282..9ee0cf1 100644
--- a/app/utils.py
+++ b/app/utils.py
@@ -11,6 +11,28 @@ def _gen_tpl(endpoint):
return endpoint.replace('.', '/') + '.jinja'
def templated(template=None):
+ """Marks a view as being rendered by a template. The view then shall
+ return a dictionary holding the parameters for the template. Ig this
+ is not the case, the response is returned unchanged. This is needed
+ to support `redirect` and similar.
+
+ The correct template is deducted as:
+ - when passed nothing: the name of the view
+ - when passed a string '.bla', the endpoint 'bla' in the current
+ blueprint
+ - when passed any other string: this string (VERBATIM!)
+
+ Except for the last case, the hierarchy of blueprint and view is taken
+ as directories in the template directory. And '.jinja' is appended.
+
+ If the first argument is a function, this is taken as 'None' to allow:
+ >>> @templated
+ ... def foo():
+ ... ...
+
+ (else it would have to be ``@templated()``).
+ """
+
fun = None
if template is not None and callable(template):
# a function was passed in
@@ -41,6 +63,11 @@ def templated(template=None):
return decorator(fun)
def redirect (target, **kwargs):
+ """Convenience wrapper for `flask.redirect`. It applies `url_for`
+ on the target, which also gets passed all arguments.
+
+ Special argument '_code' to set the HTTP-Code.
+ """
code = kwargs.pop('_code', None)
url = url_for(target, **kwargs)
@@ -50,6 +77,20 @@ def redirect (target, **kwargs):
return _redirect(url, code)
def assert_authorisation(constructor, param):
+ """Asserts that the current user has the right to load some specific data.
+
+ This is done by using the argument with keyword `param` and pass it
+ to `constructor`. If the resulting object has an attribute `user_id`,
+ this is checked to be equal to `current_user.id`.
+
+ Usage example::
+
+ @route('/job/<int:id>')
+ @assert_authorisation(Job, 'id')
+ def show_job(id):
+ # this is only executed if Job(id).user_id == current_user.id
+
+ """
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
diff --git a/app/views/categories.py b/app/views/categories.py
index 0bb64ef..ca9b828 100644
--- a/app/views/categories.py
+++ b/app/views/categories.py
@@ -9,8 +9,12 @@ mod = Blueprint('categories', __name__)
@mod.route('/', methods=('GET', 'POST'))
@login_required
@templated
-def manage ():
+def manage():
+ """Workhorse: List and edit/create. For historic reasons,
+ everything is done in JavaScript.
+ NB: No deletion possible yet.
+ """
if request.method == 'GET':
categories = Category.of(current_user).order_by(Category.name).all()
diff --git a/app/views/consts.py b/app/views/consts.py
index 97afad1..4b5ea68 100644
--- a/app/views/consts.py
+++ b/app/views/consts.py
@@ -7,14 +7,15 @@ from . import Blueprint, flash, db, \
from ..model import Category, ConstExpense
from .. import forms as F
-import datetime
from sqlalchemy import sql
from functools import partial
-assert_authorisation = partial(assert_authorisation, ConstExpense.get)
-
mod = Blueprint('consts', __name__)
+assert_authorisation = partial(assert_authorisation, ConstExpense.get)
+#
+# Form
+#
class ConstForm(F.Form):
start = F.DateField(u'Beginn', F.req,
format='%m.%Y',
@@ -43,7 +44,7 @@ class ConstForm(F.Form):
def __init__(self, cur=None, obj=None):
obj = cur if obj is None else obj
- super(F.Form, self).__init__(obj=obj)
+ super(ConstForm, self).__init__(obj=obj)
self.category.query = Category.of(current_user).order_by(Category.name)
# init prev_list
@@ -57,10 +58,14 @@ class ConstForm(F.Form):
self.prev.query = CE.of(current_user).filter(filter).order_by(CE.description)
+#
+# Views
+#
@mod.route('/')
@login_required
@templated
def list ():
+ """List all constant expenses."""
d = today()
expenses = ConstExpense.of(current_user).order_by(ConstExpense.description).all()
@@ -80,18 +85,22 @@ def list ():
return { 'current': current, 'old': old, 'future': future }
+
@mod.route('/<int:id>')
@login_required
@assert_authorisation('id')
@templated
def show(id):
+ """Show a specific constant expense."""
return { 'exp': ConstExpense.get(id) }
+
@mod.route('/edit/<int:id>', methods=('GET', 'POST'))
@login_required
@assert_authorisation('id')
@templated
def edit(id):
+ """Edit a specific constant expense. This includes deletion."""
exp = ConstExpense.get(id)
form = ConstForm(exp)
@@ -109,11 +118,33 @@ def edit(id):
return { 'form': form }
+
+@mod.route('/add/', methods=('GET', 'POST'))
+@login_required
+@templated
+def add():
+ """Add a new constant expense."""
+ exp = ConstExpense()
+
+ form = ConstForm()
+
+ if form.validate_on_submit():
+ form.populate_obj(exp)
+ exp.user = current_user
+ db.session.add(exp)
+ db.session.commit()
+ flash(u"Eintrag hinzugefügt.")
+ return redirect('.show', id = exp.id)
+
+ return { 'form': form }
+
+
@mod.route('/add/from/<int:other>')
@login_required
@assert_authorisation('other')
@templated('.add')
def add_from(other):
+ """Copy `other` and create a new expense based on it."""
exp = ConstExpense() # needed to initialize 'CE.next'
other = ConstExpense.get(other)
@@ -128,21 +159,3 @@ def add_from(other):
if not other.next: form.prev.data = other
return { 'form': form }
-
-@mod.route('/add/', methods=('GET', 'POST'))
-@login_required
-@templated
-def add ():
- exp = ConstExpense()
-
- form = ConstForm()
-
- if form.validate_on_submit():
- form.populate_obj(exp)
- exp.user = current_user
- db.session.add(exp)
- db.session.commit()
- flash(u"Eintrag hinzugefügt.")
- return redirect('.show', id = exp.id)
-
- return { 'form': form }
diff --git a/app/views/expenses.py b/app/views/expenses.py
index 870b45f..23cd42e 100644
--- a/app/views/expenses.py
+++ b/app/views/expenses.py
@@ -9,12 +9,15 @@ from ..model import Category, SingleExpense, CatExpense, MonthExpense
from .. import forms as F
import datetime
-from sqlalchemy import sql, func
+from sqlalchemy import func
from functools import partial
assert_authorisation = partial(assert_authorisation, SingleExpense.get)
mod = Blueprint('expenses', __name__)
+#
+# Form
+#
class ExpenseForm(F.Form):
date = F.DateField(u'Datum', F.req,
format="%d.%m.%Y",
@@ -30,38 +33,52 @@ class ExpenseForm(F.Form):
get_label='name')
def __init__(self, obj = None):
- super(F.Form, self).__init__(obj = obj)
+ super(ExpenseForm, self).__init__(obj = obj)
self.category.query = Category.of(current_user).order_by(Category.name)
+#
+# Utilities
+#
def calc_month_exp(year, month):
+ """Returns the `MonthExpense` for the given month."""
ssum = func.sum(SingleExpense.expense)
query = SingleExpense.of_month(current_user, month, year)
result = query.group_by(SingleExpense.category_id).\
values(SingleExpense.category_id, ssum)
- exps = [CatExpense(Category.query.get(c), s, query.filter(SingleExpense.category_id == c)) for c,s in result]
+ exps = [CatExpense(Category.get(c), s, query.filter(SingleExpense.category_id == c)) for c,s in result]
return MonthExpense(current_user, datetime.date(year, month, 1), exps)
-def pie_stuff(exp):
+
+def pie_data(exp):
+ """Generates the dictionary needed to show the pie diagram.
+ The resulting dict is category → sum of expenses.
+ """
expenses = {}
for c in exp.catexps:
- expenses[c.cat.name] = float(c.expense)
+ expenses[c.cat.name] = float(c.sum)
for c in Category.of(current_user).order_by(Category.name).all():
yield (c.name, expenses.get(c.name, 0.0))
+
def calc_month_and_pie(year, month):
exp = calc_month_exp(year,month)
- pie = pie_stuff(exp)
+ pie = pie_data(exp)
return (exp, dict(pie))
+
def entry_flash(msg, exp):
+ """When changing/adding an entry, a message is shown."""
url = url_for('.edit', id = exp.id)
link = u"<a href=\"%s\">%s</a>" % (url, exp.description)
flash(Markup(msg % link))
+#
+# Template additions
+#
@mod.app_template_filter()
def prev_date(exp):
if exp.date.month == 1:
@@ -69,6 +86,7 @@ def prev_date(exp):
else:
return exp.date.replace(month = exp.date.month - 1)
+
@mod.app_template_filter()
def next_date(exp):
if exp.date.month == 12:
@@ -76,23 +94,19 @@ def next_date(exp):
else:
return exp.date.replace(month = exp.date.month + 1)
+
@mod.app_template_test('last_date')
def is_last(exp):
return exp.date >= today().replace(day = 1)
-@mod.route('/<int(fixed_digits=4):year>/<int(fixed_digits=2):month>')
-@login_required
-@templated('.show')
-def show_date(year, month):
- c,p = calc_month_and_pie(year, month)
- return { 'exps' : [c], 'pies' : [p] }
-
-mod.add_url_rule('/<path:p>', endpoint = 'show_date_str', build_only = True)
-
+#
+# Views
+#
@mod.route('/')
@login_required
@templated
def show():
+ """Show this and the last month."""
d = today()
first, pfirst = calc_month_and_pie(d.year, d.month)
@@ -103,11 +117,25 @@ def show():
return { 'exps' : [first, second], 'pies': [pfirst, psecond] }
+
+@mod.route('/<int(fixed_digits=4):year>/<int(fixed_digits=2):month>')
+@login_required
+@templated('.show')
+def show_date(year, month):
+ """Show the expenses of the specified month."""
+ c,p = calc_month_and_pie(year, month)
+ return { 'exps' : [c], 'pies' : [p] }
+
+# shortcut to allow calling the above route, when year/month is a string
+mod.add_url_rule('/<path:p>', endpoint = 'show_date_str', build_only = True)
+
+
@mod.route('/edit/<int:id>', methods=('GET', 'POST'))
@login_required
@assert_authorisation('id')
@templated
def edit(id):
+ """Edit a single expense, given by `id`."""
exp = SingleExpense.get(id)
form = ExpenseForm(exp)
@@ -127,10 +155,12 @@ def edit(id):
return { 'form': form }
-@mod.route('/add/', methods=('GET', 'POST'))
+
+@mod.route('/add', methods=('GET', 'POST'))
@login_required
@templated
def add():
+ """Add a new expense."""
form = ExpenseForm()
if form.validate_on_submit():
diff --git a/app/views/user.py b/app/views/user.py
index 7f6f998..6102d3b 100644
--- a/app/views/user.py
+++ b/app/views/user.py
@@ -10,16 +10,19 @@ import flask
mod = Blueprint('user', __name__)
+#
+# Forms
+#
class LoginForm(F.Form):
username = F.StringField(u'Username', F.req)
pwd = F.PasswordField(u'Passwort', F.req)
def __init__(self, *args, **kwargs):
- super(F.Form, self).__init__(*args, **kwargs)
+ super(LoginForm, self).__init__(*args, **kwargs)
self.user = None
def validate(self):
- rv = super(F.Form, self).validate()
+ rv = super(LoginForm, self).validate()
if not rv:
return False
@@ -30,6 +33,7 @@ class LoginForm(F.Form):
self.user = user
return True
+
class ChangePwdForm(F.Form):
old = F.PasswordField(u'Passwort', F.req)
new = F.PasswordField(u'Neues Passwort', F.req + [F.validators.EqualTo('confirm', u'Passwörter stimmen nicht überein')])
@@ -43,22 +47,33 @@ class ChangePwdForm(F.Form):
def newpwd(self):
return self.new.data
+#
+# Views
+#
@mod.route('/login', methods=('GET', 'POST'))
@templated
def login():
- form = LoginForm(flash=u"Login fehlgeschlagen!")
+ """Log the user in."""
+ form = LoginForm(flash=u"Login fehlgeschlagen!")
+ if form.validate_on_submit():
+ login_user(form.user)
+ # we explicitly need flask's variant as we redirect to a URI
+ return flask.redirect(request.args.get('next') or url_for('index'))
+ return { 'form': form }
- if form.validate_on_submit():
- login_user(form.user)
- # we explicitly need flask's variant as we redirect to a URI
- return flask.redirect(request.args.get('next') or url_for('index'))
- return { 'form': form }
+@mod.route('/logout')
+def logout():
+ """Log the user out."""
+ logout_user()
+ return redirect('.login')
+
@mod.route('/cpw', methods=('GET', 'POST'))
@login_required
@templated
def cpw():
+ """Change the password of the user."""
form = ChangePwdForm()
if form.validate_on_submit():
@@ -69,9 +84,5 @@ def cpw():
return { 'form': form }
-@mod.route('/logout')
-def logout():
- logout_user()
- return redirect('.login')
-
+# set this, so the user is redirected to the correct view, when not logged in
login_manager.login_view = 'user.login'
diff --git a/templates/expenses/show.jinja b/templates/expenses/show.jinja
index 395d1e5..a744038 100644
--- a/templates/expenses/show.jinja
+++ b/templates/expenses/show.jinja
@@ -22,7 +22,7 @@
<div>
<div class="month_exp">
{% for c in e.catexps | sort(attribute="cat.name") %}
- {% call(exp) detail(name=c.cat.name, sum=c.expense, set=c.all) %}
+ {% call(exp) detail(name=c.cat.name, sum=c.sum, set=c.all) %}
<a href="{{ url_for(".edit", id = exp.id) }}">{{exp.day}}.{{exp.month}}. -- {{exp.description}}: {{exp.expense | eur }}</a>
{% endcall %}
{% endfor %}