Build a Maintenance Tracker in Flask (Part 2: Logging and History)
Maintenance & Reliability 4 min read 👁 41 views

Build a Maintenance Tracker in Flask (Part 2: Logging and History)

The models from Part 1 are useless until something can actually write to them. This part covers the form for logging maintenance and the view that shows the history for each piece of equipment.

Reghan
Reghan

September 04, 2026

// share

Part 1 was the data model: Station, Equipment, Maintenance Log; Three tables, sitting there, doing nothing. A model with no way to write to it is just a diagram. This part is where it actually becomes a tool someone could use.

If you're newer to Python or haven't touched Git yet, I'd pause here and go through my crash course post first; this one assumes you're comfortable with both.

Two things were needed to exist: a form to log that maintenance had occurred, and a view showing the history for a specific piece of equipment.

The Form

I used Flask-WTF instead of handling raw request.form values by hand. Not because it's fancier, but because it does two boring things I didn't want to write myself: CSRF protection, and validation that actually runs before anything touches the database.

from flask_wtf import FlaskForm
from wtforms import SelectField, StringField, TextAreaField, FloatField, DateField
from wtforms.validators import DataRequired, Optional


class MaintenanceLogForm(FlaskForm):
    equipment_id = SelectField("Equipment", coerce=int, validators=[DataRequired()])
    log_type = SelectField(
        "Type",
        choices=[
            ("preventive", "Preventive"),
            ("corrective", "Corrective"),
            ("fault", "Fault"),
        ],
        validators=[DataRequired()],
    )
    description = TextAreaField("Description", validators=[DataRequired()])
    performed_by = StringField("Performed By", validators=[DataRequired()])
    performed_on = DateField("Date", validators=[Optional()])
    downtime_hours = FloatField("Downtime (hours)", validators=[Optional()])

The equipment_id field needs coerce=int. Without it, WTForms treats the selected value as a string, and it'll silently fail to match your ForeignKey column later. Lost twenty minutes to that one myself.

The Route

from flask import Blueprint, render_template, redirect, url_for, flash
from sqlalchemy import select
from app import db
from app.models import MaintenanceLog, Equipment
from app.forms import MaintenanceLogForm

maintenance_bp = Blueprint("maintenance", __name__, url_prefix="/maintenance")


@maintenance_bp.route("/new", methods=["GET", "POST"])
def new_log():
    form = MaintenanceLogForm()
    equipment = db.session.scalars(select(Equipment)).all()
    form.equipment_id.choices = [(e.id, e.name) for e in equipment]

    if form.validate_on_submit():
        log = MaintenanceLog(
            equipment_id=form.equipment_id.data,
            log_type=form.log_type.data,
            description=form.description.data,
            performed_by=form.performed_by.data,
            performed_on=form.performed_on.data,
            downtime_hours=form.downtime_hours.data or 0.0,
        )
        db.session.add(log)
        db.session.commit()
        flash("Maintenance log recorded.", "success")
        return redirect(url_for("maintenance.equipment_history", equipment_id=log.equipment_id))

    return render_template("maintenance/new_log.html", form=form)


@maintenance_bp.route("/equipment/<int:equipment_id>")
def equipment_history(equipment_id):
    equipment = db.get_or_404(Equipment, equipment_id)
    stmt = (
        select(MaintenanceLog)
        .where(MaintenanceLog.equipment_id == equipment_id)
        .order_by(MaintenanceLog.performed_on.desc())
    )
    logs = db.session.scalars(stmt).all()
    return render_template("maintenance/history.html", equipment=equipment, logs=logs)

One deliberate choice worth calling out: I'm using select() and db.session.scalars() here instead of the older Model.query.all() style you'll find in a lot of older Flask tutorials. Both still work, but select() is what SQLAlchemy's own current documentation calls the modern pattern; Model.query is explicitly labeled the legacy interface now. No reason to learn the version that's on its way out.

get_or_404 instead of a plain .get(); if someone hits a URL for equipment that doesn't exist, this returns a proper 404 instead of a confusing template error further down.

.order_by(MaintenanceLog.performed_on.desc()) puts the newest entry first. Obvious once you say it, easy to forget until you're scrolling a year of logs in the order they happened to be entered.

Wiring the Database: Flask-Migrate

Models existing in code don't mean tables exist in your database; that's a separate step, and I actually hit this myself while testing: ran the form, got OperationalError: no such table: equipment. Fixed it properly with Flask-Migrate rather than papering over it:

pip install flask-migrate

In app/__init__.py:

from flask_migrate import Migrate

migrate = Migrate()

# inside create_app(), after db.init_app(app):
migrate.init_app(app, db)

Then, one time only:

export FLASK_APP=run.py
flask db init
flask db migrate -m "Initial migration: station, equipment, maintenance_log"
flask db upgrade

Worth knowing the difference here: db.create_all() (which you'll see in a lot of quick tutorials) only creates tables that don't exist yet — it has no idea how to change a table that already exists. The moment you add a column next month, create_all() won't touch it. Migrations are what generate the actual ALTER TABLE for that. Fine to use create_all() for a five-minute experiment; use migrations for anything real.

An Honest Gap

If you run this fresh, the equipment dropdown on /maintenance/new will be empty because there's no way yet to actually add a piece of equipment through the app itself. Right now, the only way in is by dropping into a Python shell:

flask shell
from app import db
from app.models import Station, Equipment

s = Station(name="Station A", location="Lagos")
db.session.add(s)
db.session.commit()

e = Equipment(station_id=s.id, name="Dispenser 1", equipment_type="dispenser")
db.session.add(e)
db.session.commit()

That's obviously not how a real user interacts with anything. Building the actual Station/Equipment forms, so nobody ever needs to touch a shell, is Part 3.

Testing Before Trusting It

I didn't just eyeball this and assume it worked. I ran it against a real migrated database: created a station and equipment, submitted the form through Flask's test client, followed the redirect, and confirmed the entry showed up on the history page. If you're following along, do the same before assuming the wiring is right on your end.

If you're building this with me, what would you want next: editing an existing log, or the equipment CRUD forms this post just admitted are missing? I'm building whichever one more people ask for.
Here is a link to the repo

// Comments (0)

Log in or register to join the discussion.

// No comments yet. Start the conversation.