diff options
Diffstat (limited to 'kosten/app/views')
-rw-r--r-- | kosten/app/views/__init__.py | 61 | ||||
-rw-r--r-- | kosten/app/views/categories.py | 31 | ||||
-rw-r--r-- | kosten/app/views/consts.py | 174 | ||||
-rw-r--r-- | kosten/app/views/expenses.py | 224 | ||||
-rw-r--r-- | kosten/app/views/stats.py | 56 | ||||
-rw-r--r-- | kosten/app/views/user.py | 89 |
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' |