first shot

This commit is contained in:
Joachim Lusiardi 2021-06-17 18:28:19 +02:00
parent 4196111529
commit 42356137c4
18 changed files with 839 additions and 137 deletions

View File

@ -5,15 +5,20 @@
## general configuration
Look at *app/config/email.py.example** for the configuration of the
Look at *app/config/email.py.example* for the configuration of the
parameters required for sending emails. Copy the file as *email.py* to
a folder that will serve as configuration directory and fill in the
information. The directory will be used as volume during container
operation.
## start database
`docker run --name pitstops_db -e MYSQL_ROOT_PASSWORD=$SOMESECUREPASSWORD$ -e MYSQL_DATABASE=pitstops -d mysql:latest`
or
`docker run --name pitstops_db -e MYSQL_ROOT_PASSWORD=$SOMESECUREPASSWORD$ -e MYSQL_DATABASE=pitstops -d mariadb:latest`
## Database migrations
### From *Cathrine* to *Master*:
@ -26,6 +31,6 @@ 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 --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`
`docker run --rm --name rollerverbrauch -ti -v $PWD/app:/app -v $PWD/data:/data -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 -tid -e PROXY_DATA=server_names:ps.lusiardi.de,port:5000 --link pitstops_db:database -e SECURITY_PASSWORD_SALT=XXX -e SECRET_KEY=XXX -e MAIL_SERVER=XXX -e MAIL_USERNAME=XXX -e MAIL_PASSWORD=XXX rollerverbrauch:catherine`

View File

@ -1,24 +1,33 @@
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')))
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')))
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")),
)
users_fillingstations = db.Table('users_fillingstations',
db.Column('user_id', db.Integer(), db.ForeignKey('user.id')),
db.Column('fillingstation_id', db.Integer(), db.ForeignKey('filling_station.int_id')))
users_fillingstations = db.Table(
"users_fillingstations",
db.Column("user_id", db.Integer(), db.ForeignKey("user.id")),
db.Column(
"fillingstation_id", db.Integer(), db.ForeignKey("filling_station.int_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))
@ -34,6 +43,7 @@ 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))
@ -43,17 +53,12 @@ class User(db.Model, UserMixin):
home_long = db.Column(db.Numeric(8, 5), default=0)
home_zoom = db.Column(db.Integer(), default=0)
vehicles = db.relationship(
'Vehicle'
)
vehicles = db.relationship("Vehicle")
roles = db.relationship(
'Role',
secondary=roles_users,
backref=db.backref('users', lazy='dynamic')
"Role", secondary=roles_users, backref=db.backref("users", lazy="dynamic")
)
favourite_filling_stations = db.relationship(
'FillingStation',
secondary=users_fillingstations
"FillingStation", secondary=users_fillingstations
)
def __repr__(self):
@ -69,31 +74,26 @@ class Vehicle(db.Model):
* list of pitstops
* list of possible consumables
"""
id = db.Column(db.Integer, primary_key=True)
owner_id = db.Column(db.Integer, db.ForeignKey('user.id'))
owner_id = db.Column(db.Integer, db.ForeignKey("user.id"))
name = db.Column(db.String(255))
pitstops = db.relationship(
'Pitstop',
order_by="asc(Pitstop.odometer)"
)
services = db.relationship(
'Service',
order_by = "asc(Service.odometer)"
)
consumables = db.relationship(
'Consumable',
secondary=vehicles_consumables
)
pitstops = db.relationship("Pitstop", order_by="asc(Pitstop.odometer)")
services = db.relationship("Service", order_by="asc(Service.odometer)")
regulars = db.relationship("RegularCost")
consumables = db.relationship("Consumable", secondary=vehicles_consumables)
# allow vehicle names to be duplicated between different owners but must still be uniq for each owner
__table_args__ = (db.UniqueConstraint('owner_id',
'name',
name='_owner_name_uniq'),)
__table_args__ = (db.UniqueConstraint("owner_id", "name", name="_owner_name_uniq"),)
def __init__(self, name):
self.name = name
def __repr__(self):
return '<Vehicle id="%r" owner_id="%r" name="%r" />' % (self.id, self.owner_id, self.name)
return '<Vehicle id="%r" owner_id="%r" name="%r" />' % (
self.id,
self.owner_id,
self.name,
)
class Pitstop(db.Model):
@ -107,21 +107,26 @@ class Pitstop(db.Model):
* 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'))
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'))
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'),)
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
@ -131,8 +136,31 @@ class Pitstop(db.Model):
self.consumable_id = consumable_id
def __repr__(self):
return '<Pitstop odometer="%r" amount="%r" date="%r" vehicle_id="%r" consumable_id="%r">' % \
(self.odometer, self.amount, self.date, self.vehicle_id, self.consumable_id)
return '<Pitstop odometer="%r" amount="%r" date="%r" vehicle_id="%r" consumable_id="%r">' % (
self.odometer,
self.amount,
self.date,
self.vehicle_id,
self.consumable_id,
)
class RegularCost(db.Model):
id = db.Column(db.Integer, primary_key=True)
vehicle_id = db.Column(db.Integer, db.ForeignKey("vehicle.id"))
description = db.Column(db.String(4096), unique=True)
costs = db.Column(db.Numeric(10, 2), default=0)
days = db.Column(db.String(1024))
start_at = db.Column(db.Date)
ends_at = db.Column(db.Date)
def __init__(self, vehicle_id, description, costs, days, start_at, ends_at):
self.vehicle_id = vehicle_id
self.description = description
self.costs = costs
self.days = days
self.start_at = start_at
self.ends_at = ends_at
class Consumable(db.Model):
@ -142,15 +170,13 @@ class Consumable(db.Model):
* name (must be globally unique)
* unit
"""
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), unique=True)
ext_id = db.Column(db.String(255))
unit = db.Column(db.String(255))
vehicles = db.relationship(
'Vehicle',
secondary=vehicles_consumables
)
vehicles = db.relationship("Vehicle", secondary=vehicles_consumables)
def __init__(self, name, ext_id, unit):
self.name = name
@ -165,7 +191,7 @@ class Service(db.Model):
id = db.Column(db.Integer, primary_key=True)
date = db.Column(db.Date)
odometer = db.Column(db.Integer)
vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicle.id'))
vehicle_id = db.Column(db.Integer, db.ForeignKey("vehicle.id"))
costs = db.Column(db.Numeric(10, 2), default=0)
description = db.Column(db.String(4096))
@ -177,8 +203,10 @@ class Service(db.Model):
self.vehicle_id = vehicle_id
def __repr__(self):
return '<Service odometer="%r" date="%r" vehicle_id="%r" costs="%r" description="%r">' % \
(self.odometer, self.date, self.vehicle_id, self.costs, self.description)
return (
'<Service odometer="%r" date="%r" vehicle_id="%r" costs="%r" description="%r">'
% (self.odometer, self.date, self.vehicle_id, self.costs, self.description)
)
class FillingStation(db.Model):
@ -203,6 +231,7 @@ class FillingStation(db.Model):
for c in self.__table__.columns:
val = getattr(self, c.name)
import decimal
if isinstance(val, decimal.Decimal):
val = float(val)
val = str(val)

View File

@ -4,3 +4,4 @@ from .checks import *
from .consumable import *
from .service import *
from .vehicle import *
from .regular_cost import *

View File

@ -2,10 +2,34 @@ from wtforms.validators import ValidationError
from datetime import date
def regular_costs_days_check(form, field):
"""
Checks the input field to enter multiple days in the following format:
`01-15,07-15` for Jan 15th and July 15
"""
days = form.days.data
for day in days.split(","):
day = day.strip()
if not day:
raise ValidationError("Missing Date after ','")
try:
m, d = day.split("-")
m_i = int(m)
d_i = int(d)
except Exception:
raise ValidationError("Malformed Date, must be 'Month-Day'")
try:
d = date(2021, m_i, d_i)
except Exception:
raise ValidationError("{}-{} is not a valid date".format(m, d))
def odometer_date_check(form, field):
"""
Checks that the entered date and odometer of the pit stop is conformant to the existing pit stops. That means, if a
pitstops date is between two other pit stops, the odometer should be as well.
Checks that the entered date and odometer of the pit stop is conformant to
the existing pit stops. That means, if a pitstops date is between two
other pit stops, the odometer should be as well.
:param form:
:param field:
:return:
@ -16,23 +40,37 @@ def odometer_date_check(form, field):
if len(pitstops) > 0:
if date < pitstops[0].date and odometer >= pitstops[0].odometer:
raise ValidationError('The new odometer value must be less than %i km' % pitstops[0].odometer)
raise ValidationError(
"The new odometer value must be less than %i km"
% pitstops[0].odometer
)
if date >= pitstops[-1].date and odometer <= pitstops[-1].odometer:
raise ValidationError('The new odometer value must be greater than %i km' % pitstops[-1].odometer)
raise ValidationError(
"The new odometer value must be greater than %i km"
% pitstops[-1].odometer
)
if len(pitstops) > 1:
for index in range(0, len(pitstops)-1):
for index in range(0, len(pitstops) - 1):
if pitstops[index].date <= date < pitstops[index + 1].date:
if odometer <= pitstops[index].odometer or odometer >= pitstops[index+1].odometer:
raise ValidationError('The new odometer value must be greater than %i km and less than %i km'
% (pitstops[index].odometer,pitstops[index+1].odometer))
if (
odometer <= pitstops[index].odometer
or odometer >= pitstops[index + 1].odometer
):
raise ValidationError(
"The new odometer value must be greater than %i km and less than %i km"
% (
pitstops[index].odometer,
pitstops[index + 1].odometer,
)
)
def edit_odometer_date_check(form, field):
"""
This makes exactly the same checks as 'odometer_date_check' but the odometers may be the same (to change only amount
and price).
This makes exactly the same checks as 'odometer_date_check' but the
odometers may be the same (to change only amount and price).
:param form:
:param field:
@ -44,31 +82,50 @@ def edit_odometer_date_check(form, field):
if len(pitstops) > 0:
if date < pitstops[0].date and odometer > pitstops[0].odometer:
raise ValidationError('The new odometer value must be less than %i km' % pitstops[0].odometer)
raise ValidationError(
"The new odometer value must be less than %i km"
% pitstops[0].odometer
)
if date >= pitstops[-1].date and odometer < pitstops[-1].odometer:
raise ValidationError('The new odometer value must be greater than %i km' % pitstops[-1].odometer)
raise ValidationError(
"The new odometer value must be greater than %i km"
% pitstops[-1].odometer
)
if len(pitstops) > 1:
for index in range(0, len(pitstops)-1):
for index in range(0, len(pitstops) - 1):
if pitstops[index].date <= date < pitstops[index + 1].date:
if odometer < pitstops[index].odometer or odometer > pitstops[index+1].odometer:
raise ValidationError('The new odometer value must be greater than %i km and less than %i km'
% (pitstops[index].odometer,pitstops[index+1].odometer))
if (
odometer < pitstops[index].odometer
or odometer > pitstops[index + 1].odometer
):
raise ValidationError(
"The new odometer value must be greater than %i km and less than %i km"
% (
pitstops[index].odometer,
pitstops[index + 1].odometer,
)
)
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.
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)
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())
raise ValidationError(
"The new date must not be after %s" % date.today()
)
def odometer_check(form, field):
@ -78,20 +135,29 @@ def odometer_check(form, field):
: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 (
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)
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')
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 €.')
raise ValidationError("Costs must be above 0.01 €.")
def edit_costs_check(form, field):
@ -101,8 +167,8 @@ def edit_costs_check(form, field):
:param field:
:return:
"""
costs_check_required = (form.costs.default is not None and form.costs.default > 0)
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 €.')
raise ValidationError("Costs must be above 0.01 €.")

86
app/forms/regular_cost.py Normal file
View File

@ -0,0 +1,86 @@
from flask_wtf import FlaskForm
from wtforms import (
DateField,
IntegerField,
DecimalField,
SubmitField,
TextAreaField,
StringField,
)
from wtforms.validators import Length
from .checks import *
from wtforms.validators import Optional
class DeleteRegularCostForm(FlaskForm):
submit = SubmitField(label="Really delete this regular cost!")
class EndRegularCostForm(FlaskForm):
submit = SubmitField(label="Really end this regular cost!")
class EditRegularCostForm(FlaskForm):
start_at = DateField("Date of first instance")
ends_at = DateField(
"Date of last instance", validators=[Optional(strip_whitespace=True)]
)
days = StringField("Days for instance", validators=[regular_costs_days_check])
costs = DecimalField("Costs (€, per instance)", places=2, validators=[costs_check])
description = TextAreaField("Description", validators=[Length(1, 4096)])
submit = SubmitField(label="Update it!")
def preinit_with_data(self):
if self.costs.data:
self.costs.default = self.costs.data
if self.start_at.data:
self.start_at.default = self.start_at.data
if self.ends_at.data:
self.ends_at.default = self.ends_at.data
if self.days.data:
self.days.default = self.days.data
if self.description.data:
self.description.default = self.description.data
def get_hint_messages(self):
messages = {"costs": "Costs must be higher than 0.01 €."}
return messages
class CreateRegularCostForm(FlaskForm):
start_at = DateField("Date of first instance")
ends_at = DateField(
"Date of last instance", validators=[Optional(strip_whitespace=True)]
)
days = StringField("Days for instance", validators=[regular_costs_days_check])
costs = DecimalField("Costs (€, per instance)", places=2, validators=[costs_check])
description = TextAreaField("Description", validators=[Length(1, 4096)])
submit = SubmitField(label="Do it!")
def preinit_with_data(self):
if self.start_at.data:
self.start_at.default = self.start_at.data
else:
self.start_at.default = date.today()
if self.ends_at.data:
self.ends_at.default = self.ends_at.data
else:
self.ends_at.default = None
if self.days.data:
self.days.default = self.days.data
if self.costs.data:
self.costs.default = self.costs.data
else:
self.costs.default = 0
if self.description.data:
self.description.default = self.description.data

View File

@ -3,4 +3,5 @@ from .admin import *
from .misc import *
from .pitstop import *
from .service import *
from .filling_stations import *
from .filling_stations import *
from .regular_cost import *

View File

@ -3,11 +3,16 @@ from flask_security import login_required
from flask_security.core import current_user
from sqlalchemy.exc import IntegrityError
from datetime import date
import types
from ..entities import Vehicle, Consumable, Pitstop
from ..forms import SelectVehicleForm, SelectConsumableForm, CreatePitstopForm, EditPitstopForm, DeletePitStopForm
from ..tools import db_log_update, db_log_delete, db_log_add, pitstop_service_key, \
get_event_line_for_vehicle, update_filling_station_prices
from ..forms import SelectVehicleForm, SelectConsumableForm, \
CreatePitstopForm, EditPitstopForm, DeletePitStopForm
from ..tools import db_log_update, db_log_delete, db_log_add, \
pitstop_service_key, get_event_line_for_vehicle, \
update_filling_station_prices, RegularCostInstance, \
calculate_regular_cost_instances
from .. import app, db
@ -162,11 +167,15 @@ def get_pit_stops():
data.append(pitstop)
for service in vehicle.services:
data.append(service)
for regular_instance in calculate_regular_cost_instances(vehicle):
data.append(regular_instance)
data.sort(key=pitstop_service_key)
v = {
'id': vehicle.id,
'name': vehicle.name,
'data': data
'data': data,
"regulars": vehicle.regulars,
}
user['vehicles'].append(v)

161
app/routes/regular_cost.py Normal file
View File

@ -0,0 +1,161 @@
from flask import url_for, redirect, render_template, flash
from flask_security import login_required
from flask_security.core import current_user
from sqlalchemy.exc import IntegrityError
from datetime import date
from ..entities import Vehicle, Consumable, Pitstop, RegularCost
from ..forms import (
SelectVehicleForm,
CreateRegularCostForm,
DeleteRegularCostForm,
EditRegularCostForm,
EndRegularCostForm,
)
from ..tools import (
db_log_update,
db_log_delete,
db_log_add,
pitstop_service_key,
get_event_line_for_vehicle,
update_filling_station_prices,
)
from .. import app, db
@app.route("/regular_costs/delete/<int:pid>", methods=["GET", "POST"])
@login_required
def delete_regular_form(pid):
regular_cost = RegularCost.query.filter(RegularCost.id == pid).first()
if regular_cost is None:
return redirect(url_for("get_pit_stops"))
vehicle = Vehicle.query.filter(Vehicle.id == regular_cost.vehicle_id).first()
if vehicle not in current_user.vehicles:
return redirect(url_for("get_pit_stops"))
form = DeleteRegularCostForm()
if form.validate_on_submit():
db.session.delete(regular_cost)
db.session.commit()
db_log_delete(regular_cost)
return redirect(url_for("get_pit_stops", _anchor="v" + str(vehicle.id)))
return render_template(
"deleteRegularCostForm.html", form=form, regular_cost=regular_cost
)
@app.route("/regular_costs/vehicle/select", methods=["GET", "POST"])
@login_required
def select_vehicle_for_new_regular_cost():
if len(current_user.vehicles) == 1:
return redirect(
url_for("create_regular_cost_for_vehicle", 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("create_regular_cost_for_vehicle", vid=form.vehicle.data)
)
return render_template("selectVehicle.html", form=form)
@app.route("/regular_costs/vehicle/<int:vid>/create", methods=["GET", "POST"])
@login_required
def create_regular_cost_for_vehicle(vid):
vehicle = Vehicle.query.get(vid)
if vehicle is None or vehicle not in current_user.vehicles:
return redirect(url_for("get_account_page"))
form = CreateRegularCostForm()
form.preinit_with_data()
if form.validate_on_submit():
regular_cost = RegularCost(
vid,
form.description.data,
form.costs.data,
form.days.data,
form.start_at.data,
form.ends_at.data,
)
db.session.add(regular_cost)
vehicle.regulars.append(regular_cost)
db.session.commit()
return redirect(url_for("get_pit_stops", _anchor="v" + str(vehicle.id)))
form.process()
return render_template(
"createRegularCostForm.html", form=form, vehicle=vehicle, messages=[]
)
@app.route("/regular_costs/edit/<int:pid>", methods=["GET", "POST"])
@login_required
def edit_regular_form(pid):
edit_regular = RegularCost.query.get(pid)
if edit_regular is None:
return redirect(url_for("get_pit_stops"))
vehicle = Vehicle.query.filter(Vehicle.id == edit_regular.vehicle_id).first()
if vehicle not in current_user.vehicles:
return redirect(url_for("get_pit_stops"))
form = EditRegularCostForm()
form.preinit_with_data()
if not form.is_submitted():
form.costs.default = edit_regular.costs
form.start_at.default = edit_regular.start_at
form.ends_at.default = edit_regular.ends_at
form.days.default = edit_regular.days
form.description.default = edit_regular.description
if form.validate_on_submit():
edit_regular.start_at = form.start_at.data
edit_regular.ends_at = form.ends_at.data
edit_regular.costs = form.costs.data
edit_regular.days = form.days.data
edit_regular.description = form.description.data
db.session.commit()
db_log_update(edit_regular)
return redirect(url_for("get_pit_stops", _anchor="v" + str(vehicle.id)))
form.preinit_with_data()
form.process()
return render_template(
"editRegularCostForm.html",
form=form,
vehicle=vehicle,
messages=form.get_hint_messages(),
)
@app.route("/regular_costs/end/<int:pid>", methods=["GET", "POST"])
@login_required
def end_regular_form(pid):
edit_regular = RegularCost.query.get(pid)
if edit_regular is None:
return redirect(url_for("get_pit_stops"))
vehicle = Vehicle.query.filter(Vehicle.id == edit_regular.vehicle_id).first()
if vehicle not in current_user.vehicles:
return redirect(url_for("get_pit_stops"))
form = EndRegularCostForm()
if form.validate_on_submit():
edit_regular.ends_at = date.today()
db.session.commit()
return redirect(url_for("get_pit_stops", _anchor="v" + str(vehicle.id)))
return render_template(
"endRegularCostForm.html",
form=form,
vehicle=vehicle,
)

View File

@ -12,7 +12,7 @@ function createChart(id, data, unit) {
"axisAlpha": 0,
"position": "left",
"ignoreAxisWidth":true,
// "title": unit
"title": unit
}],
"balloon": {
"borderThickness": 1,

View File

@ -0,0 +1,39 @@
{% extends "layout.html" %}
{% block body %}
<div class="col-md-2" ></div>
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-body">
<h3>New Regular Cost for '{{ vehicle.name }}'</h3>
<form class='form-horizontal' method="POST">
{{ form.hidden_tag() }}
{{ render_field_with_errors(form.start_at) }}
<span id="{{form.start_at.id}}_help" class="help-block">
{{messages['start_at']}}
</span>
{{ render_field_with_errors(form.ends_at) }}
<span id="{{form.ends_at.id}}_help" class="help-block">
{{messages['ends_at']}}
</span>
{{ render_field_with_errors(form.days) }}
<span>Format as 'Month-Day' (e.g. 05-25) and separate with ','.</span>
<span id="{{form.days.id}}_help" class="help-block">
{{messages['days']}}
</span>
{{ render_field_with_errors(form.costs) }}
<span id="{{form.costs.id}}_help" class="help-block">
{{messages['costs']}}
</span>
{{ render_field_with_errors(form.description) }}
{{ render_field_with_errors(form.submit) }}
</form>
</div>
</div>
</div>
<div class="col-md-2" ></div>
{% endblock %}

View File

@ -0,0 +1,46 @@
{% extends 'layout.html' %}
{% block body %}
<div class='col-md-2' ></div>
<div class='col-md-8'>
<div class='panel panel-default'>
<div class='panel-body'>
<h3>Delete Regular Cost?</h3>
<table style='width: 100%' class="table table-striped table-bordered table-condensed">
<tr>
<th style='text-align:right'>Description of regular cost</th>
<td style='text-align: left'>{{ regular_cost.description }}</td>
</tr>
<tr>
<th style='text-align:right'>Costs (per instance)</th>
<td style='text-align: left'>
{% if regular_cost.costs %}
{{ regular_cost.costs }}
{% else %}
--
{% endif %}
</td>
</tr>
<tr>
<th style='text-align:right'>Days for regular costs</th>
<td style='text-align: left'>{{ regular_cost.days }}</td>
</tr>
<tr>
<th style='text-align:right'>regular costs starting from</th>
<td style='text-align: left'>{{ regular_cost.start_at }}</td>
</tr>
<tr>
<th style='text-align:right'>regular costs ending at</th>
<td style='text-align: left'>{{ regular_cost.ends_at }}</td>
</tr>
</table>
<form class='form-horizontal' method='POST'>
{{ form.hidden_tag() }}
{{ render_field_with_errors(form.submit) }}
</form>
</div>
</div>
</div>
<div class='col-md-2' ></div>
{% endblock %}

View File

@ -8,7 +8,7 @@
<h3>Delete vehicle '{{vehicle.name}}'?</h3>
<form class='form-horizontal' method="POST">
{{ form.hidden_tag() }}
{{ render_field_with_errors(form.submit) }}
{{ render_field_with_errors(form.submit, include_cancel=True) }}
</form>
</div>

View File

@ -0,0 +1,39 @@
{% extends "layout.html" %}
{% block body %}
<div class="col-md-2" ></div>
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-body">
<h3>Edit Regular Cost for '{{ vehicle.name }}'</h3>
<form class='form-horizontal' method="POST">
{{ form.hidden_tag() }}
{{ render_field_with_errors(form.start_at) }}
<span id="{{form.start_at.id}}_help" class="help-block">
{{messages['start_at']}}
</span>
{{ render_field_with_errors(form.ends_at) }}
<span id="{{form.ends_at.id}}_help" class="help-block">
{{messages['ends_at']}}
</span>
{{ render_field_with_errors(form.days) }}
<span>Format as 'Month-Day' (e.g. 05-25) and separate with ','.</span>
<span id="{{form.days.id}}_help" class="help-block">
{{messages['days']}}
</span>
{{ render_field_with_errors(form.costs) }}
<span id="{{form.costs.id}}_help" class="help-block">
{{messages['costs']}}
</span>
{{ render_field_with_errors(form.description) }}
{{ render_field_with_errors(form.submit) }}
</form>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,16 @@
{% extends "layout.html" %}
{% block body %}
<div class="col-md-2" ></div>
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-body">
<h3>Edit Regular Cost for '{{ vehicle.name }}'</h3>
<form class='form-horizontal' method="POST">
{{ form.hidden_tag() }}
{{ render_field_with_errors(form.submit) }}
</form>
</div>
</div>
</div>
{% endblock %}

View File

@ -3,6 +3,7 @@
<li><a id='plan_pitstop_link' href='{{ url_for('select_vehicle_for_plan_pitstop') }}'>Plan Pitstop</a></li>
<li><a id='new_pitstop_link' href='{{ url_for('select_vehicle_for_new_pitstop') }}'>Create Pitstop</a></li>
<li><a id='new_service_link' href='{{ url_for('select_vehicle_for_new_service') }}'>Create Service</a></li>
<li><a id='new_service_link' href='{{ url_for('select_vehicle_for_new_regular_cost') }}'>Create Regular Cost</a></li>
<li><a id='statistics_limk' href='{{ url_for('get_statistics') }}'>Statistics</a></li>
<li><a id='account_link' href='{{ url_for('get_account_page') }}'>Account</a></li>
{% if current_user.has_role('admin') %}
@ -15,20 +16,17 @@
{% endif %}
{%- endmacro %}
{% macro render_field_with_errors(field) %}
{% macro render_field_with_errors(field, include_cancel=True) %}
<div class="form-group">
{% if field.type == 'SubmitField' %}
<div class="col-md-4" ></div>
<div class="col-sm-4" style="align:center">
<div class="col-md-3" ></div>
<div class="col-sm-6" style="align:center">
{% if include_cancel %}
<input id="{{ field.id }}_cancel" name="{{ field.id }}_cancel" class="btn btn-default" type="submit" value="Cancel" onclick="window.history.go(-1)">
{% endif %}
<input id="{{ field.id }}" name="{{ field.id }}" class="btn btn-default" type="submit" value="{{ field.label.text }}">
</div>
<!--
<div class="col-sm-3" style="align:center">
<a class="btn btn-default" href="{{ g.data['back'] }}" role="button">Cancel</a>
</div>
-->
<div class="col-md-4" ></div>
<div class="col-md-3" ></div>
{% else %}
<label class="col-sm-6 control-label">
{{ field.label }}

View File

@ -1,5 +1,92 @@
{% extends "layout.html" %}
{% macro regular(field, vindex, loop) -%}
<div class="panel panel-default">
<div class="panel-body">
<div style="text-align: left; font-size: 20px;">
<span class="glyphicon glyphicon-repeat" aria-hidden="true" style="border: 1px solid black; padding: 5px 5px 3px; border-radius: 5px;" />
</div>
<table class="table table-striped table-bordered table-condensed">
<tr>
<th>Description</th>
<td id="vehicle_{{vindex}}_pitstop_{{loop.index}}_date">{{field.description}}</td>
</tr>
<tr>
<th>Costs</th>
<td id="vehicle_{{vindex}}_pitstop_{{loop.index}}_cost">
{% if field.costs %}
{{field.costs}} €
{% else %}
-- €
{% endif %}
</td>
</tr>
<tr>
<th>From</th>
<td id="vehicle_{{vindex}}_pitstop_{{loop.index}}_date">{{field.start_at}}</td>
</tr>
<tr>
<th>To</th>
<td id="vehicle_{{vindex}}_pitstop_{{loop.index}}_date">{{field.ends_at}}</td>
</tr>
<tr>
<th>Days</th>
<td id="vehicle_{{vindex}}_pitstop_{{loop.index}}_date">{{field.days}}</td>
</tr>
</table>
{% if loop.first %}
{% endif %}
<a id="vehicle_{{vindex}}_edit_regular_{{loop.index}}" href="{{ url_for('edit_regular_form', pid=field.id) }}" class="btn btn-primary">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> edit
</a>
<a id="vehicle_{{vindex}}_delete_regular_{{loop.index}}" href="{{ url_for('delete_regular_form', pid=field.id) }}" class="btn btn-primary btn-warning ">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> delete
</a>
</div>
</div>
{%- endmacro %}
{% macro regular_instance(field, vindex, loop) -%}
<div class="panel panel-default">
<div class="panel-body">
<div style="text-align: left; font-size: 20px;">
<span class="glyphicon glyphicon-repeat" aria-hidden="true" style="border: 1px solid black; padding: 5px 5px 3px; border-radius: 5px;"></span>
</div>
<table class="table table-striped table-bordered table-condensed">
<tr>
<th>Date</th>
<td id="vehicle_{{vindex}}_pitstop_{{loop.index}}_date">{{field.date}}</td>
</tr>
<tr>
<th>Description</th>
<td id="vehicle_{{vindex}}_pitstop_{{loop.index}}_date">{{field.name}}</td>
</tr>
<tr>
<th>Costs</th>
<td id="vehicle_{{vindex}}_pitstop_{{loop.index}}_cost">
{% if field.costs %}
{{field.costs}} €
{% else %}
-- €
{% endif %}
</td>
</tr>
</table>
{% if loop.first %}
{% endif %}
<a id="vehicle_{{vindex}}_edit_regular_{{loop.index}}" href="{{ url_for('edit_regular_form', pid=field.id) }}" class="btn btn-primary">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> edit
</a>
<a id="vehicle_{{vindex}}_end_regular_{{loop.index}}" href="{{ url_for('end_regular_form', pid=field.id) }}" class="btn btn-primary">
<span class="glyphicon glyphicon-remove-sign" aria-hidden="true"></span> end series
</a>
<a id="vehicle_{{vindex}}_delete_regular_{{loop.index}}" href="{{ url_for('delete_regular_form', pid=field.id) }}" class="btn btn-primary btn-warning ">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> delete
</a>
</div>
</div>
{%- endmacro %}
{% macro pitstop(field, vindex, loop) -%}
<div class="panel panel-default">
<div class="panel-body">
@ -110,12 +197,22 @@
{% if 'Service' in data.__class__.__name__ %}
{{ service(data, vehicleloop.index, loop) }}
{% endif %}
{% if 'Regular' in data.__class__.__name__ %}
{{ regular_instance(data, vehicleloop.index, loop) }}
{% endif %}
{% endfor %}
{% else %}
<div class="alert alert-warning" role="alert">
not enough data: <a href="{{ url_for('select_consumable_for_new_pitstop', vid=vehicle.id) }}">log a pitstop</a>?
</div>
{% endif %}
{% if vehicle.regulars %}
<h4>Regular Costs</h4>
{% for data in vehicle.regulars %}
{{ regular(data, vehicleloop.index, loop) }}
{% endfor %}
{% endif %}
</div>
{% endfor %}
</div>

View File

@ -25,7 +25,14 @@
{% macro nav_tab(id, text, active) %}
{#
Create a UI element to select the shown pane of a tabbed view
id:
id of the pane to select
text:
the text in the UI element
active:
boolean stating if the tab is active or not
#}
<li class="{% if active %}active{% endif %}">
<a href="#ref_{{id}}" id="id_{{id}}" data-toggle="tab" >
@ -87,7 +94,15 @@
<th>Logged Costs:</th>
<td>{{ vehicle.overall_costs | round(2) }} €</td>
</tr>
</table>
<tr>
<th>Logged Costs per km:</th>
{% if vehicle.costs_per_distance != 'N/A' %}
<td>{{ vehicle.costs_per_distance | round(2) }} €/100km</td>
{% else %}
<td>{{ vehicle.costs_per_distance }}</td>
{% endif %}
</tr>
</table>
</div>
{% endmacro %}
@ -115,6 +130,7 @@
{{ print_vehicle_table(vehicle) }}
<ul id="vehicle_{{vehicle.id}}_tabs" class="nav nav-tabs" data-tabs="tabs">
{{ nav_tab(vehicle.id|string + '_odometer', 'Odometer', true) }}
{{ nav_tab(vehicle.id|string + '_costs', 'Costs', false) }}
{% for consumable in vehicle.consumables %}
{{ nav_tab(vehicle.id|string + '_' + consumable.id|string, consumable.name, false) }}
{% endfor %}
@ -131,6 +147,17 @@
true
)
}}
{{ tab_pane(
vehicle.id|string + '_costs',
chart(
vehicle.costs,
'ref_' + vehicle.id|string + '_costs',
'€',
url_for('select_consumable_for_new_pitstop', vid=vehicle.id)
),
false
)
}}
{% for consumable in vehicle.consumables %}
<div class="tab-pane" id="ref_{{vehicle.id}}_{{consumable.id}}">
{{ print_consumable_table(consumable) }}

View File

@ -7,6 +7,15 @@ from .entities import Pitstop, FillingStation
from . import db, app
class RegularCostInstance:
def __init__(self, regular_id, date, odometer, costs, name):
self.id = regular_id
self.date = date
self.odometer = odometer
self.costs = costs
self.name = name
class ConsumableStats:
def __init__(self, vehicle, consumable):
self.name = consumable.name
@ -19,7 +28,9 @@ class ConsumableStats:
self.average_amount = []
self.amounts = []
pitstops = [stop for stop in vehicle.pitstops if stop.consumable_id == consumable.id]
pitstops = [
stop for stop in vehicle.pitstops if stop.consumable_id == consumable.id
]
pitstop_count = len(pitstops)
if pitstop_count > 0:
@ -28,16 +39,27 @@ class ConsumableStats:
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
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
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)))
round(
100
* current_ps.amount
/ (current_ps.odometer - last_ps.odometer),
2,
),
)
)
class VehicleStats:
@ -48,6 +70,8 @@ class VehicleStats:
self.overall_costs = 0
self.consumables = []
self.odometers = []
self.costs = []
self.costs_per_distance = "N/A"
for consumable in vehicle.consumables:
self.consumables.append(ConsumableStats(vehicle, consumable))
@ -60,27 +84,47 @@ class VehicleStats:
self.odometers.append(StatsEvent(pitstop.date, pitstop.odometer))
if pitstop.costs is not None:
self.overall_costs += pitstop.costs
self.costs.append(StatsEvent(pitstop.date, pitstop.costs))
# add the instances to the overall costs
for regular_cost_instance in calculate_regular_cost_instances(vehicle):
self.overall_costs += regular_cost_instance.costs
self.costs.append(
StatsEvent(regular_cost_instance.date, regular_cost_instance.costs)
)
if pitstop_count > 1:
self.overall_distance = events[-1].odometer - events[0].odometer
self.costs.sort(key=lambda x: x.date)
accumulated_costs = 0
for c in self.costs:
accumulated_costs += c.value
c.value = accumulated_costs
if self.overall_distance > 0:
self.costs_per_distance = float(self.overall_costs) / (float(self.overall_distance) / 100)
class StatsEvent:
def __init__(self, date, value):
self.date = date
self.value = value
def __repr__(self):
return str(self.date)
def db_log_add(entity):
logging.info('db_add: %s' % str(entity))
logging.info("db_add: %s" % str(entity))
def db_log_delete(entity):
logging.info('db_delete: %s' % str(entity))
logging.info("db_delete: %s" % str(entity))
def db_log_update(entity):
logging.info('db_update: %s' % str(entity))
logging.info("db_update: %s" % str(entity))
def check_vehicle_name_is_unique(current_user, name_field):
@ -107,10 +151,11 @@ def get_latest_pitstop_for_vehicle(vehicle_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()) \
latest_pitstop = (
Pitstop.query.filter(Pitstop.vehicle_id == vehicle_id)
.order_by(Pitstop.id.desc())
.first()
)
return latest_pitstop
@ -121,15 +166,18 @@ def get_latest_pitstop_for_vehicle_and_consumable(vehicle_id, consumable_id):
: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()) \
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):
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:
@ -154,7 +202,7 @@ def compute_lower_limits_for_new_pitstop(latest_pitstop, last_pitstop_consumable
def pitstop_service_key(x):
return x.odometer, x.date
return x.date, x.odometer
def get_event_line_for_vehicle(vehicle):
@ -170,45 +218,79 @@ def get_event_line_for_vehicle(vehicle):
def chunks(l, n):
"""Yield successive n-sized chunks from l."""
for i in range(0, len(l), n):
yield l[i:i + n]
yield l[i : i + n]
def update_filling_station_prices(ids):
max_age = (datetime.now() - timedelta(minutes=15)).strftime('%Y-%m-%d %H:%M')
max_age = (datetime.now() - timedelta(minutes=15)).strftime("%Y-%m-%d %H:%M")
res = db.session. \
query(FillingStation). \
filter(FillingStation.id.in_(ids)). \
filter(or_(FillingStation.last_update == None, FillingStation.last_update < max_age)). \
all()
res = (
db.session.query(FillingStation)
.filter(FillingStation.id.in_(ids))
.filter(
or_(
FillingStation.last_update == None, FillingStation.last_update < max_age
)
)
.all()
)
if len(res) > 0:
id_map = {x.id: x for x in res}
query_ids = [x.id for x in res]
api_key = app.config['TANKERKOENIG_API_KEY']
url = 'https://creativecommons.tankerkoenig.de/json/prices.php'
api_key = app.config["TANKERKOENIG_API_KEY"]
url = "https://creativecommons.tankerkoenig.de/json/prices.php"
# documentation tells us to query max 10 filling stations at a time...
for c in chunks(query_ids, 10):
params = {
'apikey': api_key, 'ids': ','.join(c)
}
params = {"apikey": api_key, "ids": ",".join(c)}
response = requests.get(url, params=params)
response_json = response.json()
if response_json['ok']:
print(response_json)
prices = response_json['prices']
if response_json["ok"]:
prices = response_json["prices"]
for price in prices:
id = price
station_status = prices[id]
id_map[id].open = station_status['status'] == 'open'
id_map[id].open = station_status["status"] == "open"
if id_map[id].open:
id_map[id].diesel = station_status['diesel']
id_map[id].e10 = station_status['e10']
id_map[id].e5 = station_status['e5']
id_map[id].diesel = station_status["diesel"]
id_map[id].e10 = station_status["e10"]
id_map[id].e5 = station_status["e5"]
id_map[id].last_update = datetime.now()
else:
logging.error(
'could not update filling stations because of {r} on URL {u}.'.format(r=str(response_json),
u=response.url))
"could not update filling stations because of {r} on URL {u}.".format(
r=str(response_json), u=response.url
)
)
db.session.commit()
def calculate_regular_cost_instances(vehicle):
data = []
for regular in vehicle.regulars:
if date.today() < regular.start_at:
# skip regular costs that are not yet active
continue
if regular.ends_at:
end_date = regular.ends_at
else:
end_date = date.today()
start_year = regular.start_at.year
end_year = end_date.year
for year in range(start_year, end_year + 1):
for day in regular.days.split(","):
m, d = day.split("-")
d = date(year, int(m), int(d))
if regular.start_at <= d and d <= end_date:
r = RegularCostInstance(
regular.id,
date=d,
odometer=None,
costs=regular.costs,
name=regular.description,
)
data.append(r)
return data