diff --git a/Dockerfile b/Dockerfile index 20f3c37..85b1b85 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,14 @@ -FROM debian8_python3 +FROM python:3 COPY requirements.txt /requirements.txt -RUN pip3 install -r /requirements.txt; \ - mkdir /data +RUN mkdir /data; \ + pip3 install -r /requirements.txt; ADD app /app ADD main.py /main.py ADD config.py /config.py VOLUME ["/data"] -VOLUME ["/app/config] +VOLUME ["/app/config"] EXPOSE 5000 ENTRYPOINT python3 /main.py diff --git a/README.md b/README.md index f1d2876..31087c6 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/app/__init__.py b/app/__init__.py index bb238a8..ca9660d 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -15,10 +15,11 @@ from .forms import * app = Flask(__name__) app.config.from_object(config[os.getenv('FLASK_CONFIG') or 'default']) +# applies to all routes, so choose limits wisely! limiter = Limiter( app, key_func=get_remote_address, - default_limits=["200 per day", "50 per hour"] +# default_limits=["500 per second"] ) diff --git a/app/entities.py b/app/entities.py index b2f8006..b7d04ee 100644 --- a/app/entities.py +++ b/app/entities.py @@ -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,29 +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' - ) - services = db.relationship( - 'Service' - ) - 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 '' % (self.id, self.owner_id, self.name) + return '' % ( + self.id, + self.owner_id, + self.name, + ) class Pitstop(db.Model): @@ -105,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 @@ -129,8 +136,31 @@ class Pitstop(db.Model): self.consumable_id = consumable_id def __repr__(self): - return '' % \ - (self.odometer, self.amount, self.date, self.vehicle_id, self.consumable_id) + return '' % ( + 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): @@ -140,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 @@ -163,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)) @@ -175,8 +203,10 @@ class Service(db.Model): self.vehicle_id = vehicle_id def __repr__(self): - return '' % \ - (self.odometer, self.date, self.vehicle_id, self.costs, self.description) + return ( + '' + % (self.odometer, self.date, self.vehicle_id, self.costs, self.description) + ) class FillingStation(db.Model): @@ -201,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) diff --git a/app/forms/__init__.py b/app/forms/__init__.py index e4f65bc..6a83e2c 100644 --- a/app/forms/__init__.py +++ b/app/forms/__init__.py @@ -4,3 +4,4 @@ from .checks import * from .consumable import * from .service import * from .vehicle import * +from .regular_cost import * diff --git a/app/forms/checks.py b/app/forms/checks.py index ff8b1bb..8efe182 100644 --- a/app/forms/checks.py +++ b/app/forms/checks.py @@ -2,18 +2,130 @@ 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. + + :param form: + :param field: + :return: + """ + odometer = form.odometer.data + date = form.date.data + pitstops = form.pitstops + + 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 + ) + + 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 + ) + + if 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, + ) + ) + + +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). + + :param form: + :param field: + :return: + """ + odometer = form.odometer.data + date = form.date.data + pitstops = form.pitstops + + 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 + ) + + 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 + ) + + if 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, + ) + ) + + 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): @@ -23,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): @@ -46,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 €.") diff --git a/app/forms/pitstop.py b/app/forms/pitstop.py index 842eacb..a8aa31c 100644 --- a/app/forms/pitstop.py +++ b/app/forms/pitstop.py @@ -9,16 +9,16 @@ class DeletePitStopForm(FlaskForm): class EditPitstopForm(FlaskForm): - date = DateField('Date of Pitstop', validators=[date_check]) - odometer = IntegerField('Odometer (km)', validators=[odometer_check]) + date = DateField('Date of Pitstop') + odometer = IntegerField('Odometer (km)', validators=[edit_odometer_date_check]) litres = DecimalField('Litres (l)', places=2, validators=[litres_check]) - costs = DecimalField('Costs (€, overall)', places=2, validators=[edit_costs_check]) + costs = DecimalField('Costs (€, overall)', places=2, validators=[costs_check]) submit = SubmitField(label='Update it!') - last_pitstop = None same_odometer_allowed = True + pitstops = [] - def set_pitstop(self, last_pitstop): - self.last_pitstop = last_pitstop + def set_pitstops(self, pitstops): + self.pitstops = pitstops def set_consumable(self, consumable): self.litres.label = '%s (%s)' % (consumable.name, consumable.unit) @@ -33,18 +33,25 @@ class EditPitstopForm(FlaskForm): if self.costs.data: self.costs.default = self.costs.data + def get_hint_messages(self): + messages = { + 'litres': 'Litres must be higher than 0.01 L.', + 'costs': 'Costs must be higher than 0.01 €.' + } + return messages + class CreatePitstopForm(FlaskForm): - date = DateField('Date of Pitstop', validators=[date_check]) - odometer = IntegerField('Odometer (km)', validators=[odometer_check]) + date = DateField('Date of Pitstop') + odometer = IntegerField('Odometer (km)', validators=[odometer_date_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 + pitstops = [] - def set_pitstop(self, last_pitstop): - self.last_pitstop = last_pitstop + def set_pitstops(self, pitstops): + self.pitstops = pitstops def set_consumable(self, consumable): self.litres.label = '%s (%s)' % (consumable.name, consumable.unit) @@ -54,27 +61,31 @@ class CreatePitstopForm(FlaskForm): self.date.default = self.date.data else: self.date.default = date.today() + if self.odometer.data: self.odometer.default = self.odometer.data + elif len(self.pitstops) > 0: + self.odometer.default = self.pitstops[-1].odometer else: - self.odometer.default = self.last_pitstop.odometer + self.odometer.default = 0 + if self.litres.data: self.litres.default = self.litres.data + elif len(self.pitstops) > 0 and 'amount' in self.pitstops[-1].__dict__: + self.litres.default = self.pitstops[-1].amount else: - self.litres.default = self.last_pitstop.amount + self.litres.default = 0 + if self.costs.data: self.costs.default = self.costs.data + elif len(self.pitstops) > 0: + self.costs.default = self.pitstops[-1].costs else: - self.costs.default = self.last_pitstop.costs + self.costs.default = 0 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)), + 'litres': 'Litres must be higher than 0.01 L.', 'costs': 'Costs must be higher than 0.01 €.' } return messages diff --git a/app/forms/regular_cost.py b/app/forms/regular_cost.py new file mode 100644 index 0000000..c8009e9 --- /dev/null +++ b/app/forms/regular_cost.py @@ -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 diff --git a/app/forms/service.py b/app/forms/service.py index 3e11c62..3415ead 100644 --- a/app/forms/service.py +++ b/app/forms/service.py @@ -6,15 +6,15 @@ from .checks import * class CreateServiceForm(FlaskForm): - date = DateField('Date of Service', validators=[date_check]) - odometer = IntegerField('Odometer (km)', validators=[odometer_check]) + date = DateField('Date of Pitstop') + odometer = IntegerField('Odometer (km)', validators=[odometer_date_check]) costs = DecimalField('Costs (€, overall)', places=2, validators=[costs_check]) description = TextAreaField('Description', validators=[Length(1, 4096)]) submit = SubmitField(label='Do it!') - last_pitstop = None + pitstops = [] - def set_pitstop(self, last_pitstop): - self.last_pitstop = last_pitstop + def set_pitstops(self, pitstops): + self.pitstops = pitstops def preinit_with_data(self): if self.date.data: @@ -24,45 +24,52 @@ class CreateServiceForm(FlaskForm): if self.odometer.data: self.odometer.default = self.odometer.data + elif len(self.pitstops) > 0: + self.odometer.default = self.pitstops[-1].odometer else: - self.odometer.default = self.last_pitstop.odometer + self.odometer.default = 0 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 + class DeleteServiceForm(FlaskForm): submit = SubmitField(label='Really delete this service!') class EditServiceForm(FlaskForm): - date = DateField('Date of Service', validators=[date_check]) - odometer = IntegerField('Odometer (km)', validators=[odometer_check]) + date = DateField('Date of Service') + odometer = IntegerField('Odometer (km)', validators=[edit_odometer_date_check]) costs = DecimalField('Costs (€, overall)', places=2, validators=[costs_check]) description = TextAreaField('Description', validators=[Length(1, 4096)]) submit = SubmitField(label='Do it!') - last_pitstop = None same_odometer_allowed = True + pitstops = [] - def set_pitstop(self, last_pitstop): - self.last_pitstop = last_pitstop + def set_pitstops(self, pitstops): + self.pitstops = pitstops def preinit_with_data(self): if self.date.data: self.date.default = self.date.data - else: - self.date.default = date.today() if self.odometer.data: self.odometer.default = self.odometer.data - else: - self.odometer.default = self.last_pitstop.odometer 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 + def get_hint_messages(self): + messages = { + 'litres': 'Litres must be higher than 0.01 L.', + 'costs': 'Costs must be higher than 0.01 €.' + } + return messages \ No newline at end of file diff --git a/app/routes/__init__.py b/app/routes/__init__.py index c169592..00e44b6 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -3,4 +3,5 @@ from .admin import * from .misc import * from .pitstop import * from .service import * -from .filling_stations import * \ No newline at end of file +from .filling_stations import * +from .regular_cost import * diff --git a/app/routes/pitstop.py b/app/routes/pitstop.py index 40b739b..c9b8446 100644 --- a/app/routes/pitstop.py +++ b/app/routes/pitstop.py @@ -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 @@ -64,11 +69,10 @@ def create_pit_stop_form(vid, cid): data = get_event_line_for_vehicle(vehicle) if len(data) > 0: - form.set_pitstop(Pitstop(data[-1].odometer, 0, data[-1].date, 0, cid)) + form.set_pitstops(data) form.same_odometer_allowed = (type(data[-1]) != Pitstop) or (data[-1].consumable.id != cid) else: - form.set_pitstop(Pitstop(date(1970, 1, 1), 0, vid, 0, '')) - form.set_pitstop(Pitstop(0, 0, date(1970, 1, 1), 0, cid)) + form.set_pitstops([]) form.same_odometer_allowed = True # set the label of the litres field to make the user comfortable @@ -128,15 +132,15 @@ def edit_pit_stop_form(pid): 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) - + data = get_event_line_for_vehicle(vehicle) + data = [x for x in data if x != edit_pitstop] + form.set_pitstops(data) + if not form.is_submitted(): + form.odometer.default = edit_pitstop.odometer + form.litres.default = edit_pitstop.amount + form.date.default = edit_pitstop.date + form.costs.default = edit_pitstop.costs if form.validate_on_submit(): edit_pitstop.costs = form.costs.data edit_pitstop.date = form.date.data @@ -146,18 +150,9 @@ def edit_pit_stop_form(pid): 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.amount - form.date.default = edit_pitstop.date - form.costs.default = edit_pitstop.costs + form.preinit_with_data() 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 is not None and edit_pitstop.costs > 0: - messages['costs'] = 'Costs must be higher than 0.01 €.' - return render_template('editPitStopForm.html', form=form, vehicle=vehicle, messages=messages) + return render_template('editPitStopForm.html', form=form, vehicle=vehicle, messages=form.get_hint_messages()) @app.route('/pitstops', methods=['GET']) @@ -172,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) diff --git a/app/routes/regular_cost.py b/app/routes/regular_cost.py new file mode 100644 index 0000000..e0d1b24 --- /dev/null +++ b/app/routes/regular_cost.py @@ -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/", 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//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/", 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/", 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, + ) + diff --git a/app/routes/service.py b/app/routes/service.py index 4a5a9dd..68b6800 100644 --- a/app/routes/service.py +++ b/app/routes/service.py @@ -19,10 +19,10 @@ def create_service_for_vehicle(vid): data = get_event_line_for_vehicle(vehicle) if len(data) > 0: - form.set_pitstop(Service(data[-1].date, data[-1].odometer, vid, 0, '')) + form.set_pitstops(data) form.same_odometer_allowed = type(data[-1]) != Service else: - form.set_pitstop(Service(date(1970, 1, 1), 0, vid, 0, '')) + form.set_pitstops([]) form.same_odometer_allowed = True form.preinit_with_data() @@ -70,16 +70,17 @@ def edit_service_form(sid): if vehicle not in current_user.vehicles: return redirect(url_for('get_pit_stops')) - form = EditServiceForm() data = get_event_line_for_vehicle(vehicle) - data.reverse() - if len(data) > 0: - last_pitstop = Service(data[-1].date, data[-1].odometer, vehicle.id, 0, '') - else: - last_pitstop = Service(date(1970, 1, 1), 0, vehicle.id, 0, '') - form.set_pitstop(last_pitstop) + data = [x for x in data if x != edit_service] + form = EditServiceForm() form.same_odometer_allowed = True + form.set_pitstops(data) + if not form.is_submitted(): + form.odometer.default = edit_service.odometer + form.description.default = edit_service.description + form.date.default = edit_service.date + form.costs.default = edit_service.costs if form.validate_on_submit(): edit_service.costs = form.costs.data edit_service.date = form.date.data @@ -89,18 +90,9 @@ def edit_service_form(sid): db_log_update(edit_service) return redirect(url_for('get_pit_stops', _anchor='v' + str(vehicle.id))) - form.odometer.default = edit_service.odometer - form.description.default = edit_service.description - form.date.default = edit_service.date - form.costs.default = edit_service.costs + form.preinit_with_data() 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_service.costs is not None and edit_service.costs > 0: - messages['costs'] = 'Costs must be higher than 0.01 €.' - return render_template('editServiceForm.html', form=form, vehicle=vehicle, messages=messages) + return render_template('editServiceForm.html', form=form, vehicle=vehicle, messages=form.get_hint_messages()) @app.route('/service/vehicle/select', methods=['GET', 'POST']) diff --git a/app/static/js/main.js b/app/static/js/main.js index 4f1b963..1d594a4 100644 --- a/app/static/js/main.js +++ b/app/static/js/main.js @@ -12,7 +12,7 @@ function createChart(id, data, unit) { "axisAlpha": 0, "position": "left", "ignoreAxisWidth":true, -// "title": unit + "title": unit }], "balloon": { "borderThickness": 1, diff --git a/app/templates/account.html b/app/templates/account.html index cd47fc3..6549bf6 100644 --- a/app/templates/account.html +++ b/app/templates/account.html @@ -71,9 +71,9 @@
-
diff --git a/app/templates/createPitStopForm.html b/app/templates/createPitStopForm.html index a6941e3..b8e42f0 100644 --- a/app/templates/createPitStopForm.html +++ b/app/templates/createPitStopForm.html @@ -17,6 +17,9 @@ {{messages['odometer']}} {{ render_field_with_errors(form.litres) }} + + {{messages['litres']}} + {{ render_field_with_errors(form.costs) }} {{messages['costs']}} diff --git a/app/templates/createRegularCostForm.html b/app/templates/createRegularCostForm.html new file mode 100644 index 0000000..3870fcd --- /dev/null +++ b/app/templates/createRegularCostForm.html @@ -0,0 +1,39 @@ +{% extends "layout.html" %} + +{% block body %} +
+
+
+
+

New Regular Cost for '{{ vehicle.name }}'

+
+ {{ form.hidden_tag() }} + + {{ render_field_with_errors(form.start_at) }} + + {{messages['start_at']}} + + + {{ render_field_with_errors(form.ends_at) }} + + {{messages['ends_at']}} + + + {{ render_field_with_errors(form.days) }} + Format as 'Month-Day' (e.g. 05-25) and separate with ','. + + {{messages['days']}} + + + {{ render_field_with_errors(form.costs) }} + + {{messages['costs']}} + + {{ render_field_with_errors(form.description) }} + {{ render_field_with_errors(form.submit) }} +
+
+
+
+
+{% endblock %} diff --git a/app/templates/deleteRegularCostForm.html b/app/templates/deleteRegularCostForm.html new file mode 100644 index 0000000..9ccf944 --- /dev/null +++ b/app/templates/deleteRegularCostForm.html @@ -0,0 +1,46 @@ +{% extends 'layout.html' %} + +{% block body %} +
+
+
+
+

Delete Regular Cost?

+ + + + + + + + + + + + + + + + + + + + + +
Description of regular cost{{ regular_cost.description }}
Costs (per instance) + {% if regular_cost.costs %} + {{ regular_cost.costs }} + {% else %} + -- + {% endif %} + € +
Days for regular costs{{ regular_cost.days }}
regular costs starting from{{ regular_cost.start_at }}
regular costs ending at{{ regular_cost.ends_at }}
+
+ {{ form.hidden_tag() }} + {{ render_field_with_errors(form.submit) }} +
+
+
+
+
+{% endblock %} diff --git a/app/templates/deleteVehicleForm.html b/app/templates/deleteVehicleForm.html index c058771..d8694b3 100644 --- a/app/templates/deleteVehicleForm.html +++ b/app/templates/deleteVehicleForm.html @@ -8,7 +8,7 @@

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

{{ form.hidden_tag() }} - {{ render_field_with_errors(form.submit) }} + {{ render_field_with_errors(form.submit, include_cancel=True) }}
diff --git a/app/templates/editRegularCostForm.html b/app/templates/editRegularCostForm.html new file mode 100644 index 0000000..599e570 --- /dev/null +++ b/app/templates/editRegularCostForm.html @@ -0,0 +1,39 @@ +{% extends "layout.html" %} + +{% block body %} +
+
+
+
+

Edit Regular Cost for '{{ vehicle.name }}'

+
+ {{ form.hidden_tag() }} + + {{ render_field_with_errors(form.start_at) }} + + {{messages['start_at']}} + + + {{ render_field_with_errors(form.ends_at) }} + + {{messages['ends_at']}} + + + {{ render_field_with_errors(form.days) }} + Format as 'Month-Day' (e.g. 05-25) and separate with ','. + + {{messages['days']}} + + + {{ render_field_with_errors(form.costs) }} + + {{messages['costs']}} + + {{ render_field_with_errors(form.description) }} + + {{ render_field_with_errors(form.submit) }} +
+
+
+
+{% endblock %} diff --git a/app/templates/endRegularCostForm.html b/app/templates/endRegularCostForm.html new file mode 100644 index 0000000..483fb58 --- /dev/null +++ b/app/templates/endRegularCostForm.html @@ -0,0 +1,16 @@ +{% extends "layout.html" %} + +{% block body %} +
+
+
+
+

Edit Regular Cost for '{{ vehicle.name }}'

+
+ {{ form.hidden_tag() }} + {{ render_field_with_errors(form.submit) }} +
+
+
+
+{% endblock %} diff --git a/app/templates/layout.html b/app/templates/layout.html index 4295654..dc6582e 100644 --- a/app/templates/layout.html +++ b/app/templates/layout.html @@ -3,6 +3,7 @@
  • Plan Pitstop
  • Create Pitstop
  • Create Service
  • +
  • Create Regular Cost
  • Statistics
  • Account
  • {% 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) %}
    {% if field.type == 'SubmitField' %} -
    - -
    +
    +
    + {% if include_cancel %} + + {% endif %}
    - -
    +
    {% else %}
    {%- endmacro %} @@ -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 %} {% endif %} + {% if vehicle.regulars %} +

    Regular Costs

    + {% for data in vehicle.regulars %} + {{ regular(data, vehicleloop.index, loop) }} + {% endfor %} + {% endif %}
    {% endfor %} diff --git a/app/templates/statistics.html b/app/templates/statistics.html index 0f1ecd3..a012bc5 100644 --- a/app/templates/statistics.html +++ b/app/templates/statistics.html @@ -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 #}
  • @@ -87,7 +94,15 @@ Logged Costs: {{ vehicle.overall_costs | round(2) }} € - + + Logged Costs per km: + {% if vehicle.costs_per_distance != 'N/A' %} + {{ vehicle.costs_per_distance | round(2) }} €/100km + {% else %} + {{ vehicle.costs_per_distance }} + {% endif %} + + {% endmacro %} @@ -115,6 +130,7 @@ {{ print_vehicle_table(vehicle) }}