Files
orion/app/modules/loyalty/services/terminal_device_service.py
Samir Boulahtit a0e3461c48
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
feat(android-terminal): Phase B — setup screen with QR scanner
End-to-end pairing flow:

1. SetupScreen renders a CameraX preview on the left, a manual-entry
   form on the right (dev fallback). Camera permission is requested
   in-place — no accompanist dep.
2. QrScannerView uses ML Kit's barcode scanner (QR format only),
   single-shot fires the decoded JSON to the ViewModel and stops
   analysing.
3. SetupViewModel.pairFromQr decodes via Moshi, persists the pairing
   in DataStore, then verifies by hitting /api/v1/store/loyalty/program
   through the AuthInterceptor (which now sees the new url + token).
   On 200 it warms the staff PIN and category caches and emits Success;
   on failure it rolls back via DeviceConfigRepository.resetDevice() so
   the user is back at a clean Setup with an error.
4. The NavHost watches is_device_set_up and forwards to PIN once Success
   fires. The DataStore key was aligned to "is_device_set_up" so this
   reactive switch keeps working.

Backend: the QR payload generated by POST /merchants/loyalty/devices
now includes store_id and store_name in addition to api_url, store_code
and auth_token, so the tablet doesn't have to resolve them later via a
separate call. Old QRs (which only had three fields) won't decode — the
merchant has to revoke and re-pair, which is the same flow they'd run
anyway after losing a tablet.

Files:
- ui/scanner/QrScannerView.kt    (new) — CameraX + ML Kit composable
- ui/setup/SetupViewModel.kt     (rewrite) — pair flow + state machine
- ui/setup/SetupScreen.kt        (rewrite) — two-pane layout, status overlay
- data/model/ApiModels.kt        — SetupPayload model
- data/repository/DeviceConfigRepository.kt — IS_SET_UP key alignment
- app/modules/loyalty/services/terminal_device_service.py — richer QR payload

Verified by ./gradlew assembleDebug — clean build, all warnings address
in this commit (LocalLifecycleOwner moved to lifecycle.compose, OptIn on
ExperimentalGetImage removed since it's no longer @RequiresOptIn).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:32:25 +02:00

258 lines
8.3 KiB
Python

# app/modules/loyalty/services/terminal_device_service.py
"""Terminal device service.
Pairs Android (or other) POS tablets to a single store. Issues a long-lived
JWT at pair time — the token itself is shown once and never persisted; only
the `jti` lives on the device row, which remains the source of truth for
revocation.
Two failure modes render a token unusable:
- Explicit `revoked_at` (merchant action, row stays for audit)
- `expires_at < now` (1y default, no auto-renewal — re-pair the tablet)
"""
import base64
import io
import json
import logging
import uuid
from datetime import UTC, datetime, timedelta
import qrcode
from sqlalchemy.orm import Session
from app.core.config import settings
from app.modules.loyalty.exceptions import TerminalDeviceNotFoundException
from app.modules.loyalty.models import TerminalDevice
from app.modules.loyalty.schemas.terminal_device import (
TerminalDeviceCreate,
TerminalDeviceUpdate,
)
from middleware.auth import AuthManager
logger = logging.getLogger(__name__)
DEFAULT_TOKEN_LIFETIME = timedelta(days=365)
class TerminalDeviceService:
"""Service for pairing and managing terminal devices."""
def __init__(self):
self._auth = AuthManager()
# =========================================================================
# Reads
# =========================================================================
def get_device(self, db: Session, device_id: int) -> TerminalDevice | None:
return (
db.query(TerminalDevice)
.filter(TerminalDevice.id == device_id)
.first()
)
def require_device(self, db: Session, device_id: int) -> TerminalDevice:
device = self.get_device(db, device_id)
if not device:
raise TerminalDeviceNotFoundException(str(device_id))
return device
def require_device_for_merchant(
self, db: Session, device_id: int, merchant_id: int
) -> TerminalDevice:
"""Fetch a device but enforce it belongs to the given merchant."""
device = self.require_device(db, device_id)
if device.merchant_id != merchant_id:
raise TerminalDeviceNotFoundException(str(device_id))
return device
def get_device_by_jti(self, db: Session, jti: str) -> TerminalDevice | None:
return db.query(TerminalDevice).filter(TerminalDevice.jti == jti).first()
def list_devices(
self,
db: Session,
merchant_id: int,
*,
store_id: int | None = None,
include_revoked: bool = False,
) -> list[TerminalDevice]:
query = db.query(TerminalDevice).filter(
TerminalDevice.merchant_id == merchant_id
)
if store_id is not None:
query = query.filter(TerminalDevice.store_id == store_id)
if not include_revoked:
query = query.filter(TerminalDevice.revoked_at.is_(None))
return query.order_by(
TerminalDevice.store_id, TerminalDevice.created_at.desc()
).all()
# =========================================================================
# Writes
# =========================================================================
def create_device(
self,
db: Session,
*,
merchant_id: int,
data: TerminalDeviceCreate,
created_by_user_id: int | None,
) -> tuple[TerminalDevice, str, dict, str]:
"""Pair a new device and issue its setup token.
Returns ``(device, setup_token, setup_payload, qr_png_base64)``.
The caller must NOT persist ``setup_token`` — only the device row's
``jti`` is kept.
"""
from app.modules.tenancy.models import Store
store = (
db.query(Store)
.filter(Store.id == data.store_id, Store.merchant_id == merchant_id)
.first()
)
if not store:
raise TerminalDeviceNotFoundException(f"store:{data.store_id}")
jti = str(uuid.uuid4())
expires_at = datetime.now(UTC) + DEFAULT_TOKEN_LIFETIME
device = TerminalDevice(
merchant_id=merchant_id,
store_id=store.id,
label=data.label,
jti=jti,
expires_at=expires_at,
created_by_id=created_by_user_id,
)
db.add(device)
db.commit()
db.refresh(device)
token = self._auth.create_device_token(
device_id=device.id,
jti=jti,
merchant_id=merchant_id,
store_id=store.id,
store_code=store.store_code,
expires_at=expires_at,
)
payload = {
"api_url": settings.app_base_url,
"store_id": store.id,
"store_code": store.store_code,
"store_name": store.name,
"auth_token": token,
}
qr_png_base64 = self._render_qr_png_base64(json.dumps(payload))
logger.info(
f"Paired terminal device {device.id} ('{device.label}') for "
f"merchant {merchant_id}, store {store.id} ({store.store_code})"
)
return device, token, payload, qr_png_base64
def update_device(
self,
db: Session,
device: TerminalDevice,
data: TerminalDeviceUpdate,
) -> TerminalDevice:
if data.label is not None:
device.label = data.label
db.commit()
db.refresh(device)
return device
def revoke_device(
self,
db: Session,
device: TerminalDevice,
*,
revoked_by_user_id: int | None,
reason: str | None = None,
) -> TerminalDevice:
if device.revoked_at is not None:
return device
device.revoked_at = datetime.now(UTC)
device.revoked_by_id = revoked_by_user_id
device.revoked_reason = reason
db.commit()
db.refresh(device)
logger.info(
f"Revoked terminal device {device.id} by user {revoked_by_user_id}"
)
return device
def delete_device(self, db: Session, device: TerminalDevice) -> None:
device_id = device.id
db.delete(device)
db.commit()
logger.info(f"Deleted terminal device {device_id}")
def touch_last_seen(
self,
db: Session,
device: TerminalDevice,
ip: str | None = None,
) -> None:
"""Bump last_seen_at/ip on each authenticated request. Best-effort."""
device.last_seen_at = datetime.now(UTC)
if ip is not None:
device.last_seen_ip = ip
try:
db.commit()
except Exception: # noqa: EXC003 - best-effort heartbeat, must not break the request
db.rollback()
logger.warning(
f"Failed to update last_seen for device {device.id}", exc_info=True
)
# =========================================================================
# Helpers
# =========================================================================
@staticmethod
def _render_qr_png_base64(data: str) -> str:
qr = qrcode.QRCode(
version=None,
error_correction=qrcode.constants.ERROR_CORRECT_M,
box_size=8,
border=2,
)
qr.add_data(data)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = io.BytesIO()
img.save(buffer, format="PNG")
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
return f"data:image/png;base64,{encoded}"
@staticmethod
def build_response(device: TerminalDevice) -> dict:
"""Hydrate a response dict including derived status + store name."""
return {
"id": device.id,
"merchant_id": device.merchant_id,
"store_id": device.store_id,
"store_name": device.store.name if device.store else None,
"label": device.label,
"status": device.status,
"expires_at": device.expires_at,
"last_seen_at": device.last_seen_at,
"last_seen_ip": device.last_seen_ip,
"revoked_at": device.revoked_at,
"revoked_by_id": device.revoked_by_id,
"revoked_reason": device.revoked_reason,
"created_by_id": device.created_by_id,
"created_at": device.created_at,
}
terminal_device_service = TerminalDeviceService()