summaryrefslogtreecommitdiff
path: root/kosten
diff options
context:
space:
mode:
authorRené 'Necoro' Neumann <necoro@necoro.eu>2020-07-23 00:28:47 +0200
committerRené 'Necoro' Neumann <necoro@necoro.eu>2020-07-23 00:28:47 +0200
commit81493afa53a1a1d5ff4b417d05febf9f9e2a172b (patch)
tree00de0a1bb7c386cff4203aa7b0789569e75347bb /kosten
parent6f6c8af2a55fabb69372e3fc4e8504167805d018 (diff)
downloadkosten-81493afa53a1a1d5ff4b417d05febf9f9e2a172b.tar.gz
kosten-81493afa53a1a1d5ff4b417d05febf9f9e2a172b.tar.bz2
kosten-81493afa53a1a1d5ff4b417d05febf9f9e2a172b.zip
Restructure
Diffstat (limited to 'kosten')
-rw-r--r--kosten/__init__.py1
-rw-r--r--kosten/app/__init__.py32
-rw-r--r--kosten/app/forms.py59
-rw-r--r--kosten/app/login.py21
-rw-r--r--kosten/app/model.py205
-rw-r--r--kosten/app/utils.py117
-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
-rw-r--r--kosten/static/css/style.css338
-rw-r--r--kosten/static/images/add.pngbin0 -> 800 bytes
-rw-r--r--kosten/static/images/arrow_left.pngbin0 -> 9190 bytes
-rw-r--r--kosten/static/images/arrow_right.pngbin0 -> 7846 bytes
-rw-r--r--kosten/static/images/closed.pngbin0 -> 301 bytes
-rw-r--r--kosten/static/images/closed.svg73
-rw-r--r--kosten/static/images/currency.pngbin0 -> 3225 bytes
-rw-r--r--kosten/static/images/img01.gifbin0 -> 3840 bytes
-rw-r--r--kosten/static/images/img02.gifbin0 -> 4787 bytes
-rw-r--r--kosten/static/images/img03.gifbin0 -> 227 bytes
-rw-r--r--kosten/static/images/img04.gifbin0 -> 92 bytes
-rw-r--r--kosten/static/images/lupe.pngbin0 -> 475 bytes
-rw-r--r--kosten/static/images/minus.pngbin0 -> 3406 bytes
-rw-r--r--kosten/static/images/open.pngbin0 -> 301 bytes
-rw-r--r--kosten/static/images/open.svg73
-rw-r--r--kosten/static/images/undo.pngbin0 -> 649 bytes
-rw-r--r--kosten/static/js/kosten.js280
-rw-r--r--kosten/static/js/lib/highstock-4.2.2.js431
-rw-r--r--kosten/static/js/lib/jquery-1.11.2.min.js4
-rw-r--r--kosten/templates/404.jinja10
-rw-r--r--kosten/templates/categories/manage.jinja19
-rw-r--r--kosten/templates/consts/add.jinja13
-rw-r--r--kosten/templates/consts/edit.jinja14
-rw-r--r--kosten/templates/consts/list.jinja24
-rw-r--r--kosten/templates/consts/show.jinja39
-rw-r--r--kosten/templates/expenses/add.jinja13
-rw-r--r--kosten/templates/expenses/edit.jinja14
-rw-r--r--kosten/templates/expenses/search.jinja25
-rw-r--r--kosten/templates/expenses/show.jinja64
-rw-r--r--kosten/templates/js.jinja25
-rw-r--r--kosten/templates/layout.jinja81
-rw-r--r--kosten/templates/macros.jinja39
-rw-r--r--kosten/templates/menu.jinja14
-rw-r--r--kosten/templates/stats/const_dialog.jinja8
-rw-r--r--kosten/templates/stats/show.jinja12
-rw-r--r--kosten/templates/user/cpw.jinja13
-rw-r--r--kosten/templates/user/login.jinja13
49 files changed, 2709 insertions, 0 deletions
diff --git a/kosten/__init__.py b/kosten/__init__.py
new file mode 100644
index 0000000..c07c459
--- /dev/null
+++ b/kosten/__init__.py
@@ -0,0 +1 @@
+from .app import app
diff --git a/kosten/app/__init__.py b/kosten/app/__init__.py
new file mode 100644
index 0000000..7c6408a
--- /dev/null
+++ b/kosten/app/__init__.py
@@ -0,0 +1,32 @@
+from flask import Flask
+
+# create app
+app = Flask('kosten', instance_relative_config = True)
+
+# force autoescape in all files
+app.jinja_env.autoescape = True
+
+app.jinja_env.lstrip_blocks = True
+app.jinja_env.trim_blocks = True
+
+# load config
+app.config.from_object('settings')
+app.config.from_pyfile('settings.py', silent = True)
+
+from .model import db
+from .login import login_manager
+from . import views
+
+# commands
+@app.cli.command()
+def create():
+ db.create_all()
+
+@app.cli.command()
+def drop():
+ db.drop_all()
+
+@app.cli.command()
+def compile():
+ """Compiles all templates."""
+ app.jinja_env.compile_templates('comp', zip = None)
diff --git a/kosten/app/forms.py b/kosten/app/forms.py
new file mode 100644
index 0000000..b7cbebf
--- /dev/null
+++ b/kosten/app/forms.py
@@ -0,0 +1,59 @@
+# -*- encoding: utf-8 -*-
+import flask
+from flask_wtf import FlaskForm
+from wtforms.fields import BooleanField, StringField, HiddenField, PasswordField, DecimalField as WTFDecimalField, DateField as HTML4DateField
+from wtforms.fields.html5 import DateField, IntegerField
+from wtforms import validators, ValidationError, Form as WTForm
+
+from wtforms.ext.sqlalchemy.fields import QuerySelectField
+
+from . import app
+
+@app.template_test('hidden')
+def is_hidden_field(f):
+ return isinstance(f, HiddenField)
+
+class DecimalField(WTFDecimalField):
+ def __init__(self, *args, **kwargs):
+ render_kw = kwargs.setdefault('render_kw', dict())
+ render_kw.setdefault('inputmethod', 'decimal')
+
+ super().__init__(*args, **kwargs)
+
+ def process_formdata(self, valuelist):
+ if valuelist:
+ value = valuelist[0].replace(',','.')
+ super().process_formdata([value])
+
+class MonthField(HTML4DateField):
+ def __init__(self, label, validators, format='%m.%Y', **kwargs):
+ super().__init__(label, validators, format, **kwargs)
+
+req = [validators.input_required()]
+
+class Form(FlaskForm):
+ class Meta:
+ locales = ['de_DE', 'de']
+
+ def __init__ (self, *args, **kwargs):
+ self._msg = kwargs.pop('flash', "Fehler im Formular!")
+ super().__init__(*args, **kwargs)
+
+ def flash(self):
+ flask.flash(self._msg, 'error')
+
+ def flash_validate (self):
+ if not self.validate():
+ self.flash()
+ return False
+
+ return True
+
+ def validate_on_submit (self):
+ return self.is_submitted() and self.flash_validate()
+
+ def _get_translations(self):
+ # use WTForms builtin translation support instead of the flask-babael
+ # stuff added by flask-wtf
+ # FIXME: remove this, if flask-babel is used in the app
+ return WTForm._get_translations(self)
diff --git a/kosten/app/login.py b/kosten/app/login.py
new file mode 100644
index 0000000..850cc8a
--- /dev/null
+++ b/kosten/app/login.py
@@ -0,0 +1,21 @@
+from flask_login import LoginManager, UserMixin
+from passlib.apps import custom_app_context as pwd_context
+from . import app, model
+
+# just for exporting
+from flask_login import login_user, logout_user, login_required, current_user
+
+login_manager = LoginManager()
+login_manager.init_app(app)
+login_manager.login_message = "Bitte einloggen!"
+
+class User (model.User, UserMixin):
+ def check_password(self, pwd):
+ return pwd_context.verify(pwd, self.pwd)
+
+ def set_password(self, pwd):
+ self.pwd = pwd_context.encrypt(pwd)
+
+@login_manager.user_loader
+def load_user(id):
+ return User.get(id)
diff --git a/kosten/app/model.py b/kosten/app/model.py
new file mode 100644
index 0000000..4663685
--- /dev/null
+++ b/kosten/app/model.py
@@ -0,0 +1,205 @@
+from flask_sqlalchemy import SQLAlchemy
+from sqlalchemy import sql
+from sqlalchemy.ext.declarative import declared_attr
+
+import datetime
+import decimal
+from functools import partial
+from collections import namedtuple
+
+from . import app
+
+db = SQLAlchemy(app)
+
+__all__ = ['db', \
+ 'Category', 'SingleExpense', 'ConstExpense', \
+ 'CatExpense', 'MonthExpense']
+
+Column = db.Column
+ReqColumn = partial(Column, nullable = False)
+ExpNum = db.Numeric(scale = 2, precision = 10)
+
+def to_exp(d):
+ """Converts decimal into expense"""
+ return d.quantize(decimal.Decimal('.01'), rounding = decimal.ROUND_UP)
+
+#
+# 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)
+
+ @declared_attr
+ def __tablename__ (cls):
+ return cls.__name__.lower()
+
+ @classmethod
+ def get_by (cls, *args, **kwargs):
+ return cls.query.filter_by(*args, **kwargs).first()
+
+ @classmethod
+ def get_by_or_404 (cls, *args, **kwargs):
+ return cls.query.filter_by(*args, **kwargs).first_or_404()
+
+ @classmethod
+ def get (cls, *args, **kwargs):
+ return cls.query.get(*args, **kwargs)
+
+ @classmethod
+ 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
+ def user_id(cls):
+ return ReqColumn(db.Integer, db.ForeignKey('user.id'))
+
+ @declared_attr
+ def user(cls):
+ return db.relationship('User')
+
+ @classmethod
+ 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'))
+
+ children = db.relationship('Category',
+ backref=db.backref('parent', remote_side="Category.id"))
+
+ def __init__ (self, name, user, parent_id = None):
+ Model.__init__(self)
+ self.name = name
+ self.user = user
+ self.parent_id = parent_id
+
+ def __repr__ (self):
+ if self.parent:
+ return '<Category "%s" of "%s">' % (self.name, self.parent.name)
+ else:
+ return '<Category "%s">' % self.name
+
+
+class CategoryModel (db.Model):
+ """Abstract base class for expenses: Adds the common fields
+ and establishes the connection to `Category`.
+ """
+ __abstract__ = True
+
+ @declared_attr
+ def category_id(cls):
+ return ReqColumn(db.Integer, db.ForeignKey(Category.id))
+
+ @declared_attr
+ def category(cls):
+ return db.relationship(Category, innerjoin = True)
+
+
+class SingleExpense (UserModel, CategoryModel):
+ description = Column(db.Unicode(50))
+ expense = ReqColumn(ExpNum)
+ year = ReqColumn(db.Integer)
+ month = ReqColumn(db.SmallInteger)
+ day = ReqColumn(db.SmallInteger)
+
+ @classmethod
+ def of_month (cls, user, month, year):
+ return cls.of(user).filter_by(month = month, year = year)
+
+ @property
+ def date (self):
+ return datetime.date(self.year, self.month, self.day)
+
+ @date.setter
+ def date (self, d):
+ self.year = d.year
+ self.month = d.month
+ self.day = d.day
+
+
+class ConstExpense (UserModel, CategoryModel):
+ description = Column(db.Unicode(50))
+ expense = ReqColumn(ExpNum)
+ months = ReqColumn(db.SmallInteger)
+ start = ReqColumn(db.Date, index = True)
+ end = ReqColumn(db.Date, index = True)
+ prev_id = Column(db.Integer, db.ForeignKey('constexpense.id'))
+
+ prev = db.relationship('ConstExpense', remote_side = 'ConstExpense.id', uselist = False,
+ backref=db.backref('next', uselist = False))
+
+ @property
+ def monthly(self):
+ return to_exp(self.expense / self.months)
+
+ @classmethod
+ def of_month (cls, user, month, year):
+ d = datetime.date(year, month, 1)
+ return cls.of(user).filter(sql.between(d, cls.start, cls.end))
+
+#
+# Work entities (not stored in DB)
+#
+class CatExpense (namedtuple('CatExpense', 'cat sum exps')):
+ __slots__ = ()
+
+ @property
+ def all (self):
+ return self.exps.order_by(SingleExpense.day).all()
+
+class MonthExpense (namedtuple('MonthExpense', 'user date catexps')):
+
+ def __init__ (self, *args, **kwargs):
+ self._consts = None
+
+ @property
+ def consts (self):
+ if self._consts is None:
+ self._consts = ConstExpense.of_month(self.user, self.date.month, self.date.year).all()
+
+ return self._consts
+
+ @property
+ def constsum (self):
+ s = sum(c.monthly for c in self.consts)
+ return s or 0
+
+ @property
+ def sum (self):
+ return self.constsum + sum(x.sum for x in self.catexps)
+
+ @property
+ def all (self):
+ return SingleExpense.of_month(self.user, self.date.month, self.date.year).order_by(SingleExpense.day).all()
+
+ def __str__ (self):
+ return '<MonthExpense (user: %s) of "%s": %s>' % (self.user.name, self.date, self.sum)
+
+#
+# Extra indices have to be here
+#
+
+db.Index('idx_single_date', SingleExpense.user_id, SingleExpense.year, SingleExpense.month)
+db.Index('idx_start_end', ConstExpense.user_id, ConstExpense.start, ConstExpense.end)
diff --git a/kosten/app/utils.py b/kosten/app/utils.py
new file mode 100644
index 0000000..73f2b51
--- /dev/null
+++ b/kosten/app/utils.py
@@ -0,0 +1,117 @@
+from functools import wraps
+from flask import flash, request, render_template, url_for
+from flask import redirect as _redirect
+
+from .login import current_user
+
+import datetime
+today = datetime.date.today
+
+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
+ fun = template
+ template = None
+
+ def decorator(f):
+ @wraps(f)
+ def decorated_function(*args, **kwargs):
+ if template is None:
+ template_name = _gen_tpl(request.endpoint)
+ elif template[0] == '.' and request.blueprint is not None:
+ template_name = _gen_tpl(request.blueprint + template)
+ else:
+ template_name = template
+
+ ctx = f(*args, **kwargs)
+ if ctx is None:
+ ctx = {}
+ elif not isinstance(ctx, dict):
+ return ctx
+ return render_template(template_name, **ctx)
+ return decorated_function
+
+ if fun is None:
+ return decorator
+ else:
+ 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)
+
+ if code is None:
+ return _redirect(url)
+ else:
+ 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):
+ p = kwargs.get(param, None)
+
+ if p is None:
+ raise TypeError("Keyword %s expected but not received." % param)
+
+ obj = constructor(p)
+ if obj is None:
+ flash("Eintrag existiert nicht!", 'error')
+ return redirect('index')
+
+ if not hasattr(obj, 'user_id'):
+ return f(*args, **kwargs)
+
+ # explicitly use user_id to avoid having to load the user object
+ if obj.user_id != current_user.id:
+ flash("Nicht erlaubte Operation!", 'error')
+ return redirect('index')
+ else:
+ return f(*args, **kwargs)
+ return decorated_function
+ return decorator
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')
+
+