diff options
author | René 'Necoro' Neumann <necoro@necoro.eu> | 2020-07-23 00:28:47 +0200 |
---|---|---|
committer | René 'Necoro' Neumann <necoro@necoro.eu> | 2020-07-23 00:28:47 +0200 |
commit | 81493afa53a1a1d5ff4b417d05febf9f9e2a172b (patch) | |
tree | 00de0a1bb7c386cff4203aa7b0789569e75347bb /kosten | |
parent | 6f6c8af2a55fabb69372e3fc4e8504167805d018 (diff) | |
download | kosten-81493afa53a1a1d5ff4b417d05febf9f9e2a172b.tar.gz kosten-81493afa53a1a1d5ff4b417d05febf9f9e2a172b.tar.bz2 kosten-81493afa53a1a1d5ff4b417d05febf9f9e2a172b.zip |
Restructure
Diffstat (limited to 'kosten')
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') + + |