Skip to content

Fleet management — Developer Guide

How the fleet module (epic #1388) is laid out: pages, services, files, the daily expiries digest and the one-off iDrv4 history import.

The fleet module brings Onyx to parity with the iDrv4 (Nicola/Ceres) fleet suite. It was built in phases #1381–#1387 under epic #1388. Operator guides are in the Admin Guide under Fleet and Compliance → NCR / CAR. This page covers the code.

WhereWhat
portals/operations/fleet_routes.pyFleet pages on the operations_portal blueprint: overview, vehicle detail (/fleet/vehicles/<id>/view), maintenance, assets, expiries, KMs, fuel, fuel cards, infringements, capacity
api/fleet_api.py/api/fleet/* JSON endpoints the pages call
services/fleet/One module per area: vehicles, odometer, maintenance, assets, expiries, fuel, fuel_documents, fuel_cards, infringements, ncr, costs, documents, digest, cli, plus columns (absent-column probes)
templates/portals/operations/The pages. Add and edit happen in right-hand offcanvas-end drawers, not modals
utils/user_permissions.pyFLEET_WRITE_ROLE_IDS (Operations + Admin) and FLEET_ADMIN_ROLE_IDS (Admin: unarchive, delete documents, NCR vocabulary)

Fleet tables are read and written with plain SQL, mostly through pooled_connection(). The ORM is used only to read the compliance and document modules’ own models (defects, submissions, documents). Don’t import models/asset_management.py or models/asset_sqlalchemy.py: they declare columns the database doesn’t have.

  • One expiry rule. services/fleet/expiries.py::list_expiries drives the Expiries page, the dashboard Fleet expiries widget and the email digest. It judges dates in Melbourne time. Don’t compute expiry status anywhere else.
  • vehicles stays the source dispatch reads. Saving a registration recomputes vehicles.registration_expiry, and a DG certificate updates dg_licence_*. Both happen in the same transaction and lock the vehicle row first. They are only recomputed when records exist, never cleared.
  • Odometer. fleet_odometer_readings_v unions manual readings, fuel records, maintenance records and vehicle compliance submissions. The latest dated reading wins, and readings before a reset are ignored.
  • Files. Fleet attachments live in the private bucket fleet-docs (config.fleet_docs_bucket) and are streamed through role-checked Flask routes. Never hand out a public URL.
  • Fuel cards never store a full number, only a label and the last four digits. A migration CHECK rejects 8+ digit values.
  • Schema changes are migrations under supabase/migrations/. Promote them a step ahead of code that reads new columns. services/fleet/columns.py lets pages show a “not migrated yet” state instead of a 500.

flask fleet digest sends each enabled subscriber the day’s fleet expiries. The command is registered in both app_factory.py and main.py.

Terminal window
flask fleet digest --dry-run # print it; send and write nothing
flask fleet digest --to me@example.com # one copy to one address, no send log
flask fleet digest --date 2026-11-03 # judge expiries on that Melbourne date
flask fleet digest --force # skip the local-hour gate
flask fleet digest-subscribe --email ADDR [--user-id N] [--lead-days 30]
  • Users manage their own subscription in Operations → Settings → Fleet digest. See the operator guide.
  • digest_sends holds one row per Melbourne date and address. That row makes repeat runs no-ops and lets a failed send be retried safely.
  • It is deployed as a separate Railway cron service using railway.fleet-digest.toml, which runs hourly. The command’s own FLEET_DIGEST_LOCAL_HOUR gate (Melbourne, default 7) decides when it sends.
  • The service needs FLEET_DIGEST_ENABLED=true, DATABASE_URL, its own SESSION_SECRET and JWT_SECRET_KEY, and PORTAL_BASE_URL. In production it also needs the web service’s MAIL_* variables.
  • Without the MAIL_* variables, production refuses to send (exit 2) rather than drop mail into the Mailosaur sandbox.
  • The comments at the top of railway.fleet-digest.toml are the setup runbook.

scripts/import_ceres_fleet_records.py loads iDrv4 fleet history from live Ceres, read-only. Run it after scripts/import_ceres_fleet.py (the vehicles themselves), and only once every fleet migration is on the target tier.

  • Rule: import current state regardless of date: certificates and expiries, latest registration and insurance, fuel cards, odometer, service intervals, NCR lists and open NCRs. Import events only since --since (default 2026-07-01): refuels, other costs, renewals, NCRs and fines.
  • Datasets (--only): vocabulary, fuel_cards, certificates, registrations, insurances, odometer, service_intervals, fuel_records, other_costs, ncr_reports, infringements.
  • Flags:
    • --dry-run uses a READ ONLY transaction and rolls back.
    • --report FILE writes a Markdown reconciliation.
    • --source JAT|JATT picks the Ceres database.
    • Production also needs --allow-production.
  • Idempotent. Every row carries legacy_source and legacy_id, and a second run must report only unchanged. Fields operators own are insert-only.
  • Privacy. It never selects card or account numbers; MySQL returns only the last four digits. Drivers are matched on licence number, never by name.
  • Ceres credentials are temporary. Pass them via --mysql-defaults-file, never commit or store them, and delete the file after the run.

It ran on dev, staging and production in October 2026. It is kept for re-runs, which are safe, and for the JATT source.