Staging environment + FleetOps split #17

Open
kianiadee wants to merge 23 commits from feat/staging-fleetops-architecture into main
Owner

Staging environment + FleetOps split.

  • docs/STAGING_FLEETOPS_ARCHITECTURE.md — topology, env matrix, deploy/promotion, safety model
  • dashboard_api_rev.py — /analytics/* endpoints (fleet-summary, utilisation, driver-behaviour, fuel, filters); VTRIPS_REFRESH_INTERVAL_S<=0 disables the refresher (staging read-only)
  • migrations/17_fleetops_fuel_view.sql — reporting.v_fuel_daily (applied to prod)
  • migrations/18_grant_reporting_ro.sql — grant reporting.* to grafana_ro (applied to prod)
  • scripts/dashboard_ro_role.sql + bootstrap_dashboard_ro.sh — dedicated read-only role (applied to prod)
  • deploy_dashboard_api_staging.sh — staging dashboard_api bridge (fleetapi.fivetitude.com)

Staging live: fleetapi/fleetops/fleetnow.fivetitude.com. Prod API cutover done (analytics + CORS).

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com

Staging environment + FleetOps split. - docs/STAGING_FLEETOPS_ARCHITECTURE.md — topology, env matrix, deploy/promotion, safety model - dashboard_api_rev.py — /analytics/* endpoints (fleet-summary, utilisation, driver-behaviour, fuel, filters); VTRIPS_REFRESH_INTERVAL_S<=0 disables the refresher (staging read-only) - migrations/17_fleetops_fuel_view.sql — reporting.v_fuel_daily (applied to prod) - migrations/18_grant_reporting_ro.sql — grant reporting.* to grafana_ro (applied to prod) - scripts/dashboard_ro_role.sql + bootstrap_dashboard_ro.sh — dedicated read-only role (applied to prod) - deploy_dashboard_api_staging.sh — staging dashboard_api bridge (fleetapi.fivetitude.com) Staging live: fleetapi/fleetops/fleetnow.fivetitude.com. Prod API cutover done (analytics + CORS). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds docs/STAGING_FLEETOPS_ARCHITECTURE.md — the project doc for splitting
fleet tracking (FleetNow, frozen prod) from fleet operations (FleetOps, new
SPA) and standing up a staging environment under the fivetitude.com wildcard.

Covers: target topology + env matrix, the two dashboard_api instances
(prod 8890 / staging 8891, read-only role, refresher off), Forgejo->Coolify
webhook deploy + branch promotion model, FleetOps SPA on Caddy (Traefik still
terminates TLS), shared prod read-layer safety model, 6-phase rollout, and a
verification checklist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the read-only /analytics/* surface the FleetOps SPA will consume, plus
the migration that backs the fuel roll-up. All endpoints SELECT the indexed
reporting.* / tracksolid.v_* views and never write, so the forthcoming staging
instance can serve them against the prod DB as grafana_ro.

dashboard_api_rev.py:
  - GET /analytics/fleet-summary     per-vehicle + per-cost-centre roll-up
  - GET /analytics/utilisation       per-vehicle utilisation + daily fleet trend
  - GET /analytics/driver-behaviour  per-driver speeding / harsh index
  - GET /analytics/fuel              actual vs estimated litres (data-gated flags)
  - GET /analytics/filters           dropdown options (alias of GET /webhook/fleet-dashboard)
  - responses run through jsonable_encoder (Decimal->float, date->ISO)
  - VTRIPS_REFRESH_INTERVAL_S<=0 now DISABLES the v_trips refresher, so a
    read-only staging instance never attempts REFRESH (prod still owns it).

migrations/17_fleetops_fuel_view.sql:
  - reporting.v_fuel_daily encapsulates the v_trips->devices join (so the
    read-only role needs SELECT only on the view) and grants it to grafana_ro.

Registered 17 in run_migrations.py. Note: live migration head is 16, not 13
as CLAUDE.md implies. Endpoints are unit-compilable but untested live until
the staging bridge (Phase 1) exists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
deploy_dashboard_api_staging.sh — standalone bridge twin of the prod
~/deploy_dashboard_api.sh for the fivetitude.com staging umbrella:

  - container dashboard_api_staging on port 8891
  - Traefik Host(fleetapi.fivetitude.com), router/service names suffixed -staging
  - CORS = fleetnow.fivetitude.com, fleetops.fivetitude.com
  - DATABASE_URL derived on-host as a READ-ONLY grafana_ro URL (never printed)
  - VTRIPS_REFRESH_INTERVAL_S=0 so the read-only instance never REFRESHes
    (prod owns the v_trips materialized-view refresh)

Reuses the webhook_receiver image + app network + WIP bind-mount, exactly like
prod. Not yet executed on the host — awaiting go-ahead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
migrations/18_grant_reporting_ro.sql — grants USAGE + SELECT on the reporting.*
layer to grafana_ro, with DEFAULT PRIVILEGES so future reporting views are
auto-readable. grafana_ro is the read-only role the staging dashboard_api
connects as; it read tracksolid.* but never reporting.* (the prod dashboard_api
uses the app role), surfacing as "permission denied for view v_filter_drivers /
v_daily_summary" on the staging /analytics/* endpoints. Read-only only — no
write/REFRESH. Registered 18 in run_migrations.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the grafana_ro reuse with a purpose-built least-privilege login role
that can serve the FULL dashboard_api read surface — so it backs the staging
instance now and can take over the live prod connection later (stage 2).

scripts/dashboard_ro_role.sql (run as postgres, password-free in repo):
  - CREATE ROLE dashboard_ro LOGIN, read-only
  - SELECT on reporting.* + tracksolid.*; explicit SELECT on the
    reporting.v_trips MATERIALIZED VIEW (not covered by GRANT ON ALL TABLES)
  - EXECUTE on reporting.fn_* map functions
  - ALTER DEFAULT PRIVILEGES so future objects are auto-readable ("dynamic")

scripts/bootstrap_dashboard_ro.sh:
  - generates the password into ~/.dashboard_ro.pw (0600), never printed
  - applies the DDL via docker exec psql -U postgres -v ro_pw=...

deploy_dashboard_api_staging.sh: build DATABASE_URL from dashboard_ro +
~/.dashboard_ro.pw instead of grafana_ro.

Migrations 17/18 (already applied) are left intact. Not yet executed on host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
docs: dashboard_ro role + two-stage rollout; mark Phase 1 done
Some checks failed
Static Analysis / static (push) Waiting to run
Tests / test (push) Waiting to run
Static Analysis / static (pull_request) Has been cancelled
Tests / test (pull_request) Has been cancelled
2603f0e726
Updates STAGING_FLEETOPS_ARCHITECTURE.md to reflect the dedicated read-only
dashboard_ro role (replacing the grafana_ro reuse), the explicit v_trips matview
grant, DEFAULT PRIVILEGES, host-only password, and the two-stage plan (staging
now, live prod connection later). Notes migrations 17+18 applied; Phase 0
read-only role complete, webhook deploys still pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
docs: finalize staging+FleetOps as-built; phase closed
Some checks failed
Static Analysis / static (push) Waiting to run
Tests / test (push) Waiting to run
Static Analysis / static (pull_request) Has been cancelled
Tests / test (pull_request) Has been cancelled
54a714e0cd
- STAGING_FLEETOPS_ARCHITECTURE.md: as-built status banner + phase table marked
  delivered; remaining op follow-up = 2 Forgejo webhooks (FleetOps-prod,
  FleetNow-staging)
- CLAUDE.md §3: document FleetOps (Caddy SPA) + /analytics/*, the fivetitude.com
  staging umbrella, the 8891 staging bridge + dashboard_ro role, /env.js per-env
  injection; refresh migration range to 02-18 + new infra files in the map

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(db): split refresher onto REFRESH_DATABASE_URL; prod reads via dashboard_ro
Some checks failed
Static Analysis / static (push) Waiting to run
Tests / test (push) Waiting to run
Static Analysis / static (pull_request) Has been cancelled
Tests / test (pull_request) Has been cancelled
aea226cc74
Enables stage-2: the prod dashboard_api request pool connects as the READ-ONLY
dashboard_ro role (DATABASE_URL) while the v_trips refresher keeps a privileged
connection via REFRESH_DATABASE_URL (falls back to DATABASE_URL when unset, so
single-role/staging deploys are unchanged). Avoids the FIX-D02 trap (a read-only
role cannot REFRESH).

Adds deploy_dashboard_api.sh (the prod bridge deploy, now version-controlled):
strips inherited DATABASE_URL, sets REFRESH_DATABASE_URL=<app role> +
DATABASE_URL=dashboard_ro, CORS incl. fleetops.rahamafresh.com.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
docs: mark stage-2 (prod reads via dashboard_ro) done
Some checks failed
Static Analysis / static (push) Waiting to run
Tests / test (push) Waiting to run
Static Analysis / static (pull_request) Has been cancelled
Tests / test (pull_request) Has been cancelled
2f6ab1ba3b
Prod bridge now reads via dashboard_ro with the refresher on REFRESH_DATABASE_URL
(privileged). Both webhooks registered. Updated the as-built banner + §6 safety
note accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Collapse the backend from 7 Coolify services to 4 app services + the DB.

- Merge ingest_movement + ingest_events into a single ingest_worker:
  split each poller's main() into reusable startup_catchup()/register_jobs()
  and drive both from one schedule loop in new ingest_worker_rev.py
  (standalone entrypoints retained for local debug).
- docker-compose.yaml: replace the two poller services with ingest_worker;
  remove the pgbouncer service (dormant; transaction-mode pooling is unsafe
  for the advisory-lock'd v_trips refresher) and the grafana service +
  grafana-data volume (redundant with the FleetOps SPA).
- Add reporting.v_ingest_health (migration 19) + dashboard_api GET
  /health/ingest as the pipeline-freshness surface that replaces Grafana's
  health panels.

webhook_receiver stays isolated so a poller fault can't drop inbound pushes.
timescale_db and db_backup are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
chore(cleanup): purge n8n, Grafana, and DWH references + dead artifacts
Some checks failed
Static Analysis / static (push) Waiting to run
Tests / test (push) Waiting to run
Static Analysis / static (pull_request) Has been cancelled
Tests / test (pull_request) Has been cancelled
d5093f0a1c
These subsystems are retired and replaced by better alternatives (FleetNow /
FleetOps SPAs via dashboard_api; in-process pooling; reporting.v_ingest_health).
Remove them so the repo reflects the live stack only. Nothing running depends
on the deleted artifacts.

Deleted (dead artifacts):
- n8n-workflows/ (retired webhook exports), grafana/ (provisioning for the
  removed service), dwh/ (migrations for the decommissioned external warehouse)
- runbooks: DWH_PIPELINE.md, DWH_Execution_Manual.md, grafanaDeployment.md,
  grafanaOperationalManual.md

Code/config:
- run_migrations.py: drop sync_role_passwords() (its only entries were the now
  -dead grafana_ro + pgbouncer syncs; the guard already made it inert)
- .env: remove the two unused GRAFANA_* vars
- ingest_movement_rev.py / db_audit / deploy_dashboard_api_staging.sh: reword
  stale Grafana/grafana_ro comments

Docs: scrub n8n/Grafana/DWH from CLAUDE.md, CONNECTIONS, DATA_FLOW,
OPERATIONS_MANUAL, docker_commands, KPI_FRAMEWORK, PLATFORM_OVERVIEW,
STAGING_FLEETOPS, and deprecation-banner the two large SQL libraries
(dwh_gold was already dropped 2026-06-05).

Kept deliberately: the grafana_ro DB role (now an unused read-only login),
applied migration history, dated docs/reports/*, and docs/superpowers/* specs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(reporting): restore live-feed cost-centre exclusion + vehicle_type (migration 20)
Some checks failed
Static Analysis / static (push) Waiting to run
Tests / test (push) Waiting to run
Static Analysis / static (pull_request) Has been cancelled
Tests / test (pull_request) Has been cancelled
f1c231a737
Migration 11 was applied out of order on 2026-06-10 (it had never been recorded
applied on prod — the reporting objects were hand-created, then migrations 14/15/16
modified them). Re-running 11 recreated reporting.v_live_positions and
reporting.fn_live_positions at their BASE definitions; 15/16 were skipped as
already-applied, so the live map silently lost migration 15's cost-centre exclusion
(personal/management/mtn) and migration 16's vehicle_type/fleet_segment GeoJSON
fields — the live-map vehicle count jumped 74 -> 80.

Migration 20 re-asserts both objects' intended final definitions (verbatim union of
15 + 16). As the highest-numbered migration it always runs last, so the correct
state wins regardless of apply order. Already hot-fixed on prod by re-running 15+16;
this captures it durably. Idempotent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(tickets): INC/CRQ tickets schema, geocoding + read-API
Some checks failed
Static Analysis / static (push) Waiting to run
Tests / test (push) Waiting to run
Static Analysis / static (pull_request) Has been cancelled
Tests / test (pull_request) Has been cancelled
cae64167eb
- migrations 21->23: dedicated `tickets` schema (tickets.inc / tickets.crq,
  raw-jsonb-first), geo_clusters + geo_locations gazetteers, geom-resolution
  trigger (feed -> location -> cluster -> none), reporting.fn_tickets_for_map
- dashboard_api: GET /webhook/tickets (INC/CRQ GeoJSON for the FleetOps map)
- tools/import_tickets.py: raw-first bucket ingest + cluster/location geocoding
  (LocationIQ/OpenCage, viewbox-bounded with a cluster-distance sanity guard)
- docs/CONNECTIONS.md: geocoder env var names

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
chore(tickets): hand the tickets schema + ingest to the fleettickets repo
Some checks failed
Static Analysis / static (push) Waiting to run
Tests / test (push) Waiting to run
Static Analysis / static (pull_request) Has been cancelled
Tests / test (pull_request) Has been cancelled
868960c39c
The `tickets` schema (INC/CRQ map), its migrations, and the ingest/geocode loader
moved to their own repo: repo.rahamafresh.com/kianiadee/fleettickets.git.

- remove migrations 21-23 and tools/import_tickets.py
- run_migrations.py: drop the 21-23 entries (fleettickets owns them now)
- dashboard_api keeps GET /webhook/tickets, calling reporting.fn_tickets_for_map
  which fleettickets defines
- move geocoder env-var docs to fleettickets

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(api): add /analytics/fuel-fills endpoints for the FleetOps Fuel Log
Some checks failed
Static Analysis / static (push) Waiting to run
Tests / test (push) Waiting to run
Static Analysis / static (pull_request) Has been cancelled
Tests / test (pull_request) Has been cancelled
c42a500d4b
Reads reporting.v_fuel_fills / v_fuel_efficiency (owned by the fleetfuel module,
which ingests the rustfs `fuel` bucket). Adds GET /analytics/fuel-fills (totals,
per-vehicle rows incl. km/L, by_department, daily trend, unmatched-plate status)
and /analytics/fuel-fills/recent, reusing _analytics_window + _dim_filters.
Extends /analytics/filters with departments + fuel_types, savepoint-guarded so a
not-yet-migrated v_fuel_fills can't break the existing trips dropdowns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(api): support generic Nd period presets (90d, 365d, …)
Some checks failed
Tests / test (push) Has been cancelled
Static Analysis / static (pull_request) Has been cancelled
Tests / test (pull_request) Has been cancelled
Static Analysis / static (push) Has been cancelled
024b698bca
_preset_to_range only special-cased today/30d/custom and fell through to 7d for
everything else, so the Fuel Log tab's default "90d" silently returned 7 days.
Parse any positive `Nd` preset into a today-(N-1)..today window. Backward-
compatible: today/30d/custom and the 7d default are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(dashboard_api): wire GET /webhook/inc-dashboard route
Some checks failed
Static Analysis / static (push) Waiting to run
Tests / test (push) Waiting to run
Static Analysis / static (pull_request) Has been cancelled
Tests / test (pull_request) Has been cancelled
1d4bd6000e
Thin passthrough over reporting.fn_inc_dashboard (fleettickets migration 09),
powering the FleetOps live INC map (open + windowed-closed + metrics). Mirrors
/webhook/tickets for auth/connection and returns the function's jsonb unchanged.

Params: cluster, status, window (today|week|month|custom), from/to (custom).
Validates window enum, custom-requires-from/to, ISO-8601 from/to, and from<to
(400 on failure). Matches docs/dashboard-api-contract.md from the fleettickets repo.

Verified end-to-end against the prod DB (read-only): default/week/cluster
filters return correct payloads; bad window and custom-without-bounds → 400.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(dashboard_api): GET /webhook/inc-search route (ticket explorer)
Some checks failed
Static Analysis / static (push) Waiting to run
Tests / test (push) Waiting to run
Static Analysis / static (pull_request) Has been cancelled
Tests / test (pull_request) Has been cancelled
e27933b1cb
Thin passthrough over reporting.fn_inc_search (fleettickets migration 13):
ad-hoc ticket lookup by ticket_id / owner / cluster / status / state / time for
the FleetOps ticket explorer. Mirrors /webhook/inc-dashboard (read-only conn,
jsonb passthrough); validates state in {open,closed,all} and ISO-8601 from/to.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(dashboard_api): GET /webhook/inc-filter-options (explorer pulldowns)
Some checks failed
Static Analysis / static (push) Has been cancelled
Tests / test (push) Has been cancelled
Static Analysis / static (pull_request) Has been cancelled
Tests / test (pull_request) Has been cancelled
048d1a3173
Passthrough over reporting.fn_inc_filter_options (fleettickets migration 14):
engineers, clusters, open ticket ids for the ticket-explorer dropdowns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add auto-detecting header-alias layer (_EXPORT_HEADER_ALIASES) so
import_drivers_csv ingests the raw Tracksolid Pro device export
(Title-Case headers: IMEI, Driver Name, LicensePlateNo., Model,
Vehicle Model, …) in addition to the original snake_case schema-mirror
CSV. Add customer_name/device_group to the write set, extend the
driver-placeholder skip list (Unassigned/UG/UG Crane), and exclude
activation_time/expiration/device_group (date-only/casing churn that
would degrade precise API-set values).

Used to apply the 260625 telematics quality-check file: 152 devices
updated (device_name, driver_name, plate, vehicle type, depot_address).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Parallel to the INC routes, three new endpoints back the FleetOps Tickets tab's CRQ
sub-tab: GET /webhook/crq-dashboard, /webhook/crq-search, /webhook/crq-filter-options
— thin passthroughs over reporting.fn_crq_* (fleettickets migration 16, over
tickets.crq). The INC routes are unchanged. Header route list updated.

Not yet redeployed to the staging/prod bridges (pending go-ahead).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(security,ingest): 260702 audit — secure the stack, correct poller counters
Some checks failed
Static Analysis / static (push) Waiting to run
Tests / test (push) Waiting to run
Static Analysis / static (pull_request) Has been cancelled
Tests / test (pull_request) Has been cancelled
b11294009b
Security:
- .dockerignore + Dockerfile: stop baking .env / the 346MB OSM pbf into image
  layers; install pinned from uv.lock (reproducible builds) (SEC-04/05).
- docker-compose: DB port binds ${DB_BIND_ADDR:-127.0.0.1} — loopback-only by
  default; remote tooling moves to an SSH tunnel (SEC-01).
- webhook_receiver: CRITICAL startup warning + WEBHOOK_REQUIRE_TOKEN=1 fail-closed
  when JIMI_WEBHOOK_TOKEN is empty (SEC-02 / FIX-W01).

Correctness:
- FIX-M22/E07: capture cur.rowcount BEFORE RELEASE SAVEPOINT in poll_alarms/
  poll_trips/poll_parking — the RELEASE reported -1, producing "Alarms: -4 new
  events inserted" logs and negative ingestion_log.rows_inserted.
- FIX-W02: parse application/json push bodies (were silently dropped).
- FIX-W03: move webhook DB work off the event loop via asyncio.to_thread.
- FIX-M23: poll_trips phased so no txn/connection is held across Tracksolid +
  Nominatim (1 req/s) network calls.
- FIX-M24: sync_devices disables devices absent from every target (guarded).
- FIX-W04: reject device-clock-garbage alarm_time (2019 timestamps observed).
- get_token(): don't relabel already-aware timestamptz expiries (BUG-P9).

Observability/lifecycle:
- migration 21: v_ingest_health restricted to active pipeline endpoints so
  one-shot tools stop wedging /health/ingest at 'stale' (dry-run verified).
- FIX-M25: daily purge_audit_logs() trims ingestion_log (90d) + refresh_log (180d).
- remove orphaned duplicate migrations/10_driver_clock_views.sql; ruff lint config.

+5 webhook tests (82 pass). Report/plan/work-log in docs/reports/260702_*.
Local only; not deployed. CLAUDE.md fix-history edits left uncommitted (that file
also carries unrelated in-progress edits).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix(dashboard_api): jsonable_encoder on /health/ingest (datetime serialize)
Some checks failed
Static Analysis / static (push) Has been cancelled
Static Analysis / static (pull_request) Has been cancelled
Tests / test (pull_request) Has been cancelled
Tests / test (push) Has been cancelled
621a30cd81
The ingest-health handler returned reporting.v_ingest_health rows straight to
JSONResponse, but last_run_at is a datetime — json.dumps raised TypeError and the
endpoint fell into its except, always returning {"overall":"unknown","endpoints":[]}.
Every other analytics endpoint already routes through jsonable_encoder; this one
didn't. Surfaced when the prod bridge finally got the /health/ingest route.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Some checks failed
Static Analysis / static (push) Has been cancelled
Static Analysis / static (pull_request) Has been cancelled
Tests / test (pull_request) Has been cancelled
Tests / test (push) Has been cancelled
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/staging-fleetops-architecture:feat/staging-fleetops-architecture
git switch feat/staging-fleetops-architecture

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff feat/staging-fleetops-architecture
git switch feat/staging-fleetops-architecture
git rebase main
git switch main
git merge --ff-only feat/staging-fleetops-architecture
git switch feat/staging-fleetops-architecture
git rebase main
git switch main
git merge --no-ff feat/staging-fleetops-architecture
git switch main
git merge --squash feat/staging-fleetops-architecture
git switch main
git merge --ff-only feat/staging-fleetops-architecture
git switch main
git merge feat/staging-fleetops-architecture
git push origin main
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
kianiadee/tracksolid_timescale_grafana_prod!17
No description provided.