From 81493afa53a1a1d5ff4b417d05febf9f9e2a172b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20=27Necoro=27=20Neumann?= Date: Thu, 23 Jul 2020 00:28:47 +0200 Subject: Restructure --- kosten/__init__.py | 1 + kosten/app/__init__.py | 32 +++ kosten/app/forms.py | 59 ++++ kosten/app/login.py | 21 ++ kosten/app/model.py | 205 ++++++++++++++ kosten/app/utils.py | 117 ++++++++ kosten/app/views/__init__.py | 61 +++++ kosten/app/views/categories.py | 31 +++ kosten/app/views/consts.py | 174 ++++++++++++ kosten/app/views/expenses.py | 224 ++++++++++++++++ kosten/app/views/stats.py | 56 ++++ kosten/app/views/user.py | 89 ++++++ kosten/static/css/style.css | 338 +++++++++++++++++++++++ kosten/static/images/add.png | Bin 0 -> 800 bytes kosten/static/images/arrow_left.png | Bin 0 -> 9190 bytes kosten/static/images/arrow_right.png | Bin 0 -> 7846 bytes kosten/static/images/closed.png | Bin 0 -> 301 bytes kosten/static/images/closed.svg | 73 +++++ kosten/static/images/currency.png | Bin 0 -> 3225 bytes kosten/static/images/img01.gif | Bin 0 -> 3840 bytes kosten/static/images/img02.gif | Bin 0 -> 4787 bytes kosten/static/images/img03.gif | Bin 0 -> 227 bytes kosten/static/images/img04.gif | Bin 0 -> 92 bytes kosten/static/images/lupe.png | Bin 0 -> 475 bytes kosten/static/images/minus.png | Bin 0 -> 3406 bytes kosten/static/images/open.png | Bin 0 -> 301 bytes kosten/static/images/open.svg | 73 +++++ kosten/static/images/undo.png | Bin 0 -> 649 bytes kosten/static/js/kosten.js | 280 +++++++++++++++++++ kosten/static/js/lib/highstock-4.2.2.js | 431 ++++++++++++++++++++++++++++++ kosten/static/js/lib/jquery-1.11.2.min.js | 4 + kosten/templates/404.jinja | 10 + kosten/templates/categories/manage.jinja | 19 ++ kosten/templates/consts/add.jinja | 13 + kosten/templates/consts/edit.jinja | 14 + kosten/templates/consts/list.jinja | 24 ++ kosten/templates/consts/show.jinja | 39 +++ kosten/templates/expenses/add.jinja | 13 + kosten/templates/expenses/edit.jinja | 14 + kosten/templates/expenses/search.jinja | 25 ++ kosten/templates/expenses/show.jinja | 64 +++++ kosten/templates/js.jinja | 25 ++ kosten/templates/layout.jinja | 81 ++++++ kosten/templates/macros.jinja | 39 +++ kosten/templates/menu.jinja | 14 + kosten/templates/stats/const_dialog.jinja | 8 + kosten/templates/stats/show.jinja | 12 + kosten/templates/user/cpw.jinja | 13 + kosten/templates/user/login.jinja | 13 + 49 files changed, 2709 insertions(+) create mode 100644 kosten/__init__.py create mode 100644 kosten/app/__init__.py create mode 100644 kosten/app/forms.py create mode 100644 kosten/app/login.py create mode 100644 kosten/app/model.py create mode 100644 kosten/app/utils.py create mode 100644 kosten/app/views/__init__.py create mode 100644 kosten/app/views/categories.py create mode 100644 kosten/app/views/consts.py create mode 100644 kosten/app/views/expenses.py create mode 100644 kosten/app/views/stats.py create mode 100644 kosten/app/views/user.py create mode 100644 kosten/static/css/style.css create mode 100644 kosten/static/images/add.png create mode 100644 kosten/static/images/arrow_left.png create mode 100644 kosten/static/images/arrow_right.png create mode 100644 kosten/static/images/closed.png create mode 100644 kosten/static/images/closed.svg create mode 100644 kosten/static/images/currency.png create mode 100644 kosten/static/images/img01.gif create mode 100644 kosten/static/images/img02.gif create mode 100644 kosten/static/images/img03.gif create mode 100644 kosten/static/images/img04.gif create mode 100644 kosten/static/images/lupe.png create mode 100644 kosten/static/images/minus.png create mode 100644 kosten/static/images/open.png create mode 100644 kosten/static/images/open.svg create mode 100644 kosten/static/images/undo.png create mode 100644 kosten/static/js/kosten.js create mode 100644 kosten/static/js/lib/highstock-4.2.2.js create mode 100644 kosten/static/js/lib/jquery-1.11.2.min.js create mode 100644 kosten/templates/404.jinja create mode 100644 kosten/templates/categories/manage.jinja create mode 100644 kosten/templates/consts/add.jinja create mode 100644 kosten/templates/consts/edit.jinja create mode 100644 kosten/templates/consts/list.jinja create mode 100644 kosten/templates/consts/show.jinja create mode 100644 kosten/templates/expenses/add.jinja create mode 100644 kosten/templates/expenses/edit.jinja create mode 100644 kosten/templates/expenses/search.jinja create mode 100644 kosten/templates/expenses/show.jinja create mode 100644 kosten/templates/js.jinja create mode 100644 kosten/templates/layout.jinja create mode 100644 kosten/templates/macros.jinja create mode 100644 kosten/templates/menu.jinja create mode 100644 kosten/templates/stats/const_dialog.jinja create mode 100644 kosten/templates/stats/show.jinja create mode 100644 kosten/templates/user/cpw.jinja create mode 100644 kosten/templates/user/login.jinja (limited to 'kosten') 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 '' % (self.name, self.parent.name) + else: + return '' % 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 '' % (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/') + @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('/') +@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) + + 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/') +@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 = "%s" % (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('//') +@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) + + 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//') +@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' diff --git a/kosten/static/css/style.css b/kosten/static/css/style.css new file mode 100644 index 0000000..f2844f1 --- /dev/null +++ b/kosten/static/css/style.css @@ -0,0 +1,338 @@ +/* +Design by Free CSS Templates +http://www.freecsstemplates.org +Released for free under a Creative Commons Attribution 2.5 License +*/ + +body { + margin: 0; + padding: 0; + background: #000000; + font-size: 13px; + font-family: "Trebuchet MS", Georgia, "Times New Roman", Times, serif; + text-align: justify; + color: #FFFFFF; +} + + +h1, h2, h3 { + margin: 0; + text-transform: lowercase; + font-weight: normal; + color: #FFFFFF; +} + +h1 { + letter-spacing: -1px; + font-size: 32px; +} + +h2 { + font-size: 23px; +} + +h3 { + font-size: 18px; +} + +p, ul, ol { + text-align: justify; + line-height: 20px; +} + +p + h2, p + h3 { + margin-top: 1.5em; +} + +a:link { + color: #8BD80E; +} + +a:hover, a:active { + text-decoration: none; + color: #8BD80E; +} + +a:visited { + color: #8BD80E; +} + +img { + border: none; +} + +#left { + float: left; + margin-right: 15px; + text-decoration: none; +} + +#right { + float: right; + margin-left: 15px; + text-decoration: none; +} + +img.arrow { + width: 20px; + height: 20px; + vertical-align: middle; +} + +.navdate { + vertical-align: middle; + padding-right: 5px; + padding-left: 5px; +} + +.heading { + font-weight: bold; +} + +.detail .heading { + cursor: pointer; +} + +.detail > .mark { + cursor: pointer; +} + +ul.arrow { + list-style-image: url(../images/closed.png); +} + +.error { + color: #E15418; +} + +.pie { + display: inline-block; + width: 410px; + height: 150px; +} + +.month_exp { + display: inline-block; + width: 280px; + vertical-align: top; + margin-bottom: 30px; + margin-right: 10px; +} + +/* Header */ + +#header { + width: 760px; + margin: 0 auto; + height: 42px; +} + +#head-wrapper { + margin: 0; + padding: 0; + background: #FFFFFF url(../images/img01.gif) repeat-x left top; +} + +/* Menu */ + +#menu { + float: left; + width: 760px; + height: 50px; + background: url(../images/img02.gif) no-repeat left top; +} + +#menu > ul { + margin: 0; + padding: 0px 0 0 10px; + list-style: none; + line-height: normal; +} + +.menu-item { + display: block; + float: left; +} + +.menu-item > a { + display: block; + float: left; + background: url(../images/img04.gif) no-repeat right 55%; + margin-top: 5px; + margin-right: 3px; + padding: 8px 17px; + text-decoration: none; + font-size: 13px; + color: #000000; +} + +.menu-item > a:hover { + color: #000000; +} + +li.search { + display: block; + float: left; + cursor: pointer; + position: relative; +} + +img.search { + padding: 9px 10px; +} + +form.search { + position: relative; +} + +input.search { + position: absolute; + top: -10px; + left: -30px; + width: 80px; + overflow: hidden; +} + +input.search-submit { + position: absolute; + top: -20px; + z-index: -1; + color: transparent; + border: none; + outline: none; + opacity: 0; +} + +/** LOGO */ + +#logo { + width: 760px; + height: 80px; + margin: 0 auto; +} + +#logo > h1, #logo > h2, #logo > img { + float: left; + margin: 0; + padding: 30px 0 0 0px; + line-height: normal; +} + +#logo > img { + float: left; + margin-right: 15px; + padding: 30px 0 0 0px; +} + +#logo > h1 { + font-family: Georgia, "Times New Roman", Times, serif; + font-size:40px; +} + +#logo > h1 > a { + text-decoration: none; + color: #4C4C4C; +} + +#logo > h1 > a:hover { text-decoration: underline; } + +#logo > h2 { + float: left; + padding: 45px 0 0 18px; + font: 18px Georgia, "Times New Roman", Times, serif; + color: #8BD80E; +} + +/* Page */ + +#page { + width: 710px; + margin: 0 auto; + background: #4C4C4C url(../images/img03.gif) no-repeat left bottom; + padding: 0 25px; +} + +/* Post */ + +#messages { + list-style-type: none; + padding: 0; + margin: 0; +} + +#messages > li { + background: #9e9e9e; + color: #000; + padding: 5px; + margin: 5px 5px 15px 5px; + text-align: center; + box-shadow: 0 0 6px #888; +} + +#messages > li.msg-error { + background: #DA6F6F; +} + +#messages a { + color: #fff; +} + +#content { + float: left; + width: 710px; + padding: 15px 0px; + margin-bottom: 20px; +} + +#page_heading { + margin-bottom: 20px; + padding-bottom: 5px; +} + +#content h2, #content h3 { + margin-bottom: 0.5em; +} + +/* Forms */ + +form input[value], form select { + width: 170px; + padding: 1px; + margin-left: 4px; + box-sizing: border-box; + font-size: 100%; + font-family: inherit; + height: 25px; +} + +form input[type=checkbox] { + width: initial; +} + +form input[type=submit] { + margin-top: 10px; +} + +/* Footer */ +#footer { + margin: 0 auto; + padding: 20px 0 10px 0; + background: #000000; + height: auto; +} + +#footer > p { + font-size: 11px; +} + +#legal { + clear: both; + padding-top: 17px; + text-align: center; + color: #FFFFFF; +} + +#legal > a { + font-weight: normal; + color: #FFFFFF; +} diff --git a/kosten/static/images/add.png b/kosten/static/images/add.png new file mode 100644 index 0000000..b748150 Binary files /dev/null and b/kosten/static/images/add.png differ diff --git a/kosten/static/images/arrow_left.png b/kosten/static/images/arrow_left.png new file mode 100644 index 0000000..de740e7 Binary files /dev/null and b/kosten/static/images/arrow_left.png differ diff --git a/kosten/static/images/arrow_right.png b/kosten/static/images/arrow_right.png new file mode 100644 index 0000000..14922a9 Binary files /dev/null and b/kosten/static/images/arrow_right.png differ diff --git a/kosten/static/images/closed.png b/kosten/static/images/closed.png new file mode 100644 index 0000000..6bba92a Binary files /dev/null and b/kosten/static/images/closed.png differ diff --git a/kosten/static/images/closed.svg b/kosten/static/images/closed.svg new file mode 100644 index 0000000..52ba819 --- /dev/null +++ b/kosten/static/images/closed.svg @@ -0,0 +1,73 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/kosten/static/images/currency.png b/kosten/static/images/currency.png new file mode 100644 index 0000000..f0bee75 Binary files /dev/null and b/kosten/static/images/currency.png differ diff --git a/kosten/static/images/img01.gif b/kosten/static/images/img01.gif new file mode 100644 index 0000000..5f082bd Binary files /dev/null and b/kosten/static/images/img01.gif differ diff --git a/kosten/static/images/img02.gif b/kosten/static/images/img02.gif new file mode 100644 index 0000000..6f7624f Binary files /dev/null and b/kosten/static/images/img02.gif differ diff --git a/kosten/static/images/img03.gif b/kosten/static/images/img03.gif new file mode 100644 index 0000000..137ec06 Binary files /dev/null and b/kosten/static/images/img03.gif differ diff --git a/kosten/static/images/img04.gif b/kosten/static/images/img04.gif new file mode 100644 index 0000000..950c4af Binary files /dev/null and b/kosten/static/images/img04.gif differ diff --git a/kosten/static/images/lupe.png b/kosten/static/images/lupe.png new file mode 100644 index 0000000..55a6e2f Binary files /dev/null and b/kosten/static/images/lupe.png differ diff --git a/kosten/static/images/minus.png b/kosten/static/images/minus.png new file mode 100644 index 0000000..99b21ce Binary files /dev/null and b/kosten/static/images/minus.png differ diff --git a/kosten/static/images/open.png b/kosten/static/images/open.png new file mode 100644 index 0000000..c4d5094 Binary files /dev/null and b/kosten/static/images/open.png differ diff --git a/kosten/static/images/open.svg b/kosten/static/images/open.svg new file mode 100644 index 0000000..3a914e9 --- /dev/null +++ b/kosten/static/images/open.svg @@ -0,0 +1,73 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/kosten/static/images/undo.png b/kosten/static/images/undo.png new file mode 100644 index 0000000..b39d182 Binary files /dev/null and b/kosten/static/images/undo.png differ diff --git a/kosten/static/js/kosten.js b/kosten/static/js/kosten.js new file mode 100644 index 0000000..3821283 --- /dev/null +++ b/kosten/static/js/kosten.js @@ -0,0 +1,280 @@ +"use strict"; + +{ + +jQuery.fn.extend({ + copy: function(){ + return this.clone().removeAttr('id class'); + } + }); + +String.prototype.splitAt = function(pos){ + return [this.slice(0, pos), this.slice(pos)]; +}; + +Date.prototype.format = function(){ + return $.datepicker.formatDate('dd.mm.yy', this); +}; + +let jq = (f) => () => $(f); + +// Call this to localize HighCharts +let setLang = function(){ + Highcharts.setOptions({ + colors: ['#2f7ed8', '#0d233a', '#8bbc21', '#910000', '#1aadce', '#492970', '#f28f43', '#77a1e5', '#c42525', '#a6c96a'], + lang: { + months: ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'], + shortMonths: ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'], + weekdays: ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'], + rangeSelectorFrom: 'von', + rangeSelectorTo: 'bis', + rangeSelectorZoom: null, + numericSymbols: null + } + }); +}; + +let extendDate = function(input) { + const re = /^([12][0-9]|3[01]|0?[1-9])(?:(0?[1-9]|1[0-2])((?:20)?[0-9][0-9])?)?$/; + let match, day, month, year; + + if (match = re.exec(input.val())) { + const now = new Date(); + [ , day, month, year] = match; + + if (year == null) year = now.getFullYear(); + if (month == null) month = now.getMonth() + 1; + + if (year <= 99) year = "20" + year; + + const date = new Date(year, month - 1, day).format(); + + input.val(date); + } +}; + +let searchController = function() { + $('form.search').hide(); + $('li.search').click(() => { + const search = $('form.search'); + search.toggle(); + if (search.is(':visible')) $('input.search').focus(); + }); + + $('input.search').focusout(() => $('form.search').hide()); +}; + +$(searchController); + +var showJS = jq(() => { + setLang(); + + $(".detail .heading").click(function() { + $(this).closest('.detail').children('.mark:first').click(); + }); + + $(".detail > .mark").click(function() { + if (this.src.indexOf('closed') !== -1) { + this.src = this.src.replace('closed', 'open'); + } else { + this.src = this.src.replace('open', 'closed'); + } + $(this).nextAll('.details:first').toggle(); + }); + + $('.details').hide(); + + // draw the pies + $('.pie').each(function() { + const pie = $(this); + const piedata = Object.entries(pie.data('pie')).map(([key, value]) => ( + { + name: value > 0 ? key : '', + y: value, + visible: value > 0 + })); + + pie.highcharts({ + title: { + text: null + }, + tooltip: { + hideDelay: 200, + formatter: function() { + return `${this.key}: ${this.y.toFixed(2)}€ / ${this.percentage.toFixed(2)}%`; + } + }, + chart: { + backgroundColor: null, + plotBorderWidth: null, + plotShadow: false, + spacingTop: 0 + }, + credits: { + enabled: false + }, + series: [{ + type: 'pie', + size: '70%', + states: { + hover: { + halo: null + } + }, + allowPointSelect: true, + dataLabels: { + color: pie.css('color'), + distance: 20 + }, + data: piedata + }] + }); + }); +}); + +var statJS = jq(() => { + setLang(); + + const df = Highcharts.dateFormat; + const month = 30 * 24 * 60 * 60 * 1000; + + const constDialog = function() { + const time = this.x; + + $.get(df('/stats/_const/%Y/%m', time), + data => $(data).dialog({ title: df('%B %Y', time) }) + ); + }; + + $('.stats').each(function (){ + const stats = $(this); + stats.highcharts('StockChart', { + credits: { + enabled: false + }, + rangeSelector: { + buttons: [], + inputDateFormat: "%b %Y", + inputEditDateFormat: "%m.%Y", + inputDateParser: value => { + value = value.split(/\./); + return Date.UTC( + value[1], // year + value[0] - 1, // month ... 0-based -.- + 1, // day + 0, 0, 0, 0 // time + ); + } + }, + plotOptions: { + series: { + stacking: 'normal', + marker: { + enabled: false, + radius: 2 + } + } + }, + chart: { + events: { + click: function() { + for (let point of this.series[0].data) { + if (point.state) { + // constDialog is used as a normal callback later on, so has to use 'this' + constDialog.apply(point); + break; + } + } + } + } + }, + xAxis: { + minTickInterval: month, + minRange: month + }, + yAxis: { + reversedStacks: false, + labels: { + x: 5, + align: 'left' + } + }, + series: [ + { + data: stats.data('consts'), + step: 'left', + name: 'Konstant', + point: { + events: { + click: constDialog + } + } + }, { + data: stats.data('expenses'), + name: 'Variabel', + step: 'left' + } + ], + tooltip: { + formatter: function(){ + const header = `${df('%B %Y', this.x)}
`; + const body = this.points.map(p => `${p.series.name}: ${p.point.y} €
`).join(''); + const footer = `Summe: ${this.points[0].total}`; + return header + body + footer; + } + } + }); + }); +}); + +var catsJS = jq(() => { + let counter = 0; + const addImg = $('img#add'); + const newInput = $('input#new'); + const newImage = newName => { + const copy = addImg.copy(); + copy.attr('src', function() { + return this.src.replace('add', newName); + }); + return copy; + }; + + $("li > span").click(function() { + const span = $(this); + const input = span.next(); + const img = newImage('undo'); + + img.click(function() { + $(this).remove(); + input.val(span.text()); + input.fadeOut('slow', () => span.toggle() ); + }); + + span.toggle(); + input.fadeIn('slow', () => img.insertAfter(input)); + }); + + addImg.click(() => { + const img = newImage('minus'); + img.click(function() { + $(this).parent().fadeOut('slow', function() { + $(this).remove(); + }); + }); + + const input = newInput.copy(); + console.log(input.focus); + input + .attr('name', function(){ return this.name + counter; }) + .removeAttr('style') + .wrap("
  • ") + .parent() + .append(img) + .hide() + .insertBefore(addImg.parent()) + .fadeIn('slow', () => input.focus() ); + + counter++; + }); +}); +} diff --git a/kosten/static/js/lib/highstock-4.2.2.js b/kosten/static/js/lib/highstock-4.2.2.js new file mode 100644 index 0000000..ede6605 --- /dev/null +++ b/kosten/static/js/lib/highstock-4.2.2.js @@ -0,0 +1,431 @@ +/* + Highstock JS v4.2.2 (2016-02-04) + + (c) 2009-2016 Torstein Honsi + + License: www.highcharts.com/license +*/ +(function(J,ea){typeof module==="object"&&module.exports?module.exports=J.document?ea(J):ea:J.Highcharts=ea(J)})(typeof window!=="undefined"?window:this,function(J){function ea(a,b){var c="Highcharts error #"+a+": www.highcharts.com/errors/"+a;if(b)throw Error(c);J.console&&console.log(c)}function yb(a,b,c){this.options=b;this.elem=a;this.prop=c}function C(){var a,b=arguments,c,d={},e=function(a,b){var c,d;typeof a!=="object"&&(a={});for(d in b)b.hasOwnProperty(d)&&(c=b[d],a[d]=c&&typeof c==="object"&& +Object.prototype.toString.call(c)!=="[object Array]"&&d!=="renderTo"&&typeof c.nodeType!=="number"?e(a[d]||{},c):b[d]);return a};b[0]===!0&&(d=b[1],b=Array.prototype.slice.call(b,2));c=b.length;for(a=0;a-1?h.thousandsSep:""))):e=la(f,e)}j.push(e);a=a.slice(c+1);c=(d=!d)?"}":"{"}j.push(a);return j.join("")}function Ab(a){return X.pow(10,U(X.log(a)/X.LN10))}function Bb(a,b,c,d,e){var f,g=a,c=p(c,1);f=a/c;b||(b=[1,2,2.5,5,10],d===!1&&(c===1?b=[1,2,5,10]:c<=0.1&&(b=[1/c])));for(d=0;d=a||!e&&f<=(b[d]+(b[d+ +1]||b[d]))/2)break;g*=c;return g}function pb(a,b){var c=a.length,d,e;for(e=0;ec&&(c=a[b]);return c}function Qa(a,b){for(var c in a)a[c]&&a[c]!==b&&a[c].destroy&&a[c].destroy(),delete a[c]}function Xa(a){qb||(qb=ca(Ya));a&&qb.appendChild(a);qb.innerHTML=""}function ma(a, +b){return parseFloat(a.toPrecision(b||14))}function cb(a,b){b.renderer.globalAnimation=p(a,b.animation)}function Pb(){var a=O.global,b=a.useUTC,c=b?"getUTC":"get",d=b?"setUTC":"set";da=a.Date||J.Date;zb=b&&a.timezoneOffset;hb=b&&a.getTimezoneOffset;rb=function(a,c,d,h,i,j){var k;b?(k=da.UTC.apply(0,arguments),k+=gb(k)):k=(new da(a,c,p(d,1),p(h,0),p(i,0),p(j,0))).getTime();return k};Cb=c+"Minutes";Db=c+"Hours";Eb=c+"Day";ib=c+"Date";jb=c+"Month";kb=c+"FullYear";Qb=d+"Milliseconds";Rb=d+"Seconds";Sb= +d+"Minutes";Tb=d+"Hours";Fb=d+"Date";Gb=d+"Month";Hb=d+"FullYear"}function va(a){if(!(this instanceof va))return new va(a);this.init(a)}function Y(){}function db(a,b,c,d){this.axis=a;this.pos=b;this.type=c||"";this.isNew=!0;!c&&!d&&this.addLabel()}function Ub(a,b,c,d,e){var f=a.chart.inverted;this.axis=a;this.isNegative=c;this.options=b;this.x=d;this.total=null;this.points={};this.stack=e;this.rightCliff=this.leftCliff=0;this.alignOptions={align:b.align||(f?c?"left":"right":"center"),verticalAlign:b.verticalAlign|| +(f?"middle":c?"bottom":"top"),y:p(b.y,f?4:c?14:-6),x:p(b.x,f?c?-6:6:0)};this.textAlign=b.textAlign||(f?c?"right":"left":"center")}function Ib(a){var b=a.options,c=b.navigator,d=c.enabled,b=b.scrollbar,e=b.enabled,f=d?c.height:0,g=e?b.height:0;this.handles=[];this.scrollbarButtons=[];this.elementsToDestroy=[];this.chart=a;this.setBaseSeries();this.height=f;this.scrollbarHeight=g;this.scrollbarEnabled=e;this.navigatorEnabled=d;this.navigatorOptions=c;this.scrollbarOptions=b;this.outlineHeight=f+g;this.i