Printomato

Printomato runs photo-printing kiosks in malls, festivals and hotel lobbies. We built the operator side of it: a session-authenticated REST API next to the existing device API, and a React console that shows the whole fleet in real time — who is online, who is out of paper, what printed today, and which payments settled.

Year: 2024 — 2026 · Category: fleet management

Our role

Full-stack, on a two-developer project. We owned the payment stack, the admin API, the React console and the containerised deploy.

The problem

Printomato runs photo-printing kiosks — mall atriums, festival stands, hotel lobbies. Each kiosk prints branded photos for whichever campaign is live at that venue, and each one eventually runs out of paper, loses sight of its camera, or drops off the network at the worst possible moment.

Operations ran on server-rendered Django pages: one for campaigns, one for devices, one for photos. They worked, but they only ever showed what was true the last time somebody pressed refresh. Answering which kiosks are down right now meant reloading four tabs and trusting the oldest one. Then the kiosks started taking card payments, which added a second live thing to watch and nowhere good to watch it from.

The approach

The rewrite was scoped to the operator, not to the fleet. The kiosks were left completely alone — they still POST to the same photo endpoint, still open the same WebSocket, still call the payment endpoints with the same shared secret. A second, session-authenticated API went in beside the first at `/api/admin/`, and a React single-page console mounted at `/app/` to consume it. The old server-rendered pages stayed live on their original URLs the whole time, so there was never a cutover weekend to survive.

Anything an operator has to trust is computed server-side from a single annotated queryset — paper percentage, prints today, campaign progress, whether a device has open alerts — never reassembled in the browser. On the client, every endpoint gets exactly one TanStack Query hook, and that hook file is also where cache keys and invalidation live. Editing a device refreshes the fleet grid and the dashboard because that rule is written down once instead of repeated across eight components.

Highlights

  • Kiosks hold a WebSocket open — connecting flips a device online and broadcasts presence to every console watching, so the fleet grid never needs a refresh button.
  • Twenty-seven session-authenticated routes were added alongside the device API rather than on top of it: the kiosks and the original server-rendered pages kept working untouched throughout.
  • Paper percentage, prints today, campaign progress and open-alert state are all computed server-side — the percentages in the serializer, the counts as queryset annotations — so no two screens can disagree about the same number.
  • KEEPZ card payments: a shared-secret endpoint mints a per-device token, triggers the flow on the kiosk over its socket, and logs every session as it settles.
  • 96 Django tests, 73 of them pinning the admin API's contract — filters, pagination edges, validation messages, permissions.
  • One `docker compose up` brings the whole stack — Postgres, Redis, Daphne, nginx — to a seeded, sign-in-able console.

Architecture

  • Kiosk: Each device holds a WebSocket open on its own channel. Connecting marks it online and broadcasts presence to the fleet group; dropping the socket clears it. Commands — reprint, reboot, start a payment — travel back down the same connection.
  • Django 5 + Channels: One ASGI application serves the REST surface and three channel routes: the per-device socket, a fleet channel the console subscribes to, and a notifications channel that drives alert toasts. Redis is the channel layer. The containerised console runs a single Daphne for both HTTP and WebSockets; the original compose keeps gunicorn on the WSGI app beside a separate Daphne for /ws/.
  • Domain: Five domain models — Campaign, Device, Photo, Notification, PaymentSession — beside a custom user. Manager methods annotate online-device counts, print totals and campaign state onto the queryset, so a list view does not fan out into a query per kiosk.
  • Admin API: Twenty-seven session-authenticated routes under `/api/admin/`: five viewsets plus auth, dashboard and options endpoints, with query filtering, a page-size-capped pagination envelope and an OpenAPI schema generated by drf-spectacular.
  • Console: React 18 in strict TypeScript, Vite 6, Tailwind v4 with a CSS-first theme block and no config file, TanStack Query v5 for server state, React Router v6 under a `/app` basename. Eleven route paths, code-split past the dashboard, 113 KB gzipped on first load.
  • Payments: The public payment service authenticates with a shared-secret header to store a per-device token, trigger the flow on a kiosk and upsert the resulting session record. The console reads that ledger back with a summary computed over whatever filters are applied — revenue, success rate, rejections.
  • Media: Pillow generates a thumbnail on save. Files land on local disk in development and in an S3-compatible bucket in production, behind the same storage interface. Bulk-selected photos stream back as a single ZIP.
  • Deploy: A dedicated compose file builds the console in a Node stage, collects static in the Python stage and puts nginx in front, then migrates, seeds and serves — one command from clone to a console you can sign in to.

In numbers

Django tests: 96 · Admin API routes: 27 · Production console, gzipped: 113 KB · Live WebSocket channels: 3

Results

  • Fleet state is live. A kiosk losing its connection greys out on every open console immediately, and comes back the same way, with nobody pressing refresh.
  • One place to look. Paper, prints, campaigns, alerts and payment sessions share a dashboard instead of four browser tabs of varying age.
  • Nothing on the floor had to change. The device API and the original server-rendered pages both kept working through the entire rewrite — the new console was additive, not a migration.
  • The API's behaviour is pinned by 73 tests of its own: filter semantics, the pagination envelope, the exact validation messages the forms render inline, and who is allowed to call what.
  • The status palette was validated with a colour checker rather than chosen by eye — it clears WCAG AA on the panel surface and holds a wide separation under deuteranopia, and every status ships an icon and a text label so colour is never the only signal.
  • The whole stack starts from a single command, which is the reason a clickable demo of it exists at all.

What is used

  • Backend: Python 3.12, Django 5.0, Django REST Framework 3.15, drf-spectacular 0.27, Channels 4.1 + channels-redis 4.2, Daphne 4.1, gunicorn 23, Pillow 10, django-crispy-forms 2.3
  • Payment service (separate): FastAPI 0.115, uvicorn 0.30, SQLAlchemy 2.0, httpx 0.27, cryptography 43 (AES-256-CBC + RSA key wrap), KEEPZ integrator API, Python 3.11
  • Data and storage: PostgreSQL 15, SQLite (default engine), Redis 7, psycopg2, django-storages 1.14 + boto3 (DigitalOcean Spaces)
  • Console: React 18.3, TypeScript 5.7 (strict), Vite 6, Tailwind CSS 4 (CSS-first @theme), TanStack Query 5.62, React Router 6.28, i18next 24, lucide-react
  • Infrastructure: Docker + Compose, nginx 1.27-alpine, three-stage console image, GitHub Actions CI

The demo

The demo runs the real console front-end: the same React components, the same TanStack Query cache, the same stylesheet that ships against Django. What it does not run is Django. Underneath, the fetch layer is swapped for an in-browser mock that serves seeded data and applies your writes to an in-memory store — no server, no database, no backend of any kind. There is no third-party request at all — the fonts ship in the bundle with everything else. Every screen is interactive and every edit sticks for the session; reload and you get a pristine dataset back. Fleet telemetry is simulated on a timer instead of arriving over a socket, so devices really do drop offline and prints really do land while you watch.