"""Kanban: sign-in, the board, commercial review and card movement.""" import hashlib import os import secrets from datetime import datetime, timedelta, timezone from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Request, Response from psycopg.types.json import Jsonb from .. import db from ..auth import (COOKIE_SECURE, DUMMY_PASSWORD_HASH, audit, client_ip, operator, password_matches, throttle) from ..models import Move, OperatorLogin, Review from ..pricing import price from ..runtime import (BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, STATES, TRANSITIONS, enqueue, freight, quote_view, storage, upload_row) from ..scanning import require_clean router = APIRouter() @router.post('/api/operator/login') def operator_login(body: OperatorLogin, request: Request, response: Response): email = body.email throttle('operator:'+email, request) with db.connect() as c: account = c.execute('SELECT * FROM dtf_local.operators WHERE email=%s', (email,)).fetchone() if not c.execute('SELECT 1 FROM dtf_local.operators WHERE active LIMIT 1').fetchone(): raise HTTPException(503, 'No Kanban operator account is configured') # Comparable password work whether or not the account exists or is active. stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH matches = password_matches(body.password, stored) if not account or not account['active'] or not matches: audit('operator_login_failed', ip=client_ip(request), operator=email) raise HTTPException(401, 'Invalid operator login') token = secrets.token_urlsafe(32) with db.connect() as c: previous = hashlib.sha256(request.cookies.get('dtf_operator','').encode()).hexdigest() c.execute('DELETE FROM dtf_local.operator_sessions WHERE token_hash=%s', (previous,)) c.execute('INSERT INTO dtf_local.operator_sessions(token_hash,username) VALUES(%s,%s)', (hashlib.sha256(token.encode()).hexdigest(), email)) c.execute('UPDATE dtf_local.operators SET last_login_at=now() WHERE id=%s', (account['id'],)) response.set_cookie('dtf_operator', token, httponly=True, secure=COOKIE_SECURE, samesite='strict', path='/api/operator', max_age=28800) audit('operator_login_success', operator=email) return {'ok': True} @router.post('/api/operator/logout') def operator_logout(request: Request, response: Response): with db.connect() as c: digest = hashlib.sha256(request.cookies.get('dtf_operator','').encode()).hexdigest() c.execute('DELETE FROM dtf_local.operator_sessions WHERE token_hash=%s', (digest,)) response.delete_cookie('dtf_operator', path='/api/operator', httponly=True, secure=COOKIE_SECURE, samesite='strict') audit('operator_logout') return {'ok': True} @router.get('/api/operator/board') def board(user=Depends(operator)): with db.connect() as c: # Everything still in progress, however old: an operator must never lose a # card they can act on. Finished orders are terminal and only accumulate, # so the board carries a recent window of them and reports the true total. active = c.execute("SELECT * FROM dtf_local.orders WHERE state<>'fin' ORDER BY created_at").fetchall() finished = c.execute("SELECT * FROM dtf_local.orders WHERE state='fin' ORDER BY created_at DESC LIMIT %s", (BOARD_FINISHED_LIMIT,)).fetchall() finished_total = c.execute("SELECT count(*) AS n FROM dtf_local.orders WHERE state='fin'").fetchone()['n'] quotes = c.execute('''SELECT q.* FROM dtf_local.quotes q LEFT JOIN dtf_local.orders o ON o.quote_id=q.id WHERE o.id IS NULL ORDER BY q.created_at LIMIT %s''', (BOARD_QUOTE_LIMIT,)).fetchall() return {'states': STATES, 'transitions': TRANSITIONS, 'orders': active + list(reversed(finished)), 'finished_shown': len(finished), 'finished_total': finished_total, 'quotes': [quote_view(c, q) for q in quotes], 'events': c.execute('SELECT * FROM dtf_local.outbox ORDER BY id DESC LIMIT 100').fetchall()} @router.post('/api/operator/quotes/{uid}/approve') def approve(uid: UUID, body: Review, user=Depends(operator)): with db.connect() as c: row = c.execute('SELECT * FROM dtf_local.quotes WHERE id=%s FOR UPDATE', (uid,)).fetchone() if not row: raise HTTPException(404, 'Quote not found') if row['approved']: raise HTTPException(409, 'Approved quotes are immutable; request a new quote') draft = row['draft'] if len(body.items) != len(draft['items']): raise HTTPException(422, 'Review must cover every item') items = [] for item, original in zip(body.items, draft['items']): if item.mode != original['mode'] or list(map(str,item.uploads)) != original['uploads']: raise HTTPException(422, 'Product mode and attached files cannot change during review') for upload_id in item.uploads: require_clean(upload_row(c, upload_id, row['owner'])) items.append({**price(item.mode, str(item.metres), item.grade), 'uploads': original['uploads']}) quoted_freight = freight.quote(**draft['freight']) approved = {'customer': draft['customer'], 'items': items, 'freight': quoted_freight, 'total_cents': sum(i['total_cents'] for i in items)+quoted_freight['total_cents']} c.execute('UPDATE dtf_local.quotes SET approved=%s, reviewed_by=%s, approved_at=now() WHERE id=%s', (Jsonb(approved),user,uid)) return approved @router.post('/api/operator/orders/{uid}/move') def move(uid: UUID, body: Move, user=Depends(operator)): with db.connect() as c: row = c.execute('SELECT * FROM dtf_local.orders WHERE id=%s FOR UPDATE', (uid,)).fetchone() if not row: raise HTTPException(404, 'Order not found') if body.version != row['version']: raise HTTPException(409, 'Order changed; refresh the board') if body.state == row['state']: return row if body.state not in TRANSITIONS[row['state']]: raise HTTPException(409, 'Move is not allowed from this state') if body.state == 'cor' and not body.reason.strip(): raise HTTPException(422, 'Correction requires a reason') if body.state in ('fil','imp'): coverage = c.execute('SELECT DISTINCT f.item_index FROM dtf_local.order_files f JOIN dtf_local.uploads u ON u.id=f.upload_id WHERE f.order_id=%s AND f.kind=\'final\' AND f.active AND u.expires_at>now() AND u.purged_at IS NULL AND u.scan_state=\'clean\'', (uid,)).fetchall() if {r['item_index'] for r in coverage} != set(range(len(row['snapshot']['items']))): raise HTTPException(409, 'Approve a complete final-file set for every item before queueing') if body.state == 'cor': c.execute("UPDATE dtf_local.order_files SET active=false WHERE order_id=%s AND kind='final'", (uid,)) c.execute('INSERT INTO dtf_local.movements(order_id,from_state,to_state,operator,reason) VALUES(%s,%s,%s,%s,%s)', (uid,row['state'],body.state,user,body.reason)) changed = c.execute('UPDATE dtf_local.orders SET state=%s, version=version+1, updated_at=now() WHERE id=%s RETURNING *', (body.state,uid)).fetchone() events = {'imp':'production_started','cor':'correction_needed','fin':'ready'} if body.state in events: for provider in ('tiny','whatsapp'): enqueue(c, f'{uid}:{changed["version"]}:{provider}', provider, {'order_id':str(uid), 'number':row['number'], 'event':events[body.state], 'reason':body.reason, 'customer_path': f'/portal.html?order={uid}'}) return changed @router.get('/api/operator/orders/{uid}/history') def history(uid: UUID, user=Depends(operator)): with db.connect() as c: return c.execute('SELECT * FROM dtf_local.movements WHERE order_id=%s ORDER BY id', (uid,)).fetchall() @router.get('/api/operator/uploads/{uid}/download') def download(uid: UUID, user=Depends(operator)): with db.connect() as c: row = c.execute('SELECT * FROM dtf_local.uploads WHERE id=%s AND complete', (uid,)).fetchone() if not row: raise HTTPException(404, 'Completed upload not found') if row['expires_at'] <= datetime.now(timezone.utc): raise HTTPException(410, 'Artwork retention expired') require_clean(row) return {'name':row['name'], 'url':storage.download(row['object_key'],row['name']), 'expires_in':300}