diff --git a/Dockerfile b/Dockerfile index 96c10bd..20f3c37 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,14 @@ FROM debian8_python3 -COPY app/requirements.txt /requirements.txt +COPY requirements.txt /requirements.txt RUN pip3 install -r /requirements.txt; \ mkdir /data ADD app /app +ADD main.py /main.py +ADD config.py /config.py VOLUME ["/data"] VOLUME ["/app/config] EXPOSE 5000 -ENTRYPOINT python3 /app/main.py +ENTRYPOINT python3 /main.py diff --git a/README.md b/README.md index bdd6bb8..afe2bdf 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,11 @@ operation. ## start database `docker run --name pitstops_db -e MYSQL_ROOT_PASSWORD=$SOMESECUREPASSWORD$ -e MYSQL_DATABASE=pitstops -d mysql:latest` +## Database migrations +### From *Cathrine* to *Master*: + +`ALTER TABLE pitstop ADD COLUMN costs DECIMAL(5,2) NOT NULL DEFAULT 0.0 AFTER vehicle_id;` + ## run in development Include the development version of the code as volume, so the app gets @@ -21,8 +26,7 @@ reloaded automatically. The sqlite file will be stored in *tmp* so it can be inspected with tools like *sqlite3*. The switch *DEBUG* enables debugging during development. -`docker run --rm --name rollerverbrauch -ti -v $PWD/app:/app -v $PWD/../rollerverbrauch_config:/app/config -v /tmp/pitstops/:/data -e DEBUG=True -p 5000:5000 rollerverbrauch` - +`docker run --rm --name rollerverbrauch -ti -v $PWD/app:/app --link pitstops_db:database -p 5000:5000 -e SECURITY_PASSWORD_SALT=XXX -e SECRET_KEY=XXX -e MAIL_SERVER=XXX -e MAIL_USERNAME=XXX -e MAIL_PASSWORD=XXX rollerverbrauch` ## run in production `docker run --name pitstops -d -v /data/pitstops/:/data -v /configs/pitstops/:/app/config -p 80:5000 rollerverbrauch` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..18c3a92 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,486 @@ +from datetime import date + +from flask import Flask +from flask import redirect, g +from flask import render_template +from flask import url_for +from flask_mail import Mail +from flask_security import Security, SQLAlchemyUserDatastore, \ + login_required, roles_required, user_registered +from flask_security.core import current_user +from flask_security.forms import LoginForm +from flask_sqlalchemy import SQLAlchemy +import os +from config import config +from sqlalchemy.exc import IntegrityError +from flask.ext.security.forms import LoginForm + +from .forms import \ + CreatePitstopForm, \ + EditVehicleForm, \ + DeleteVehicleForm, \ + SelectVehicleForm, \ + DeleteAccountForm, \ + DeletePitStopForm, \ + EditPitstopForm, \ + CreateConsumableForm, \ + EditConsumableForm, \ + DeletConsumableForm, \ + SelectConsumableForm + + +app = Flask(__name__) +app.config.from_object(config[os.getenv('FLASK_CONFIG') or 'default']) + +db = SQLAlchemy(app) +mail = Mail(app) + +from .entities import \ + User, \ + Role, \ + Pitstop, \ + Vehicle, \ + Consumable + +# required to activate the filters +from .filters import * +from .tools import * + + +user_datastore = SQLAlchemyUserDatastore(db, User, Role) +security = Security(app, user_datastore) + + +@user_registered.connect_via(app) +def user_registered_sighandler(app, user, confirm_token): + """ + Called after a user was created + """ + role = user_datastore.find_role('user') + user_datastore.add_role_to_user(user, role) + new_vehicle = Vehicle('default vehicle') + db.session.add(new_vehicle) + user.vehicles.append(new_vehicle) + db.session.commit() + tools.db_log_add(user) + tools.db_log_add(new_vehicle) + + +@app.before_first_request +def before_first_request(): + db.create_all() + user_datastore.find_or_create_role(name='admin', description='Role for administrators') + user_datastore.find_or_create_role(name='user', description='Role for all users.') + db.session.commit() + + +@app.before_request +def before_request(): + g.data = {} + + +@app.route('/') +def index(): + if current_user.is_authenticated: + return redirect(url_for('get_pit_stops')) + else: + user_count = len(User.query.all()) + consumables = Consumable.query.all() + per_consumable = {} + for consumable in consumables: + per_consumable[consumable.id] = { + 'name': consumable.name, + 'unit': consumable.unit, + 'amount': 0 + } + vehicles = Vehicle.query.all() + kilometers = 0 + for vehicle in vehicles: + stats = tools.VehicleStats(vehicle) + for consumable in stats.consumables: + per_consumable[consumable.id]['amount'] += consumable.overall_amount + kilometers += stats.overall_distance + vehicle_count = len(vehicles) + pitstop_count = len(Pitstop.query.all()) + data = { + 'users': user_count, + 'vehicles': vehicle_count, + 'pitstops': pitstop_count, + 'kilometers': kilometers, + 'consumables': per_consumable + } + + return render_template('index.html', login_user_form=LoginForm(), data=data) + + +@app.route('/account/vehicle/edit/', methods=['GET', 'POST']) +@login_required +def edit_vehicle(vid): + vehicle = Vehicle.query.filter(Vehicle.id == vid).first() + + # prevent edit of foreign vehicles + if vehicle not in current_user.vehicles: + return redirect(url_for('get_account_page')) + + form = EditVehicleForm() + form.consumables.choices = [(g.id, g.name) for g in Consumable.query.all()] + + if not form.consumables.data: + form.consumables.default = [g.id for g in vehicle.consumables] + + if form.name.data is not None: + form.name.default = form.name.data + + if form.validate_on_submit(): + vehicle.name = form.name.data + # we cannot delete consumables where there are pitstops for => report error + vehicle.consumables = [] + for consumable_id in form.consumables.data: + consumable = Consumable.query.get(consumable_id) + if consumable is not None: + vehicle.consumables.append(consumable) + try: + db.session.commit() + tools.db_log_update(vehicle) + except IntegrityError: + db.session.rollback() + form.name.errors.append('"%s" is not unique.' % (form.name.data)) + return render_template('editVehicleForm.html', form=form) + return redirect(url_for('get_account_page')) + + form.name.default = vehicle.name + form.process() + return render_template('editVehicleForm.html', form=form, vehicle=vehicle) + + +@app.route('/account/vehicle/delete/', methods=['GET', 'POST']) +@login_required +def delete_vehicle(vid): + vehicle = Vehicle.query.filter(Vehicle.id == vid).first() + + # prevent deletion of foreign vehicles + if vehicle not in current_user.vehicles: + return redirect(url_for('get_account_page')) + + if len(current_user.vehicles) == 1: + return redirect(url_for('get_account_page')) + + form = DeleteVehicleForm() + + if form.validate_on_submit(): + db.session.delete(vehicle) + db.session.commit() + tools.db_log_delete(vehicle) + return redirect(url_for('get_account_page')) + + return render_template('deleteVehicleForm.html', form=form, vehicle=vehicle) + + +@app.route('/account/vehicle/create', methods=['GET', 'POST']) +@login_required +def create_vehicle(): + form = EditVehicleForm() + form.consumables.choices = [(g.id, g.name) for g in Consumable.query.all()] + + if form.name.data is not None: + form.name.default = form.name.data + + if form.consumables.data: + form.consumables.default = form.consumables.data + + if form.validate_on_submit(): + if len(form.consumables.data) == 0: + form.consumables.errors.append('At least one consumable must be selected.') + return render_template('createVehicleForm.html', form=form) + + vehicle_name = form.name.data + new_vehicle = Vehicle(vehicle_name) + for consumable_id in form.consumables.data: + consumable = Consumable.query.get(consumable_id) + if consumable is not None: + new_vehicle.consumables.append(consumable) + db.session.add(new_vehicle) + current_user.vehicles.append(new_vehicle) + try: + db.session.commit() + tools.db_log_add(new_vehicle) + except IntegrityError: + db.session.rollback() + form.name.errors.append('"%s" is not unique.' % (form.name.data)) + return render_template('createVehicleForm.html', form=form) + return redirect(url_for('get_account_page')) + + return render_template('createVehicleForm.html', form=form) + + +@app.route('/pitstops/vehicle/select', methods=['GET', 'POST']) +@login_required +def select_vehicle_for_new_pitstop(): + if len(current_user.vehicles) == 1: + return redirect(url_for('select_consumable_for_new_pitstop', vid=current_user.vehicles[0].id)) + + form = SelectVehicleForm() + form.vehicle.choices = [(g.id, g.name) for g in current_user.vehicles] + + if form.validate_on_submit(): + return redirect(url_for('select_consumable_for_new_pitstop', vid=form.vehicle.data)) + + return render_template('selectVehicle.html', form=form) + + +@app.route('/pitstops/vehicle//consumable/select', methods=['GET', 'POST']) +@login_required +def select_consumable_for_new_pitstop(vid): + vehicle = Vehicle.query.get(vid) + if vehicle is None or vehicle not in current_user.vehicles: + return redirect(url_for('select_vehicle_for_new_pitstop')) + + if len(vehicle.consumables) == 1: + return redirect(url_for('create_pit_stop_form', vid=vid, cid=vehicle.consumables[0].id)) + + form = SelectConsumableForm() + form.consumable.choices = [(g.id, g.name) for g in vehicle.consumables] + + if form.validate_on_submit(): + return redirect(url_for('create_pit_stop_form', vid=vid, cid=form.consumable.data)) + + return render_template('selectConsumableForVehicle.html', vehicle=vehicle, form=form) + + +@app.route('/pitstops/vehicle//consumable//create', methods=['GET', 'POST']) +@login_required +def create_pit_stop_form(vid, cid): + vehicle = Vehicle.query.get(vid) + if vehicle is None or vehicle not in current_user.vehicles: + return redirect(url_for('select_vehicle_for_new_pitstop')) + + consumable = Consumable.query.get(cid) + if consumable not in vehicle.consumables: + return redirect(url_for('select_consumable_for_new_pitstop', vid=vid)) + + form = CreatePitstopForm() + + # the last pitstop is required to be able to check the monotonicy of date and odometer + last_pitstop = tools.get_latest_pitstop_for_vehicle(vid) + last_pitstop_consumable = tools.get_latest_pitstop_for_vehicle_and_consumable(vid, cid) + + # we can enter the same odometer if the pitstops are not equal + form.same_odometer_allowed = (last_pitstop != last_pitstop_consumable) + + # set the lower limits for odometer andd date and the values for amount and costs of the last stop + form.set_pitstop(tools.compute_lower_limits_for_new_pitstop(last_pitstop, last_pitstop_consumable, cid)) + + # set the label of the litres field to make the user comfortable + form.set_consumable(consumable) + + # preinitialize the defaults with potentially existing values from a try before + form.preinit_with_data() + + # + # Validate should accept same odometer on different consumables + # + if form.validate_on_submit(): + new_stop = Pitstop(form.odometer.data, form.litres.data, form.date.data, form.costs.data, cid) + db.session.add(new_stop) + vehicle.pitstops.append(new_stop) + try: + db.session.commit() + tools.db_log_add(new_stop) + except IntegrityError: + db.session.rollback() + form.odometer.errors.append('Pitstop already present for %s at odometer %s km!' % (consumable.name, form.odometer.data)) + return render_template('createPitStopForm.html', form=form, vehicle=vehicle, messages=form.get_hint_messages()) + return redirect(url_for('get_pit_stops', _anchor= 'v' + str(vehicle.id))) + + form.process() + return render_template('createPitStopForm.html', form=form, vehicle=vehicle, messages=form.get_hint_messages()) + + +@app.route('/pitstops/delete/', methods=['GET', 'POST']) +@login_required +def delete_pit_stop_form(pid): + pitstop = Pitstop.query.filter(Pitstop.id == pid).first() + if pitstop is None: + return redirect(url_for('get_pit_stops')) + vehicle = Vehicle.query.filter(Vehicle.id == pitstop.vehicle_id).first() + if vehicle not in current_user.vehicles: + return redirect(url_for('get_pit_stops')) + + form = DeletePitStopForm() + if form.validate_on_submit(): + db.session.delete(pitstop) + db.session.commit() + tools.db_log_delete(pitstop) + return redirect(url_for('get_pit_stops', _anchor='v' + str(vehicle.id))) + + return render_template('deletePitstopForm.html', form=form, pitstop=pitstop ) + + +@app.route('/pitstops/edit/', methods=['GET', 'POST']) +@login_required +def edit_pit_stop_form(pid): + edit_pitstop = Pitstop.query.get(pid).first() + if edit_pitstop is None: + return redirect(url_for('get_pit_stops')) + + vehicle = Vehicle.query.filter(Vehicle.id == edit_pitstop.vehicle_id).first() + if vehicle not in current_user.vehicles: + return redirect(url_for('get_pit_stops')) + + last_pitstop_pos = vehicle.pitstops.index(edit_pitstop) - 1 + if last_pitstop_pos > 0: + last_pitstop = vehicle.pitstops[last_pitstop_pos] + else: + last_pitstop = Pitstop(0, 0, date(1970, 1, 1), 0, 0) + + form = EditPitstopForm() + form.set_pitstop(last_pitstop) + + if form.validate_on_submit(): + edit_pitstop.costs = form.costs.data + edit_pitstop.date = form.date.data + edit_pitstop.litres = form.litres.data + edit_pitstop.odometer = form.odometer.data + db.session.commit() + tools.db_log_update(edit_pitstop) + return redirect(url_for('get_pit_stops', _anchor='v' + str(vehicle.id))) + + form.odometer.default = edit_pitstop.odometer + form.litres.default = edit_pitstop.litres + form.date.default = edit_pitstop.date + form.costs.default = edit_pitstop.costs + form.process() + messages = { + 'date': 'Date must be between %s and %s (including).' % (str(last_pitstop.date), str(date.today())), + 'odometer': 'Odometer must be greater than %s km.' % (str(last_pitstop.odometer)) + } + if edit_pitstop.costs > 0: + messages['costs'] = 'Costs must be higher than 0.01 €.' + return render_template('editPitStopForm.html', form=form, vehicle=vehicle, messages=messages) + + +@app.route('/pitstops', methods=['GET']) +@login_required +def get_pit_stops(): + return render_template('pitstops.html', user=current_user) + + +@app.route('/manual', methods=['GET']) +@login_required +def get_manual(): + return render_template('manual.html', data=g.data) + + +@app.route('/admin', methods=['GET']) +@roles_required('admin') +def get_admin_page(): + users = User.query.all() + consumables = Consumable.query.all() + for consumable in consumables: + consumable.in_use = len(consumable.vehicles) > 0 + return render_template('admin.html', users=users, consumables=consumables) + + +@app.route('/admin/consumable/create', methods=['GET', 'POST']) +@login_required +def create_consumable(): + form = CreateConsumableForm() + + # preinitialize the defaults with potentially existing values from a try before + if form.name.data is not None: + form.name.default = form.name.data + if form.unit.data is not None: + form.unit.default = form.unit.data + + if form.validate_on_submit(): + new_consumable = Consumable(form.name.data, form.unit.data) + db.session.add(new_consumable) + try: + db.session.commit() + tools.db_log_add(new_consumable) + except IntegrityError: + db.session.rollback() + form.name.errors.append('"%s" is not unique.' % (form.name.data)) + return render_template('createConsumableForm.html', form=form) + return redirect(url_for('get_admin_page')) + + return render_template('createConsumableForm.html', form=form) + + +@app.route('/admin/consumable/delete/', methods=['GET', 'POST']) +@login_required +def delete_consumable(cid): + consumable = Consumable.query.filter(Consumable.id == cid).first() + if consumable is None: + return redirect(url_for('get_admin_page')) + + form = DeletConsumableForm() + + if form.validate_on_submit(): + db.session.delete(consumable) + db.session.commit() + tools.db_log_delete(consumable) + return redirect(url_for('get_admin_page')) + + return render_template('deleteConsumableForm.html', form=form, consumable=consumable) + + +@app.route('/admin/consumable/edit/', methods=['GET', 'POST']) +@login_required +def edit_consumable(cid): + consumable = Consumable.query.filter(Consumable.id == cid).first() + if consumable is None: + return redirect(url_for('get_admin_page')) + + form = EditConsumableForm() + + form.name.default = consumable.name + form.unit.default = consumable.unit + + # preinitialize the defaults with potentially existing values from a try before + if form.name.data is not None: + form.name.default = form.name.data + if form.unit.data is not None: + form.unit.default = form.unit.data + + if form.validate_on_submit(): + consumable.name = form.name.data + consumable.unit = form.unit.data + try: + db.session.commit() + tools.db_log_update(consumable) + except IntegrityError: + db.session.rollback() + form.name.errors.append('"%s" is not unique.' % (form.name.data)) + return render_template('editConsumableForm.html', form=form) + return redirect(url_for('get_admin_page')) + + return render_template('editConsumableForm.html', form=form) + + +@app.route('/account', methods=['GET']) +@login_required +def get_account_page(): + return render_template('account.html', data=g.data) + + +@app.route('/statistics', methods=['GET']) +@login_required +def get_statistics(): + stats = [] + for vehicle in current_user.vehicles: + stats.append(tools.VehicleStats(vehicle)) + return render_template('statistics.html', data=stats) + + +@app.route('/account/delete', methods=['GET', 'POST']) +@login_required +def delete_account(): + form = DeleteAccountForm() + + if form.validate_on_submit(): + user_datastore.delete_user(current_user) + db.session.commit() + return redirect(url_for('index')) + + return render_template('deleteAccountForm.html', form=form) diff --git a/app/rollerverbrauch/config/config.py.example b/app/config/config.py.example similarity index 100% rename from app/rollerverbrauch/config/config.py.example rename to app/config/config.py.example diff --git a/app/entities.py b/app/entities.py new file mode 100644 index 0000000..5e06001 --- /dev/null +++ b/app/entities.py @@ -0,0 +1,142 @@ +from app import db +from flask_security import UserMixin, RoleMixin + +roles_users = db.Table('roles_users', + db.Column('user_id', db.Integer(), db.ForeignKey('user.id')), + db.Column('role_id', db.Integer(), db.ForeignKey('role.id'))) + +vehicles_consumables = db.Table('vehicles_consumables', + db.Column('vehicle_id', db.Integer(), db.ForeignKey('vehicle.id')), + db.Column('consumable_id', db.Integer(), db.ForeignKey('consumable.id'))) + + +class Role(db.Model, RoleMixin): + """ + Entity to handle different roles for users: Typically user and admin exist + """ + id = db.Column(db.Integer(), primary_key=True) + name = db.Column(db.String(80), unique=True) + description = db.Column(db.String(255)) + + def __str__(self): + return self.name + + def __hash__(self): + return hash(self.name) + + +class User(db.Model, UserMixin): + """ + Entity to represent a user including login data and links to roles and vehicles. + """ + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(255), unique=True) + password = db.Column(db.String(255)) + active = db.Column(db.Boolean()) + confirmed_at = db.Column(db.DateTime()) + + vehicles = db.relationship( + 'Vehicle' + ) + roles = db.relationship( + 'Role', + secondary=roles_users, + backref=db.backref('users', lazy='dynamic') + ) + + def __repr__(self): + return '' % (self.id, self.owner_id, self.name) + + +class Pitstop(db.Model): + """ + Entity to represent a pitstop for a single consumable. + Attributes: + * the date of the pitstop + * the odometer of the pitstop + * the id of the fuelled consumable + * amount of consumable used + * the costs of the consumable + * the id of the vehicle that was refuelled + """ + id = db.Column(db.Integer, primary_key=True) + date = db.Column(db.Date) + odometer = db.Column(db.Integer) + consumable_id = db.Column(db.Integer, db.ForeignKey('consumable.id')) + amount = db.Column(db.Numeric(5, 2)) + costs = db.Column(db.Numeric(5, 2), default=0) + vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicle.id')) + # short cut to access the fuelled consumable of the pitstop + consumable = db.relationship('Consumable') + # this uniqueness constraint makes sure that for each consumable and each vehicle only one pitstop exists at the + # same odometer + __table_args__ = (db.UniqueConstraint('odometer', + 'consumable_id', + 'vehicle_id', + name='_odometer_consumable_vehicle_uniq'),) + + def __init__(self, odometer, amount, date, costs, consumable_id): + self.odometer = odometer + self.amount = amount + self.date = date + self.costs = costs + self.consumable_id = consumable_id + + def __repr__(self): + return '' % \ + (self.odometer, self.amount, self.date, self.vehicle_id, self.consumable_id) + + +class Consumable(db.Model): + """ + Entity to represent a material that be consumed by a vehilce. + Attributes: + * name (must be globally unique) + * unit + """ + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(255), unique=True) + unit = db.Column(db.String(255)) + + vehicles = db.relationship( + 'Vehicle', + secondary=vehicles_consumables + ) + + def __init__(self, name, unit): + self.name = name + self.unit = unit + + def __repr__(self): + return '' % (self.name, self.unit) diff --git a/app/rollerverbrauch/filters.py b/app/filters.py similarity index 91% rename from app/rollerverbrauch/filters.py rename to app/filters.py index f3ce080..6aadde6 100644 --- a/app/rollerverbrauch/filters.py +++ b/app/filters.py @@ -1,4 +1,4 @@ -from rollerverbrauch import app +from app import app import hashlib diff --git a/app/forms.py b/app/forms.py new file mode 100644 index 0000000..f884643 --- /dev/null +++ b/app/forms.py @@ -0,0 +1,169 @@ +from flask_wtf import Form +from wtforms import DateField, IntegerField, DecimalField, StringField, SelectField, SubmitField, SelectMultipleField, BooleanField +from wtforms.validators import ValidationError, Length +from datetime import date + + +def date_check(form, field): + """ + Checks that the date of the pitstop is not before the date of the latest pitstop and not after today. + + :param form: the form where the field is in + :param field: the field to check + :return: Nothing or a ValidationError if the limits are not kept + """ + if field.data < form.last_pitstop.date: + raise ValidationError('The new date must not be before %s' % form.last_pitstop.date) + if field.data > date.today(): + raise ValidationError('The new date must not be after %s' % date.today()) + + +def odometer_check(form, field): + """ + + :param form: + :param field: + :return: + """ + if not form.same_odometer_allowed and field.data <= form.last_pitstop.odometer: + raise ValidationError('The new odometer value must be higher than %i km' % form.last_pitstop.odometer) + if form.same_odometer_allowed and field.data < form.last_pitstop.odometer: + raise ValidationError('The new odometer value must be higher than %i km' % form.last_pitstop.odometer) + + +def litres_check(form, field): + if field.data is not None and field.data <= 0: + raise ValidationError('You must fuel at least 0.1 l') + + +def costs_check(form, field): + if field.data is not None and field.data <= 0: + raise ValidationError('Costs must be above 0.01 €.') + + +def edit_costs_check(form, field): + """ + Costs must be given, if a default value was given to the form field. + :param form: + :param field: + :return: + """ + costs_check_required = (form.costs.default is not None and form.costs.default > 0) + if costs_check_required and field.data is not None and field.data <= 0: + raise ValidationError('Costs must be above 0.01 €.') + + +class SelectVehicleForm(Form): + vehicle = SelectField('Vehicle', coerce=int) + submit = SubmitField(label='Do it!') + + +class SelectConsumableForm(Form): + consumable = SelectField('Consumable', coerce=int) + submit = SubmitField(label='Do it!') + + +class CreatePitstopForm(Form): + date = DateField('Date of Pitstop', validators=[date_check]) + odometer = IntegerField('Odometer (km)', validators=[odometer_check]) + litres = DecimalField('Litres (l)', places=2, validators=[litres_check]) + costs = DecimalField('Costs (€, overall)', places=2, validators=[costs_check]) + submit = SubmitField(label='Do it!') + last_pitstop = None + same_odometer_allowed = True + + def set_pitstop(self, last_pitstop): + self.last_pitstop = last_pitstop + + def set_consumable(self, consumable): + self.litres.label = '%s (%s)' % (consumable.name, consumable.unit) + + def preinit_with_data(self): + if self.date.data: + self.date.default = self.date.data + else: + self.date.default = self.last_pitstop.date + if self.odometer.data: + self.odometer.default = self.odometer.data + else: + self.odometer.default = self.last_pitstop.odometer + if self.litres.data: + self.litres.default = self.litres.data + else: + self.litres.default = self.last_pitstop.amount + if self.costs.data: + self.costs.default = self.costs.data + else: + self.costs.default = self.last_pitstop.costs + + def get_hint_messages(self): + if self.same_odometer_allowed: + or_equal = ' or equal to' + else: + or_equal = '' + messages = { + 'date': 'Date must be between %s and %s (including).' % (str(self.last_pitstop.date), str(date.today())), + 'odometer': 'Odometer must be greater than%s %s km.' % (or_equal, str(self.last_pitstop.odometer)), + 'costs': 'Costs must be higher than 0.01 €.' + } + return messages + + +class EditVehicleForm(Form): + name = StringField('Name', validators=[Length(1, 255)]) + consumables = SelectMultipleField('Consumables', coerce=int,validators=[]) + submit = SubmitField(label='Do it!') + + +class DeleteVehicleForm(Form): + submit = SubmitField(label='Do it!') + + +class DeleteAccountForm(Form): + submit = SubmitField(label='Really delete my account!') + + +class DeletePitStopForm(Form): + submit = SubmitField(label='Really delete this pitstop!') + + +class EditPitstopForm(Form): + date = DateField('Date of Pitstop', validators=[date_check]) + odometer = IntegerField('Odometer (km)', validators=[odometer_check]) + litres = DecimalField('Litres (l)', places=2, validators=[litres_check]) + costs = DecimalField('Costs (€, overall)', places=2, validators=[edit_costs_check]) + submit = SubmitField(label='Update it!') + last_pitstop = None + same_odometer_allowed = True + + def set_pitstop(self, last_pitstop): + self.last_pitstop = last_pitstop + + def set_consumable(self, consumable): + self.litres.label = '%s (%s)' % (consumable.name, consumable.unit) + + def preinit_with_data(self): + if self.date.data: + self.date.default = self.date.data + if self.odometer.data: + self.odometer.default = self.odometer.data + if self.litres.data: + self.litres.default = self.litres.data + if self.costs.data: + self.costs.default = self.costs.data + + +class CreateConsumableForm(Form): + name = StringField('Name', validators=[Length(1, 255)]) + unit = StringField('Unit', validators=[Length(1, 255)]) + submit = SubmitField(label='Do it!') + + +class EditConsumableForm(Form): + name = StringField('Name', validators=[Length(1, 255)]) + unit = StringField('Unit', validators=[Length(1, 255)]) + submit = SubmitField(label='Do it!') + + +class DeletConsumableForm(Form): + submit = SubmitField(label='Do it!') diff --git a/app/rollerverbrauch/__init__.py b/app/rollerverbrauch/__init__.py deleted file mode 100644 index 7da899b..0000000 --- a/app/rollerverbrauch/__init__.py +++ /dev/null @@ -1,253 +0,0 @@ -from datetime import date -from flask import Flask -from flask import redirect, g -from flask import render_template -from flask import url_for -from flask.ext.mail import Mail -from flask.ext.security import Security, SQLAlchemyUserDatastore, \ - UserMixin, RoleMixin, login_required, roles_required -from flask.ext.security import user_registered -from flask_security.core import current_user -from flask_sqlalchemy import SQLAlchemy -from flask.ext.security.forms import LoginForm - -app = Flask(__name__) -app.config['SECURITY_PASSWORD_HASH'] = 'pbkdf2_sha512' -app.config['SECURITY_REGISTERABLE'] = True -app.config['SECURITY_CHANGEABLE'] = True -app.config['SECURITY_RECOVERABLE'] = True -app.config.from_envvar('config') -app.config.from_object(__name__) - -db = SQLAlchemy(app) -mail = Mail(app) - -import rollerverbrauch.tools as tools - -from rollerverbrauch.forms import \ - CreatePitstopForm, \ - EditVehicleForm, \ - DeleteVehicleForm, \ - SelectVehicleForm, \ - DeleteAccountForm - -from rollerverbrauch.entities import \ - User, \ - Role, \ - Pitstop, \ - Vehicle - -# required to activate the filters -import rollerverbrauch.filters - - -user_datastore = SQLAlchemyUserDatastore(db, User, Role) -security = Security(app, user_datastore) - - -@user_registered.connect_via(app) -def user_registered_sighandler(app, user, confirm_token): - """ - Called after a user was created - """ - role = user_datastore.find_role('user') - user_datastore.add_role_to_user(user, role) - new_vehicle = Vehicle('default vehicle') - db.session.add(new_vehicle) - user.vehicles.append(new_vehicle) - db.session.commit() - tools.db_log_add(user) - tools.db_log_add(new_vehicle) - - -@app.before_first_request -def before_first_request(): - db.create_all() - user_datastore.find_or_create_role(name='admin', description='Role for administrators') - user_datastore.find_or_create_role(name='user', description='Role for all users.') - db.session.commit() - - -@app.before_request -def before_request(): - g.data = {} - - -@app.route('/') -def index(): - if current_user.is_authenticated: - return redirect(url_for('get_pit_stops')) - else: - user_count = len(User.query.all()) - vehicles = Vehicle.query.all() - litres = 0 - kilometers = 0 - for vehicle in vehicles: - stats = tools.VehicleStats(vehicle) - litres += stats.overall_litres - kilometers += stats.overall_distance - vehicle_count = len(vehicles) - pitstop_count = len(Pitstop.query.all()) - data = { - 'users':user_count, - 'vehicles': vehicle_count, - 'pitstops': pitstop_count, - 'litres': litres, - 'kilometers': kilometers - } - return render_template('index.html', login_user_form=LoginForm(), data=data) - - -@app.route('/account/edit_vehicle/', methods=['GET', 'POST']) -@login_required -def edit_vehicle(vid): - vehicle = Vehicle.query.filter(Vehicle.id == vid).first() - form = EditVehicleForm() - - if form.validate_on_submit(): - if not tools.check_vehicle_name_is_unique(current_user, form.name): - return render_template('editVehicleForm.html', form=form) - vehicle.name = form.name.data - db.session.commit() - tools.db_log_update(vehicle) - return redirect(url_for('get_account_page')) - - form.name.default = vehicle.name - form.process() - return render_template('editVehicleForm.html', form=form) - - -@app.route('/account/delete_vehicle/', methods=['GET', 'POST']) -@login_required -def delete_vehicle(vid): - vehicle = Vehicle.query.filter(Vehicle.id == vid).first() - - # prevent deletion of foreign vehicles - if vehicle not in current_user.vehicles: - return redirect(url_for('get_account_page')) - - form = DeleteVehicleForm() - - if form.validate_on_submit(): - db.session.delete(vehicle) - db.session.commit() - tools.db_log_delete(vehicle) - return redirect(url_for('get_account_page')) - - return render_template('deleteVehicleForm.html', form=form, vehicle=vehicle) - - -@app.route('/account/create_vehicle', methods=['GET', 'POST']) -@login_required -def create_vehicle(): - form = EditVehicleForm() - - if form.validate_on_submit(): - vehicle_name = form.name.data - if not tools.check_vehicle_name_is_unique(current_user, form.name): - return render_template('createVehicleForm.html', form=form) - new_vehicle = Vehicle(vehicle_name) - db.session.add(new_vehicle) - current_user.vehicles.append(new_vehicle) - db.session.commit() - tools.db_log_add(new_vehicle) - return redirect(url_for('get_account_page')) - - return render_template('createVehicleForm.html', form=form) - - -@app.route('/pitstops/select_vehicle', methods=['GET', 'POST']) -@login_required -def select_vehicle_for_new_pitstop(): - form = SelectVehicleForm() - form.vehicle.choices = [(g.id, g.name) for g in current_user.vehicles] - - if form.validate_on_submit(): - vehicle = Vehicle.query.filter(Vehicle.id == form.vehicle.data).first() - if vehicle not in current_user.vehicles: - return render_template('selectVehice.html', form=form) - - return redirect(url_for('create_pit_stop_form', vid=form.vehicle.data)) - - return render_template('selectVehice.html', form=form) - - -@app.route('/pitstops/create/', methods=['GET', 'POST']) -@login_required -def create_pit_stop_form(vid): - vehicle = Vehicle.query.filter(Vehicle.id == vid).first() - if vehicle not in current_user.vehicles: - return redirect(url_for('select_vehicle_for_new_pitstop')) - - if len(vehicle.pitstops) > 0: - last_pitstop = vehicle.pitstops[-1] - else: - last_pitstop = Pitstop(0, 0, date(1970, 1, 1)) - - form = CreatePitstopForm() - form.set_pitstop(last_pitstop) - - if form.validate_on_submit(): - new_stop = Pitstop(form.odometer.data, form.litres.data, form.date.data) - db.session.add(new_stop) - vehicle.pitstops.append(new_stop) - db.session.commit() - tools.db_log_add(new_stop) - return redirect(url_for('get_pit_stops', _anchor= 'v' + str(vehicle.id))) - - form.odometer.default = last_pitstop.odometer - form.litres.default = last_pitstop.litres - form.date.default = date.today() - form.process() - messages = { - 'date': 'Date must be between %s and %s (including).' % (str(last_pitstop.date), str(date.today())), - 'odometer': 'Odometer must be greater than %s km.' % (str(last_pitstop.odometer)) - } - return render_template('newPitStopForm.html', form=form, vehicle=vehicle, messages = messages) - - -@app.route('/pitstops', methods=['GET']) -@login_required -def get_pit_stops(): - return render_template('pitstops.html', user=current_user) - - -@app.route('/manual', methods=['GET']) -@login_required -def get_manual(): - return render_template('manual.html', data=g.data) - - -@app.route('/admin', methods=['GET']) -@roles_required('admin') -def get_admin_page(): - g.data['users'] = User.query.all() - return render_template('admin.html', data=g.data) - - -@app.route('/account', methods=['GET']) -@login_required -def get_account_page(): - return render_template('account.html', data=g.data) - - -@app.route('/statistics', methods=['GET']) -@login_required -def get_statistics(): - stats = [] - for vehicle in current_user.vehicles: - stats.append(tools.VehicleStats(vehicle)) - return render_template('statistics.html', data=stats) - - -@app.route('/account/delete', methods=['GET', 'POST']) -@login_required -def delete_account(): - form = DeleteAccountForm() - - if form.validate_on_submit(): - user_datastore.delete_user(current_user) - db.session.commit() - return redirect(url_for('index')) - - return render_template('deleteAccountForm.html', form=form) diff --git a/app/rollerverbrauch/entities.py b/app/rollerverbrauch/entities.py deleted file mode 100644 index 64d456b..0000000 --- a/app/rollerverbrauch/entities.py +++ /dev/null @@ -1,69 +0,0 @@ -from rollerverbrauch import db -from flask.ext.security import UserMixin, RoleMixin - -roles_users = db.Table('roles_users', - db.Column('user_id', db.Integer(), db.ForeignKey('user.id')), - db.Column('role_id', db.Integer(), db.ForeignKey('role.id'))) - - -class Role(db.Model, RoleMixin): - id = db.Column(db.Integer(), primary_key=True) - name = db.Column(db.String(80), unique=True) - description = db.Column(db.String(255)) - - def __str__(self): - return self.name - - def __hash__(self): - return hash(self.name) - - -class User(db.Model, UserMixin): - id = db.Column(db.Integer, primary_key=True) - email = db.Column(db.String(255), unique=True) - password = db.Column(db.String(255)) - active = db.Column(db.Boolean()) - confirmed_at = db.Column(db.DateTime()) - vehicles = db.relationship( - 'Vehicle' - ) - roles = db.relationship( - 'Role', - secondary=roles_users, - backref=db.backref('users', lazy='dynamic') - ) - - def __repr__(self): - return '' % (self.id, self.owner_id, self.name) - - -class Pitstop(db.Model): - id = db.Column(db.Integer, primary_key=True) - date = db.Column(db.Date) - odometer = db.Column(db.Integer) - litres = db.Column(db.Numeric(5, 2)) - vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicle.id')) - - def __init__(self, odometer, litres, date): - self.odometer = odometer - self.litres = litres - self.date = date - - def __repr__(self): - return '' % (self.odometer, self.litres, self.date, self.vehicle_id) \ No newline at end of file diff --git a/app/rollerverbrauch/forms.py b/app/rollerverbrauch/forms.py deleted file mode 100644 index 1e20c47..0000000 --- a/app/rollerverbrauch/forms.py +++ /dev/null @@ -1,50 +0,0 @@ -from flask_wtf import Form -from wtforms import DateField, IntegerField, DecimalField, StringField, SelectField, SubmitField -from wtforms.validators import ValidationError, Length -from datetime import date - - -def date_check(form, field): - if field.data < form.last_pitstop.date: - raise ValidationError('The new date must not be before %s' % form.last_pitstop.date) - if field.data > date.today(): - raise ValidationError('The new date must not be after %s' % date.today()) - - -def odometer_check(form, field): - if field.data <= form.last_pitstop.odometer: - raise ValidationError('The new odometer value must be higher than %i km' % form.last_pitstop.odometer) - - -def litres_check(form, field): - if field.data is not None and field.data <= 0: - raise ValidationError('You must fuel at least 0.1 l') - - -class SelectVehicleForm(Form): - vehicle = SelectField('Vehicle', coerce=int) - submit = SubmitField(label='Do it!') - - -class CreatePitstopForm(Form): - date = DateField('Date of Pitstop', validators=[date_check]) - odometer = IntegerField('Odometer (km)', validators=[odometer_check]) - litres = DecimalField('Litres (l)', places=2, validators=[litres_check]) - submit = SubmitField(label='Do it!') - last_pitstop = None - - def set_pitstop(self, last_pitstop): - self.last_pitstop = last_pitstop - - -class EditVehicleForm(Form): - name = StringField('Name', validators=[Length(1, 255)]) - submit = SubmitField(label='Do it!') - - -class DeleteVehicleForm(Form): - submit = SubmitField(label='Do it!') - - -class DeleteAccountForm(Form): - submit = SubmitField(label='Really delete my account!') diff --git a/app/rollerverbrauch/templates/admin.html b/app/rollerverbrauch/templates/admin.html deleted file mode 100644 index 8b25d30..0000000 --- a/app/rollerverbrauch/templates/admin.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends "layout.html" %} - -{% block body %} -
-
-
-
-

Admin

- We have {{ data.users|length }} users so far: -
    - {% for user in data.users %} -
  • {{user.email}}
  • - {% endfor %} -
-
-
-
-{% endblock %} diff --git a/app/rollerverbrauch/templates/pitstops.html b/app/rollerverbrauch/templates/pitstops.html deleted file mode 100644 index 043485e..0000000 --- a/app/rollerverbrauch/templates/pitstops.html +++ /dev/null @@ -1,92 +0,0 @@ - {% extends "layout.html" %} -{% block body %} -
- -
- {% for vehicle in current_user.vehicles %} -
-

{{vehicle.name}}

- {% if vehicle.pitstops %} -
- - - - - - - {% for pitstop in vehicle.pitstops|reverse %} - {% if not loop.last %} - {% set days = (pitstop.date - vehicle.pitstops[vehicle.pitstops|length - loop.index - 1].date).days %} - {% set distance = pitstop.odometer - vehicle.pitstops[vehicle.pitstops|length - loop.index - 1].odometer %} - {% set average = (pitstop.litres / distance) * 100 %} - - - - - - {% else %} - - - - - - {% endif %} - {% endfor %} -
- Date
- Days -
- Odometer
- Distance -
- Litres
- Average -
- {{pitstop.date}}
- {{ days }} days -
- {{pitstop.odometer}} km
- {{distance}} km -
- {{pitstop.litres}} l
- {{average | round(2)}} l/100km -
- {{pitstop.date}}
- -- days -
- {{pitstop.odometer}} km
- -- km -
- {{pitstop.litres}} l
- -- l/100km -
-
- {% else %} - - {% endif %} -
- {% endfor %} -
-
- - - -{% endblock %} diff --git a/app/rollerverbrauch/templates/statistics.html b/app/rollerverbrauch/templates/statistics.html deleted file mode 100644 index c1d5aeb..0000000 --- a/app/rollerverbrauch/templates/statistics.html +++ /dev/null @@ -1,119 +0,0 @@ -{% extends "layout.html" %} - -{% block body %} -
- -
- {% for vehicle in data %} -
-

{{vehicle.name}}

-
- - - - - - - - - - - - - - - - - - - - - - - - - -
Number of Pitstops:{{ vehicle.pitstop_count }}
Logged Distance:{{ vehicle.overall_distance | round(2) }} km
Average Distance:{{ vehicle.average_distance | round(2) }} km
Litres fuelled:{{ vehicle.overall_litres | round(2) }} l
Average Litres fuelled:{{ vehicle.average_litres_fuelled | round(2) }} l
Average Litres used:{{ vehicle.average_litres_used | round(2) }} l/100km
-
- -
-
- {% if vehicle.pitstop_count > 1 %} -
- - {% else %} - - {% endif %} -
-
- {% if vehicle.pitstop_count > 0 %} -
- - {% else %} - - {% endif %} -
-
- {% if vehicle.pitstop_count > 0 %} -
- - {% else %} - - {% endif %} -
-
-
- {% endfor %} -
-
- - - - - - -{% endblock %} diff --git a/app/rollerverbrauch/tools.py b/app/rollerverbrauch/tools.py deleted file mode 100644 index 3099d76..0000000 --- a/app/rollerverbrauch/tools.py +++ /dev/null @@ -1,67 +0,0 @@ -import logging - - -class VehicleStats: - def __init__(self, vehicle): - self.name = vehicle.name - self.id = vehicle.id - self.pitstop_count = len(vehicle.pitstops) - self.overall_distance = 0 - self.average_distance = 0 - self.overall_litres = 0 - self.average_litres_fuelled = 0 - self.average_litres_used = 0 - self.litres = [] - self.average_litres = [] - self.odometers = [] - - if self.pitstop_count > 0: - for pitstop in vehicle.pitstops: - self.overall_litres += pitstop.litres - self.litres.append(StatsEvent(pitstop.date, pitstop.litres)) - self.odometers.append(StatsEvent(pitstop.date, pitstop.odometer)) - self.average_litres_fuelled = self.overall_litres / self.pitstop_count - if self.pitstop_count > 1: - self.overall_distance = vehicle.pitstops[-1].odometer - vehicle.pitstops[0].odometer - self.average_distance = self.overall_distance / (self.pitstop_count - 1) - self.average_litres_used = 100 * (self.overall_litres - vehicle.pitstops[0].litres) / self.overall_distance - for index in range(1, self.pitstop_count): - last_ps = vehicle.pitstops[index - 1] - current_ps = vehicle.pitstops[index] - self.average_litres.append(StatsEvent(current_ps.date, round(100 * current_ps.litres/(current_ps.odometer - last_ps.odometer),2))) - - -class StatsEvent: - def __init__(self, date, value): - self.date = date - self.value = value - - -def db_log_add(entity): - logging.info('db_add: %s' % str(entity)) - - -def db_log_delete(entity): - logging.info('db_delete: %s' % str(entity)) - - -def db_log_update(entity): - logging.info('db_update: %s' % str(entity)) - - -def check_vehicle_name_is_unique(current_user, name_field): - """ - Checks if the vehicle name given in the name_field is unique for the vehicles of the current user. An error is added - to the field it the name is not unique. - - :param current_user: the user currently logged in - :param name_field: the form field to enter the name to - :return: True if the name is unique, False otherwise. - """ - vehicle_name = name_field.data - for vehicle in current_user.vehicles: - if vehicle.name == vehicle_name: - name_field.default = vehicle_name - name_field.errors.append('Vehicle "%s" already exists.' % vehicle_name) - return False - return True \ No newline at end of file diff --git a/app/rollerverbrauch/static/android-icon-192x192.png b/app/static/android-icon-192x192.png similarity index 100% rename from app/rollerverbrauch/static/android-icon-192x192.png rename to app/static/android-icon-192x192.png diff --git a/app/rollerverbrauch/static/apple-touch-icon-114.png b/app/static/apple-touch-icon-114.png similarity index 100% rename from app/rollerverbrauch/static/apple-touch-icon-114.png rename to app/static/apple-touch-icon-114.png diff --git a/app/rollerverbrauch/static/apple-touch-icon-120.png b/app/static/apple-touch-icon-120.png similarity index 100% rename from app/rollerverbrauch/static/apple-touch-icon-120.png rename to app/static/apple-touch-icon-120.png diff --git a/app/rollerverbrauch/static/apple-touch-icon-144.png b/app/static/apple-touch-icon-144.png similarity index 100% rename from app/rollerverbrauch/static/apple-touch-icon-144.png rename to app/static/apple-touch-icon-144.png diff --git a/app/rollerverbrauch/static/apple-touch-icon-152.png b/app/static/apple-touch-icon-152.png similarity index 100% rename from app/rollerverbrauch/static/apple-touch-icon-152.png rename to app/static/apple-touch-icon-152.png diff --git a/app/rollerverbrauch/static/apple-touch-icon-180.png b/app/static/apple-touch-icon-180.png similarity index 100% rename from app/rollerverbrauch/static/apple-touch-icon-180.png rename to app/static/apple-touch-icon-180.png diff --git a/app/rollerverbrauch/static/apple-touch-icon-57.png b/app/static/apple-touch-icon-57.png similarity index 100% rename from app/rollerverbrauch/static/apple-touch-icon-57.png rename to app/static/apple-touch-icon-57.png diff --git a/app/rollerverbrauch/static/apple-touch-icon-60.png b/app/static/apple-touch-icon-60.png similarity index 100% rename from app/rollerverbrauch/static/apple-touch-icon-60.png rename to app/static/apple-touch-icon-60.png diff --git a/app/rollerverbrauch/static/apple-touch-icon-72.png b/app/static/apple-touch-icon-72.png similarity index 100% rename from app/rollerverbrauch/static/apple-touch-icon-72.png rename to app/static/apple-touch-icon-72.png diff --git a/app/rollerverbrauch/static/apple-touch-icon-76.png b/app/static/apple-touch-icon-76.png similarity index 100% rename from app/rollerverbrauch/static/apple-touch-icon-76.png rename to app/static/apple-touch-icon-76.png diff --git a/app/rollerverbrauch/static/favicon-16.png b/app/static/favicon-16.png similarity index 100% rename from app/rollerverbrauch/static/favicon-16.png rename to app/static/favicon-16.png diff --git a/app/rollerverbrauch/static/favicon-32.png b/app/static/favicon-32.png similarity index 100% rename from app/rollerverbrauch/static/favicon-32.png rename to app/static/favicon-32.png diff --git a/app/rollerverbrauch/static/favicon-96.png b/app/static/favicon-96.png similarity index 100% rename from app/rollerverbrauch/static/favicon-96.png rename to app/static/favicon-96.png diff --git a/app/rollerverbrauch/static/jquery-1.11.2.min.js b/app/static/jquery-1.11.2.min.js similarity index 100% rename from app/rollerverbrauch/static/jquery-1.11.2.min.js rename to app/static/jquery-1.11.2.min.js diff --git a/app/rollerverbrauch/static/main.css b/app/static/main.css similarity index 100% rename from app/rollerverbrauch/static/main.css rename to app/static/main.css diff --git a/app/rollerverbrauch/static/main.js b/app/static/main.js similarity index 100% rename from app/rollerverbrauch/static/main.js rename to app/static/main.js diff --git a/app/rollerverbrauch/static/modernizr-2.8.3-respond-1.4.2.min.js b/app/static/modernizr-2.8.3-respond-1.4.2.min.js similarity index 100% rename from app/rollerverbrauch/static/modernizr-2.8.3-respond-1.4.2.min.js rename to app/static/modernizr-2.8.3-respond-1.4.2.min.js diff --git a/app/rollerverbrauch/static/normalize.min.css b/app/static/normalize.min.css similarity index 100% rename from app/rollerverbrauch/static/normalize.min.css rename to app/static/normalize.min.css diff --git a/app/rollerverbrauch/templates/account.html b/app/templates/account.html similarity index 96% rename from app/rollerverbrauch/templates/account.html rename to app/templates/account.html index cc1ed3a..43a0d33 100644 --- a/app/rollerverbrauch/templates/account.html +++ b/app/templates/account.html @@ -36,7 +36,8 @@ {{ vehicle.name }} - {{ vehicle.pitstops | length }} pitstops + {{ vehicle.pitstops | length }} pitstops
+ {{ vehicle.consumables | length }} consumables diff --git a/app/templates/admin.html b/app/templates/admin.html new file mode 100644 index 0000000..8bee9ef --- /dev/null +++ b/app/templates/admin.html @@ -0,0 +1,66 @@ +{% extends "layout.html" %} + +{% block body %} +

Admin

+
+
Users
+
+ We have {{ users|length }} users so far: +
    + {% for user in users %} +
  • {{user.email}}
  • + {% endfor %} +
+
+ +
+
Consumables
+
+ + + + + + + + + {% for consumable in consumables %} + + + + + + + {% endfor %} + +
+ Name + + Unit + + Used by + + Actions +
+ {{ consumable.name }} + + {{ consumable.unit }} + + {{ consumable.vehicles | length }} vehicles + + {% if not consumable.in_use %} + + delete + + {% endif %} + + edit + +
+
+ +{% endblock %} diff --git a/app/templates/createConsumableForm.html b/app/templates/createConsumableForm.html new file mode 100644 index 0000000..1634ccf --- /dev/null +++ b/app/templates/createConsumableForm.html @@ -0,0 +1,19 @@ +{% extends "layout.html" %} + +{% block body %} +
+
+
+
+

Create consumable

+
+ {{ form.hidden_tag() }} + {{ render_field_with_errors(form.name) }} + {{ render_field_with_errors(form.unit) }} + {{ render_field_with_errors(form.submit) }} +
+
+
+
+
+{% endblock %} diff --git a/app/rollerverbrauch/templates/newPitStopForm.html b/app/templates/createPitStopForm.html similarity index 80% rename from app/rollerverbrauch/templates/newPitStopForm.html rename to app/templates/createPitStopForm.html index 37f2f71..a6941e3 100644 --- a/app/rollerverbrauch/templates/newPitStopForm.html +++ b/app/templates/createPitStopForm.html @@ -17,9 +17,14 @@ {{messages['odometer']}} {{ render_field_with_errors(form.litres) }} + {{ render_field_with_errors(form.costs) }} + + {{messages['costs']}} + {{ render_field_with_errors(form.submit) }} +
{% endblock %} diff --git a/app/rollerverbrauch/templates/createVehicleForm.html b/app/templates/createVehicleForm.html similarity index 88% rename from app/rollerverbrauch/templates/createVehicleForm.html rename to app/templates/createVehicleForm.html index 2dce35b..9e5ced5 100644 --- a/app/rollerverbrauch/templates/createVehicleForm.html +++ b/app/templates/createVehicleForm.html @@ -9,6 +9,7 @@
{{ form.hidden_tag() }} {{ render_field_with_errors(form.name) }} + {{ render_field_with_errors(form.consumables) }} {{ render_field_with_errors(form.submit) }}
diff --git a/app/rollerverbrauch/templates/deleteAccountForm.html b/app/templates/deleteAccountForm.html similarity index 100% rename from app/rollerverbrauch/templates/deleteAccountForm.html rename to app/templates/deleteAccountForm.html diff --git a/app/templates/deleteConsumableForm.html b/app/templates/deleteConsumableForm.html new file mode 100644 index 0000000..40a6e88 --- /dev/null +++ b/app/templates/deleteConsumableForm.html @@ -0,0 +1,18 @@ +{% extends "layout.html" %} + +{% block body %} +
+
+
+
+

Delete vehicle '{{consumable.name}}'?

+
+ {{ form.hidden_tag() }} + {{ render_field_with_errors(form.submit) }} + +
+
+
+
+
+{% endblock %} diff --git a/app/templates/deletePitstopForm.html b/app/templates/deletePitstopForm.html new file mode 100644 index 0000000..fa14203 --- /dev/null +++ b/app/templates/deletePitstopForm.html @@ -0,0 +1,35 @@ +{% extends 'layout.html' %} + +{% block body %} +
+
+
+
+

Delete pitstop?

+ + + + + + + + + + + + + + + + + +
Date of Pitstop{{ pitstop.date }}
Odometer{{ pitstop.odometer }} km
Litres{{ pitstop.litres }} l
Costs (overall){{ pitstop.costs }} €
+
+ {{ form.hidden_tag() }} + {{ render_field_with_errors(form.submit) }} +
+
+
+
+
+{% endblock %} diff --git a/app/rollerverbrauch/templates/deleteVehicleForm.html b/app/templates/deleteVehicleForm.html similarity index 100% rename from app/rollerverbrauch/templates/deleteVehicleForm.html rename to app/templates/deleteVehicleForm.html diff --git a/app/templates/editConsumableForm.html b/app/templates/editConsumableForm.html new file mode 100644 index 0000000..97510d5 --- /dev/null +++ b/app/templates/editConsumableForm.html @@ -0,0 +1,19 @@ +{% extends "layout.html" %} + +{% block body %} +
+
+
+
+

Edit consumable

+
+ {{ form.hidden_tag() }} + {{ render_field_with_errors(form.name) }} + {{ render_field_with_errors(form.unit) }} + {{ render_field_with_errors(form.submit) }} +
+
+
+
+
+{% endblock %} diff --git a/app/templates/editPitStopForm.html b/app/templates/editPitStopForm.html new file mode 100644 index 0000000..3761c34 --- /dev/null +++ b/app/templates/editPitStopForm.html @@ -0,0 +1,29 @@ +{% extends "layout.html" %} + +{% block body %} +
+
+
+
+

Edit Pitstop for '{{ vehicle.name }}'

+
+ {{ form.hidden_tag() }} + {{ render_field_with_errors(form.date) }} + + {{messages['date']}} + + {{ render_field_with_errors(form.odometer) }} + + {{messages['odometer']}} + + {{ render_field_with_errors(form.litres) }} + {{ render_field_with_errors(form.costs) }} + + {{messages['costs']}} + + {{ render_field_with_errors(form.submit) }} +
+
+
+
+{% endblock %} diff --git a/app/rollerverbrauch/templates/editVehicleForm.html b/app/templates/editVehicleForm.html similarity index 88% rename from app/rollerverbrauch/templates/editVehicleForm.html rename to app/templates/editVehicleForm.html index 8d2827f..c5b18dd 100644 --- a/app/rollerverbrauch/templates/editVehicleForm.html +++ b/app/templates/editVehicleForm.html @@ -9,6 +9,7 @@
{{ form.hidden_tag() }} {{ render_field_with_errors(form.name) }} + {{ render_field_with_errors(form.consumables) }} {{ render_field_with_errors(form.submit) }}
diff --git a/app/rollerverbrauch/templates/index.html b/app/templates/index.html similarity index 69% rename from app/rollerverbrauch/templates/index.html rename to app/templates/index.html index 162c066..2dc8e1f 100644 --- a/app/rollerverbrauch/templates/index.html +++ b/app/templates/index.html @@ -11,7 +11,13 @@

Join the pitstop community!

-

There are already {{ data.users}} members with {{ data.vehicles }} vehicles who have logged {{ data.pitstops }} pitstops fuelling {{ data.litres }}l for {{ data.kilometers }}km.

+

There are already {{ data.users}} members with {{ data.vehicles }} vehicles who have logged {{ data.kilometers }}km in {{ data.pitstops }} pitstops. They fuelled

+
    + {% for key in data.consumables %} + {% set consumable = data.consumables[key] %} +
  • {{consumable.amount}}{{consumable.unit}} of {{consumable.name}}
  • + {% endfor %} +

With pitstop community you can:

    diff --git a/app/rollerverbrauch/templates/layout.html b/app/templates/layout.html similarity index 91% rename from app/rollerverbrauch/templates/layout.html rename to app/templates/layout.html index 60e5ebe..8920020 100644 --- a/app/rollerverbrauch/templates/layout.html +++ b/app/templates/layout.html @@ -85,30 +85,6 @@ {% endmacro %} -{% macro chartScript(divId, data, unit)%} - {% set hash = divId | md5 %} - - data_{{ hash }} = [{% for stop in data %}{ - "date": "{{stop.date}}", - "value": {{stop.value}} - }{% if not loop.last %},{%endif%} - {% endfor%} - ] - var chart_{{ hash }} = createChart('{{divId}}', data_{{ hash }}, '{{unit}}'); - - function zoom_chart_{{ hash }}() { - chart_{{ hash }}.zoomToIndexes( - chart_{{ hash }}.dataProvider.length - 40, - chart_{{ hash }}.dataProvider.length - 1 - ); - } - - chart_{{ hash }}.addListener("rendered", zoom_chart_{{ hash }}); - - zoom_chart_{{ hash }}() - -{% endmacro %} - diff --git a/app/rollerverbrauch/templates/manual.html b/app/templates/manual.html similarity index 100% rename from app/rollerverbrauch/templates/manual.html rename to app/templates/manual.html diff --git a/app/templates/pitstops.html b/app/templates/pitstops.html new file mode 100644 index 0000000..25c7283 --- /dev/null +++ b/app/templates/pitstops.html @@ -0,0 +1,78 @@ + {% extends "layout.html" %} +{% block body %} +
    +
    + +
    + {% for vehicle in current_user.vehicles %} +
    +

    {{vehicle.name}}

    + {% if vehicle.pitstops %} + {% for pitstop in vehicle.pitstops|reverse %} +
    +
    + + + + + + + + + + + + + + + + + +
    Date{{pitstop.date}}
    Odometer{{pitstop.odometer}} km
    {{ pitstop.consumable.name }}{{pitstop.amount}} {{ pitstop.consumable.unit }}
    Costs + {% if pitstop.costs %} + {{pitstop.costs}} € + {% else %} + -- € + {% endif %} +
    + {% if loop.first %} + + edit + + + delete + + {% endif %} +
    +
    + {% endfor %} + {% else %} + + {% endif %} +
    + {% endfor %} +
    +
    +
    + + + +{% endblock %} diff --git a/app/rollerverbrauch/templates/security/change_password.html b/app/templates/security/change_password.html similarity index 100% rename from app/rollerverbrauch/templates/security/change_password.html rename to app/templates/security/change_password.html diff --git a/app/rollerverbrauch/templates/security/forgot_password.html b/app/templates/security/forgot_password.html similarity index 100% rename from app/rollerverbrauch/templates/security/forgot_password.html rename to app/templates/security/forgot_password.html diff --git a/app/rollerverbrauch/templates/security/login_user.html b/app/templates/security/login_user.html similarity index 100% rename from app/rollerverbrauch/templates/security/login_user.html rename to app/templates/security/login_user.html diff --git a/app/rollerverbrauch/templates/security/register_user.html b/app/templates/security/register_user.html similarity index 100% rename from app/rollerverbrauch/templates/security/register_user.html rename to app/templates/security/register_user.html diff --git a/app/rollerverbrauch/templates/security/reset_password.html b/app/templates/security/reset_password.html similarity index 100% rename from app/rollerverbrauch/templates/security/reset_password.html rename to app/templates/security/reset_password.html diff --git a/app/templates/selectConsumableForVehicle.html b/app/templates/selectConsumableForVehicle.html new file mode 100644 index 0000000..9676e2a --- /dev/null +++ b/app/templates/selectConsumableForVehicle.html @@ -0,0 +1,19 @@ +{% extends "layout.html" %} + +{% block body %} +
    +
    +
    +
    +

    Select Consumable for '{{ vehicle.name }}'

    +
    + {{ form.hidden_tag() }} + {{ render_field_with_errors(form.consumable) }} + {{ render_field_with_errors(form.submit) }} +
    +
    +
    +
    +
    +{% endblock %} + diff --git a/app/rollerverbrauch/templates/selectVehice.html b/app/templates/selectVehicle.html similarity index 100% rename from app/rollerverbrauch/templates/selectVehice.html rename to app/templates/selectVehicle.html diff --git a/app/templates/statistics.html b/app/templates/statistics.html new file mode 100644 index 0000000..0f1ecd3 --- /dev/null +++ b/app/templates/statistics.html @@ -0,0 +1,175 @@ +{% extends "layout.html" %} + +{% macro chartScript(divId, data, unit)%} + {% set hash = divId | md5 %} + + data_{{ hash }} = [{% for stop in data %}{ + "date": "{{stop.date}}", + "value": {{stop.value}} + }{% if not loop.last %},{%endif%} + {% endfor%} + ] + var chart_{{ hash }} = createChart('{{divId}}', data_{{ hash }}, '{{unit}}'); + + function zoom_chart_{{ hash }}() { + chart_{{ hash }}.zoomToIndexes( + chart_{{ hash }}.dataProvider.length - 40, + chart_{{ hash }}.dataProvider.length - 1 + ); + } + + chart_{{ hash }}.addListener("rendered", zoom_chart_{{ hash }}); + + zoom_chart_{{ hash }}() +{% endmacro %} + +{% macro nav_tab(id, text, active) %} + {# + + #} +
  • + + {{ text }} + +
  • +{% endmacro %} + +{% macro tab_pane(id, content, active) %} +
    + {{ content }} +
    +{% endmacro %} + +{% macro chart(data, baseId, unit, link) %} + {% if data|length > 0 %} + {% set chartID = 'chart_' + baseId %} +
    + + {% else %} + + {% endif %} +{% endmacro %} + +{% macro print_consumable_table(consumable) %} + + + + + + + + + + + + + + + + + +
    Average Distance:{{ consumable.average_distance | round(2) }} km
    Amount fuelled:{{ consumable.overall_amount | round(2) }} {{ consumable.unit }}
    Average Amount fuelled:{{ consumable.average_amount_fuelled | round(2) }} {{ consumable.unit }}
    Average Amount used:{{ consumable.average_amount_used | round(2) }} {{ consumable.unit }}/100km
    +{% endmacro %} + +{% macro print_vehicle_table(vehicle) %} +

    {{vehicle.name}}

    +
    + + + + + + + + + +
    Logged Distance:{{ vehicle.overall_distance | round(2) }} km
    Logged Costs:{{ vehicle.overall_costs | round(2) }} €
    +
    +{% endmacro %} + +{% macro tab_script(id) %} + +{% endmacro %} + +{% block body %} +
    + +
    + {% for vehicle in data %} +
    + {{ print_vehicle_table(vehicle) }} + +
    + {{ tab_pane( + vehicle.id|string + '_odometer', + chart( + vehicle.odometers, + 'ref_' + vehicle.id|string + '_odometer', + 'km', + url_for('select_consumable_for_new_pitstop', vid=vehicle.id) + ), + true + ) + }} + {% for consumable in vehicle.consumables %} +
    + {{ print_consumable_table(consumable) }} + +
    + {{ tab_pane( + vehicle.id|string + '_' + consumable.id|string + '_consumption', + chart( + consumable.average_amount, + 'ref_' + vehicle.id|string + '_' + consumable.id|string + '_consumption', + consumable.unit + '/100km', + url_for('create_pit_stop_form', vid=vehicle.id, cid=consumable.id) + ), + true + ) + }} + {{ tab_pane( + vehicle.id|string + '_' + consumable.id|string + '_amount', + chart( + consumable.amounts, + 'ref_' + vehicle.id|string + '_' + consumable.id|string + '_amount', + consumable.unit, + url_for('create_pit_stop_form', vid=vehicle.id, cid=consumable.id) + ), + false + ) + }} +
    + {{ tab_script('vehicle_' + vehicle.id|string + '_' + consumable.id|string + '_tabs') }} +
    + {% endfor %} +
    + {{ tab_script('vehicle_' + vehicle.id|string + '_tabs') }} +
    + {% endfor %} +
    + {{ tab_script('vehicle_tabs') }} +
    +{% endblock %} diff --git a/app/tools.py b/app/tools.py new file mode 100644 index 0000000..cd8108a --- /dev/null +++ b/app/tools.py @@ -0,0 +1,177 @@ +import logging +from datetime import date + +from .entities import Pitstop + + +class ConsumableStats: + def __init__(self, vehicle, consumable): + self.name = consumable.name + self.id = consumable.id + self.unit = consumable.unit + self.overall_amount = 0 + self.average_distance = 0 + self.average_amount_fuelled = 0 + self.average_amount_used = 0 + self.average_amount = [] + self.amounts = [] + + pitstops = [stop for stop in vehicle.pitstops if stop.consumable_id == consumable.id] + pitstop_count = len(pitstops) + + if pitstop_count > 0: + for pitstop in pitstops: + self.overall_amount += pitstop.amount + self.amounts.append(StatsEvent(pitstop.date, pitstop.amount)) + self.average_amount_fuelled = self.overall_amount / pitstop_count + if pitstop_count > 1: + overall_distance = vehicle.pitstops[-1].odometer - vehicle.pitstops[0].odometer + self.average_distance = overall_distance / (pitstop_count - 1) + self.average_amount_used = 100 * (self.overall_amount - pitstops[0].amount) / overall_distance + for index in range(1, pitstop_count): + last_ps = pitstops[index - 1] + current_ps = pitstops[index] + self.average_amount.append( + StatsEvent( + current_ps.date, + round(100 * current_ps.amount/(current_ps.odometer - last_ps.odometer), 2))) + + +class VehicleStats: + def __init__(self, vehicle): + self.name = vehicle.name + self.id = vehicle.id + self.overall_distance = 0 + self.overall_costs = 0 + self.consumables = [] + self.odometers = [] + + for consumable in vehicle.consumables: + self.consumables.append(ConsumableStats(vehicle, consumable)) + + pitstop_count = len(vehicle.pitstops) + + if pitstop_count > 0: + for pitstop in vehicle.pitstops: + self.odometers.append(StatsEvent(pitstop.date, pitstop.odometer)) + if pitstop.costs is not None: + self.overall_costs += pitstop.costs + + if pitstop_count > 1: + self.overall_distance = vehicle.pitstops[-1].odometer - vehicle.pitstops[0].odometer + + +class StatsEvent: + def __init__(self, date, value): + self.date = date + self.value = value + + +def db_log_add(entity): + logging.info('db_add: %s' % str(entity)) + + +def db_log_delete(entity): + logging.info('db_delete: %s' % str(entity)) + + +def db_log_update(entity): + logging.info('db_update: %s' % str(entity)) + + +def check_vehicle_name_is_unique(current_user, name_field): + """ + Checks if the vehicle name given in the name_field is unique for the vehicles of the current user. An error is added + to the field it the name is not unique. + + :param current_user: the user currently logged in + :param name_field: the form field to enter the name to + :return: True if the name is unique, False otherwise. + """ + vehicle_name = name_field.data + for vehicle in current_user.vehicles: + if vehicle.name == vehicle_name: + name_field.default = vehicle_name + name_field.errors.append('Vehicle "%s" already exists.' % vehicle_name) + return False + return True + + +def get_latest_pitstop_for_vehicle(vehicle_id): + """ + return the latest pit stop for the vehicle with the given id. + :param vehicle_id: the id of the vehicle + :return: the latest pitstop or None if no pitstop exists + """ + latest_pitstop = Pitstop.query\ + .filter(Pitstop.vehicle_id == vehicle_id)\ + .order_by(Pitstop.id.desc())\ + .first() + return latest_pitstop + + +def get_latest_pitstop_for_vehicle_and_consumable(vehicle_id, consumable_id): + """ + return the latest pit stop for the vehicle and consumable with the given ids. + :param vehicle_id: the id of the vehicle + :param consumable_id: the id of the consumable + :return: the latest pitstop or None if no pitstop exists + """ + latest_pitstop_consumable = Pitstop.query\ + .filter(Pitstop.vehicle_id == vehicle_id)\ + .filter(Pitstop.consumable_id == consumable_id)\ + .order_by(Pitstop.id.desc())\ + .first() + return latest_pitstop_consumable + + +def compute_lower_limits_for_new_pitstop(latest_pitstop, last_pitstop_consumable, consumable_id): + """ + This function figures out the lower limits for date and odometer of a new pitstop. + :param latest_pitstop: + :param last_pitstop_consumable: + :param consumable_id: + :return: + """ + odometer = 0 + date_of_pitstop = date(1970, 1, 1) + amount = 0 + costs = 0 + + if latest_pitstop is not None: + odometer = latest_pitstop.odometer + date_of_pitstop = latest_pitstop.date + + if last_pitstop_consumable is not None: + amount = last_pitstop_consumable.amount + costs = last_pitstop_consumable.costs + + return Pitstop(odometer, amount, date_of_pitstop, costs, consumable_id) + + + # if latest_pitstop is not None: + # if last_pitstop_consumable is not None and last_pitstop_consumable != latest_pitstop: + # if latest_pitstop.id > last_pitstop_consumable.id: + # return Pitstop(latest_pitstop.odometer, + # last_pitstop_consumable.overall_amount, + # latest_pitstop.date, + # last_pitstop_consumable.costs, + # consumable_id) + # else: + # return Pitstop(last_pitstop_consumable.odometer, + # last_pitstop_consumable.overall_amount, + # last_pitstop_consumable.date, + # last_pitstop_consumable.costs, + # consumable_id) + # else: + # # either only one pitstop exists or both are the same + # litres = 0 + # costs = 0 + # if latest_pitstop.consumable_id == last_pitstop_consumable.consumable_id: + # litres = latest_pitstop.overall_amount + # costs = latest_pitstop.costs + # return Pitstop(latest_pitstop.odometer, litres, latest_pitstop.date, costs, consumable_id) + # else: + # # No existing pitstop at all: insert fake data + # return Pitstop(0, 0, date(1970, 1, 1), 0, None) + diff --git a/config.py b/config.py new file mode 100644 index 0000000..7952a33 --- /dev/null +++ b/config.py @@ -0,0 +1,46 @@ +import os +basedir = os.path.abspath(os.path.dirname(__file__)) + + +class Config: + SECURITY_PASSWORD_HASH = 'pbkdf2_sha512' + SECURITY_REGISTERABLE = True + SECURITY_CHANGEABLE = True + SECURITY_RECOVERABLE = True + SECURITY_PASSWORD_SALT = os.environ.get('SECURITY_PASSWORD_SALT') or 'SOME SECRET STRING' + SECRET_KEY = os.environ.get('SECRET_KEY') or 'SOME SECRET STRING' + SECURITY_EMAIL_SENDER = 'no-reply@lusiardi.de' + SQLALCHEMY_TRACK_MODIFICATIONS = False + MAIL_SERVER = 'mail.nerd2nerd.org' + MAIL_PORT = 25 + MAIL_USE_TLS = True + MAIL_USE_SSL = False + MAIL_USERNAME = os.environ.get('MAIL_USERNAME') + MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') + + @staticmethod + def init_app(app): + pass + + +class DevelopmentConfig(Config): + SECURITY_SEND_REGISTER_EMAIL = False + DEBUG = True + SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:%s@database/pitstops_old' % (os.environ['DATABASE_ENV_MYSQL_ROOT_PASSWORD']) + + +class TestingConfig(Config): + TESTING = True + SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'data_testing.sqlite') + + +class ProductionConfig(Config): + SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'data.sqlite') + + +config = { + 'development': DevelopmentConfig, + 'testing': TestingConfig, + 'production': ProductionConfig, + 'default': DevelopmentConfig +} \ No newline at end of file diff --git a/database_upgrades/upgrade_cathrine_to_danielle.sql b/database_upgrades/upgrade_cathrine_to_danielle.sql new file mode 100644 index 0000000..c221633 --- /dev/null +++ b/database_upgrades/upgrade_cathrine_to_danielle.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS `consumable` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `name` varchar(255) DEFAULT NULL, + `unit` varchar(255) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `name` (`name`) +) ENGINE=InnoDB AUTO_INCREMENT=31 DEFAULT CHARSET=latin1; + +INSERT INTO `consumable`(`id`, `name`, `unit`) VALUES (1, 'Super E5', 'l'); + +CREATE TABLE IF NOT EXISTS `vehicles_consumables` ( + `vehicle_id` int(11) DEFAULT NULL, + `consumable_id` int(11) DEFAULT NULL, + KEY `vehicle_id` (`vehicle_id`), + KEY `consumable_id` (`consumable_id`), + CONSTRAINT `vehicles_consumables_ibfk_1` FOREIGN KEY (`vehicle_id`) REFERENCES `vehicle` (`id`), + CONSTRAINT `vehicles_consumables_ibfk_2` FOREIGN KEY (`consumable_id`) REFERENCES `consumable` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=latin1; + +ALTER TABLE `pitstop` ADD COLUMN `costs` decimal(5,2) DEFAULT NULL; + +ALTER TABLE `pitstop` CHANGE `litres` `amount`; + +ALTER TABLE `pitstop` ADD COLUMN `consumable_id` int(11); +ALTER TABLE `pitstop` ADD FOREIGN KEY (`consumable_id`) REFERENCES `consumable` (`id`); +UPDATE `pitstop` set `consumable_id`=1; + +INSERT INTO `vehicles_consumables`(`vehicle_id`, `consumable_id`) SELECT `id`, 1 FROM `vehicle`; \ No newline at end of file diff --git a/app/main.py b/main.py similarity index 90% rename from app/main.py rename to main.py index 3bce806..98c0490 100644 --- a/app/main.py +++ b/main.py @@ -1,5 +1,5 @@ import os -from rollerverbrauch import app +from app import app import logging diff --git a/app/requirements.txt b/requirements.txt similarity index 100% rename from app/requirements.txt rename to requirements.txt