From 2bbf3bf2d37b319f06bc9e3a1c6d9097aa287bfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20=27Necoro=27=20Neumann?= Date: Fri, 1 Nov 2013 22:57:17 +0100 Subject: Fixes and cleanup and documentation --- app/forms.py | 3 +-- app/login.py | 8 ++---- app/model.py | 25 ++++++++++++++--- app/utils.py | 41 ++++++++++++++++++++++++++++ app/views/categories.py | 6 ++++- app/views/consts.py | 57 ++++++++++++++++++++++++--------------- app/views/expenses.py | 62 ++++++++++++++++++++++++++++++++----------- app/views/user.py | 37 +++++++++++++++++--------- templates/expenses/show.jinja | 2 +- 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 '' % 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 '' % (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/') + @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('/') @login_required @assert_authorisation('id') @templated def show(id): + """Show a specific constant expense.""" return { 'exp': ConstExpense.get(id) } + @mod.route('/edit/', 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/') @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"%s" % (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('//') -@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('/', 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('//') +@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('/', endpoint = 'show_date_str', build_only = True) + + @mod.route('/edit/', 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 @@
{% 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) %} {{exp.day}}.{{exp.month}}. -- {{exp.description}}: {{exp.expense | eur }} {% endcall %} {% endfor %} -- cgit v1.2.3