summaryrefslogtreecommitdiff
path: root/kosten/app/views
diff options
context:
space:
mode:
Diffstat (limited to 'kosten/app/views')
-rw-r--r--kosten/app/views/__init__.py61
-rw-r--r--kosten/app/views/categories.py31
-rw-r--r--kosten/app/views/consts.py174
-rw-r--r--kosten/app/views/expenses.py224
-rw-r--r--kosten/app/views/stats.py56
-rw-r--r--kosten/app/views/user.py89
6 files changed, 635 insertions, 0 deletions
diff --git a/kosten/app/views/__init__.py b/kosten/app/views/__init__.py
new file mode 100644
index 0000000..6b432e8
--- /dev/null
+++ b/kosten/app/views/__init__.py
@@ -0,0 +1,61 @@
+from flask import render_template, request, url_for
+import flask
+
+from .. import app, db
+
+#
+# Some general imports
+#
+from ..login import current_user, login_required
+from ..utils import today, templated, redirect, assert_authorisation
+from flask import Blueprint, flash
+
+__all__ = [
+ 'db', 'app',
+ 'current_user', 'login_required',
+ 'assert_authorisation', 'templated', 'today', 'redirect',
+ 'Blueprint', 'flash',
+ 'request', 'url_for'
+]
+
+# check for mobile visitors
+mobile_checks = ['J2ME', 'Opera Mini']
+
+app.add_template_global(zip)
+app.add_template_global(current_user)
+
+@app.before_request
+def handle_mobile():
+ ua = request.environ.get('HTTP_USER_AGENT', '')
+
+ flask.g.is_mobile = any((x in ua) for x in mobile_checks)
+
+@app.template_filter('static_url')
+def static_url(s, **kwargs):
+ return url_for('static', filename=s, **kwargs)
+
+@app.template_filter('eur')
+def eur(s):
+ return ('%s EUR' % s)
+
+@app.template_filter('date')
+def format_date(s, format='%Y/%m'):
+ if hasattr(s, 'date'):
+ return s.date.strftime(format)
+ else:
+ return s.strftime(format)
+
+@app.errorhandler(404)
+def page_not_found (error):
+ return render_template('404.jinja', page = request.path), 404
+
+# Now import the views
+from . import categories, consts, expenses, user, stats
+
+app.register_blueprint(expenses.mod)
+app.register_blueprint(user.mod, url_prefix='/user')
+app.register_blueprint(consts.mod, url_prefix='/const')
+app.register_blueprint(categories.mod, url_prefix='/cat')
+app.register_blueprint(stats.mod, url_prefix='/stats')
+
+app.add_url_rule('/', endpoint = 'index', build_only = True)
diff --git a/kosten/app/views/categories.py b/kosten/app/views/categories.py
new file mode 100644
index 0000000..47379ba
--- /dev/null
+++ b/kosten/app/views/categories.py
@@ -0,0 +1,31 @@
+from . import Blueprint, db, \
+ current_user, login_required, \
+ templated, redirect, request
+
+from ..model import Category
+
+mod = Blueprint('categories', __name__)
+
+@mod.route('/', methods=('GET', 'POST'))
+@login_required
+@templated
+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()
+
+ return { 'cats' : categories }
+ else:
+ for id, name in request.form.items():
+ if id.startswith('n-'):
+ db.session.add(Category(name = name, user = current_user))
+ else:
+ Category.get(id).name = name
+
+ db.session.commit()
+
+ return redirect('.manage')
diff --git a/kosten/app/views/consts.py b/kosten/app/views/consts.py
new file mode 100644
index 0000000..5d6598d
--- /dev/null
+++ b/kosten/app/views/consts.py
@@ -0,0 +1,174 @@
+# -*- coding: utf-8 -*-
+from . import Blueprint, flash, db, \
+ current_user, login_required, \
+ assert_authorisation, templated, redirect, request, \
+ today
+
+from ..model import Category, ConstExpense
+from .. import forms as F
+
+from sqlalchemy import sql
+from functools import partial
+
+mod = Blueprint('consts', __name__)
+assert_authorisation = partial(assert_authorisation, ConstExpense.get)
+
+def one_year(d):
+ """Returns the date d', such that [d, d'] spans exactly one year.
+ In effect, this is d + 11 months (NB: not 12!)"""
+ if d.month == 1:
+ return d.replace(month = 12)
+ else:
+ return d.replace(month = d.month - 1, year = d.year + 1)
+
+#
+# Form
+#
+class ConstForm(F.Form):
+ start = F.MonthField('Beginn', F.req,
+ default=lambda: today())
+
+ end = F.MonthField('Ende', F.req,
+ default=lambda: one_year(today()),
+ description='(einschließlich)')
+
+ months = F.IntegerField('Zahlungsrythmus', F.req,
+ description='Monate')
+
+ expense = F.DecimalField('Betrag', F.req,
+ description='EUR',
+ places=2)
+
+ description = F.StringField('Beschreibung', F.req)
+
+ category = F.QuerySelectField('Kategorie',
+ get_label='name',
+ get_pk=lambda c: c.id)
+
+ prev = F.QuerySelectField('Vorgänger',
+ get_label='description',
+ allow_blank=True,
+ get_pk=lambda p: p.id)
+
+ def __init__(self, cur=None, obj=None):
+ obj = cur if obj is None else obj
+ super().__init__(obj=obj)
+ self.category.query = Category.of(current_user).order_by(Category.name)
+
+ # init prev_list
+ CE = ConstExpense
+
+ filter = (CE.next == None)
+
+ if cur and cur.id is not None: # not empty
+ filter = sql.or_(CE.next == cur, filter)
+ filter = sql.and_(filter, CE.id != cur.id)
+
+ 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().replace(day = 1)
+
+ expenses = ConstExpense.of(current_user).order_by(ConstExpense.description).all()
+
+ current = []
+ old = []
+ future = []
+ last_month = []
+
+ for e in expenses:
+ if e.start <= d:
+ if e.end >= d:
+ current.append(e)
+ else:
+ if (d.month == 1 and e.end.month == 12 and e.end.year == d.year - 1) \
+ or (e.end.year == d.year and e.end.month == d.month - 1):
+ last_month.append(e)
+ else:
+ old.append(e)
+ else:
+ future.append(e)
+
+ return { 'current': current, 'old': old, 'future': future, 'last_month': last_month }
+
+
+@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)
+
+ if form.is_submitted():
+ if 'deleteB' in request.form:
+ db.session.delete(exp)
+ db.session.commit()
+ return redirect('.list')
+
+ elif form.flash_validate(): # change
+ form.populate_obj(exp)
+ db.session.commit()
+ flash("Eintrag geändert.")
+ return redirect('.show', id = 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("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)
+
+ # get form with data from other
+ form = ConstForm(obj = other)
+
+ # replace some fields to be more meaningful
+ start = max(form.end.data, today())
+ form.start.data = start
+ form.end.data = one_year(start)
+ if not other.next: form.prev.data = other
+
+ return { 'form': form }
diff --git a/kosten/app/views/expenses.py b/kosten/app/views/expenses.py
new file mode 100644
index 0000000..90c8ffd
--- /dev/null
+++ b/kosten/app/views/expenses.py
@@ -0,0 +1,224 @@
+# -*- coding: utf-8 -*-
+from . import Blueprint, flash, db, \
+ current_user, login_required, \
+ assert_authorisation, templated, redirect, request, url_for, today
+
+from flask import Markup
+
+from ..model import Category, SingleExpense, CatExpense, MonthExpense
+from .. import forms as F
+
+import datetime
+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('Datum', F.req,
+ default=lambda: today())
+
+ expense = F.DecimalField('Betrag', F.req,
+ description='EUR',
+ places=2)
+
+ description = F.StringField('Beschreibung')
+
+ category = F.QuerySelectField('Kategorie',
+ get_label='name',
+ get_pk=lambda c: c.id)
+
+ def __init__(self, obj = None, description_req = True):
+ super().__init__(obj = obj)
+ self.category.query = Category.of(current_user).order_by(Category.name)
+
+ if description_req:
+ self.description.validators.extend(F.req)
+
+#
+# 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.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_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.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_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 = "<a href=\"%s\">%s</a>" % (url, exp.description)
+ flash(Markup(msg % link))
+
+DATE_FORMAT='%Y%m%d'
+def parse_date(value):
+ try:
+ dt = datetime.datetime.strptime(value, DATE_FORMAT)
+ except ValueError:
+ return today()
+ else:
+ return dt.date()
+
+def gen_date(value):
+ return value.strftime(DATE_FORMAT)
+
+#
+# Template additions
+#
+@mod.app_template_filter()
+def prev_date(exp):
+ if exp.date.month == 1:
+ return exp.date.replace(year = exp.date.year - 1, month = 12)
+ else:
+ return exp.date.replace(month = exp.date.month - 1)
+
+
+@mod.app_template_filter()
+def next_date(exp):
+ if exp.date.month == 12:
+ return exp.date.replace(year = exp.date.year + 1, month = 1)
+ 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)
+
+#
+# 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)
+ if d.month == 1:
+ second, psecond = calc_month_and_pie(d.year - 1, 12)
+ else:
+ second, psecond = calc_month_and_pie(d.year, d.month - 1)
+
+ 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)
+
+ if form.is_submitted():
+ if 'deleteB' in request.form:
+ db.session.delete(exp)
+
+ elif form.flash_validate(): # change
+ form.populate_obj(exp)
+
+ else:
+ return { 'form': form }
+
+ db.session.commit()
+ entry_flash("Eintrag %s geändert.", exp)
+ return redirect('index')
+
+ return { 'form': form }
+
+
+@mod.route('/add', methods=('GET', 'POST'))
+@login_required
+@templated
+def add():
+ """Add a new expense."""
+ form = ExpenseForm(description_req=False)
+
+ if request.method == 'GET' and 'date' in request.args:
+ form.date.data = parse_date(request.args['date'])
+
+ if form.validate_on_submit():
+ if not form.description.data.strip():
+ form.description.data = form.category.data.name
+
+ exp = SingleExpense()
+
+ form.populate_obj(exp)
+ exp.user = current_user
+
+ db.session.add(exp)
+ db.session.commit()
+
+ entry_flash("Neuer Eintrag %s hinzugefügt.", exp)
+
+ return redirect('.add', date = gen_date(exp.date) if exp.date != today() else None)
+
+ return { 'form': form }
+
+@mod.route('/search', methods=('POST', 'GET'))
+@login_required
+@templated
+def search():
+ try:
+ query = request.form['search'].strip()
+ except KeyError:
+ flash("Ungültige Suchanfrage")
+ return redirect('index')
+
+ if not query:
+ flash("Leere Suche")
+ return redirect('index')
+
+ exps = SingleExpense.of(current_user).filter(SingleExpense.description.ilike(query))\
+ .order_by(SingleExpense.year.desc(), SingleExpense.month, SingleExpense.day, SingleExpense.description)\
+ .all()
+
+ if not exps:
+ flash("Keine Ergebnisse")
+ return redirect('index')
+
+ return { 'exps': exps }
diff --git a/kosten/app/views/stats.py b/kosten/app/views/stats.py
new file mode 100644
index 0000000..9ff81a1
--- /dev/null
+++ b/kosten/app/views/stats.py
@@ -0,0 +1,56 @@
+from . import Blueprint, flash, db, \
+ current_user, login_required, \
+ assert_authorisation, templated, redirect, request, \
+ today
+
+from .. import forms as F
+from ..model import ConstExpense, SingleExpense
+import sqlalchemy as sql
+import calendar
+from collections import defaultdict
+from datetime import date
+from flask import jsonify
+
+mod = Blueprint('stats', __name__)
+
+def next_date(d):
+ if d.month == 12:
+ return d.replace(year = d.year + 1, month = 1)
+ else:
+ return d.replace(month = d.month + 1)
+
+def date_to_ms(d):
+ return calendar.timegm(d.timetuple()) * 1000
+
+@mod.route('/_const/<int(fixed_digits=4):year>/<int(fixed_digits=2):month>')
+@login_required
+@templated
+def const_dialog(year,month):
+ consts = ConstExpense.of_month(current_user, month, year).order_by(ConstExpense.description)
+
+ return { 'consts': consts }
+
+
+@mod.route('/')
+@login_required
+@templated
+def show():
+ # easy way: fetch them all and then do some computation
+ consts = defaultdict(int)
+ t = today().replace(day = 1)
+ for e in ConstExpense.of(current_user):
+ cur = e.start
+ end = min(e.end, t)
+ while cur <= end:
+ consts[date_to_ms(cur)] += e.monthly
+ cur = next_date(cur)
+
+ consts = list(sorted(consts.items()))
+
+ expQuery = SingleExpense.of(current_user)\
+ .group_by(SingleExpense.year, SingleExpense.month)\
+ .values(SingleExpense.year, SingleExpense.month, sql.func.sum(SingleExpense.expense))
+
+ expenses = list(sorted((date_to_ms(date(year,month,1)), exp) for (year, month, exp) in expQuery))
+
+ return { 'consts': consts, 'expenses' : expenses }
diff --git a/kosten/app/views/user.py b/kosten/app/views/user.py
new file mode 100644
index 0000000..9b75af8
--- /dev/null
+++ b/kosten/app/views/user.py
@@ -0,0 +1,89 @@
+# -*- encoding: utf-8 -*-
+from . import Blueprint, flash, db, \
+ current_user, login_required, \
+ templated, redirect, request, url_for
+
+from .. import forms as F
+from ..login import login_user, logout_user, login_manager, User
+
+import flask
+
+mod = Blueprint('user', __name__)
+
+#
+# Forms
+#
+class LoginForm(F.Form):
+ username = F.StringField('Username', F.req)
+ pwd = F.PasswordField('Passwort', F.req)
+ remember = F.BooleanField('Eingeloggt bleiben?')
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.user = None
+
+ def validate(self):
+ rv = super().validate()
+ if not rv:
+ return False
+
+ user = User.get_by(name = self.username.data)
+ if user is None or not user.check_password(self.pwd.data):
+ return False
+
+ self.user = user
+ return True
+
+
+class ChangePwdForm(F.Form):
+ old = F.PasswordField('Passwort', F.req)
+ new = F.PasswordField('Neues Passwort', F.req + [F.validators.EqualTo('confirm', 'Passwörter stimmen nicht überein')])
+ confirm = F.PasswordField('Wdh. neues Passwort', F.req)
+
+ def validate_old(self, field):
+ if not current_user.check_password(field.data):
+ raise F.ValidationError("Falsches Passwort")
+
+ @property
+ def newpwd(self):
+ return self.new.data
+
+#
+# Views
+#
+@mod.route('/login', methods=('GET', 'POST'))
+@templated
+def login():
+ """Log the user in."""
+ form = LoginForm(flash="Login fehlgeschlagen!")
+ if form.validate_on_submit():
+ login_user(form.user, remember=form.remember.data)
+ # 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():
+ current_user.set_password(form.newpwd)
+ db.session.commit()
+ flash("Passwort geändert.")
+ return redirect('index')
+
+ return { 'form': form }
+
+# set this, so the user is redirected to the correct view, when not logged in
+login_manager.login_view = 'user.login'