184 lines
11 KiB
Python
184 lines
11 KiB
Python
"""Kanban: sign-in, the board, commercial review and card movement."""
|
|
import hashlib
|
|
import os
|
|
import secrets
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Literal
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
|
from psycopg.types.json import Jsonb
|
|
|
|
from ..core import db
|
|
from ..core.auth import (COOKIE_SECURE, DUMMY_PASSWORD_HASH, audit, client_ip, operator,
|
|
password_matches, throttle)
|
|
from ..core.models import Move, OperatorLogin, Review
|
|
from ..core.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']
|
|
pending = 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 AND q.approved IS NULL
|
|
ORDER BY q.created_at DESC,q.id DESC LIMIT %s''', (BOARD_QUOTE_LIMIT,)).fetchall()
|
|
approved = 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 AND q.approved IS NOT NULL
|
|
ORDER BY q.created_at DESC,q.id DESC LIMIT 20''').fetchall()
|
|
pending_total = c.execute('''SELECT count(*) AS n FROM dtf_local.quotes q
|
|
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
|
|
WHERE o.id IS NULL AND q.approved IS NULL''').fetchone()['n']
|
|
approved_total = c.execute('''SELECT count(*) AS n FROM dtf_local.quotes q
|
|
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
|
|
WHERE o.id IS NULL AND q.approved IS NOT NULL''').fetchone()['n']
|
|
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 pending + approved],
|
|
'pending_total': pending_total, 'approved_total': approved_total,
|
|
'events': c.execute('SELECT * FROM dtf_local.outbox ORDER BY id DESC LIMIT 100').fetchall()}
|
|
|
|
@router.get('/api/operator/quotes')
|
|
def quote_page(kind: Literal['pending','approved'], before_created_at: datetime | None = None,
|
|
before_id: UUID | None = None, limit: int = Query(default=50, ge=1, le=100),
|
|
user=Depends(operator)):
|
|
if (before_created_at is None) != (before_id is None):
|
|
raise HTTPException(422, 'Both quote cursor fields are required')
|
|
approved_filter = 'q.approved IS NULL' if kind == 'pending' else 'q.approved IS NOT NULL'
|
|
cursor = 'AND (q.created_at,q.id)<(%s,%s)' if before_created_at else ''
|
|
params = ((before_created_at,before_id) if before_created_at else ()) + (limit+1,)
|
|
with db.connect() as c:
|
|
rows = c.execute(f'''SELECT q.* FROM dtf_local.quotes q
|
|
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
|
|
WHERE o.id IS NULL AND {approved_filter} {cursor}
|
|
ORDER BY q.created_at DESC,q.id DESC LIMIT %s''', params).fetchall()
|
|
return {'quotes':[quote_view(c,row) for row in rows[:limit]],
|
|
'has_more':len(rows)>limit}
|
|
|
|
@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 any(item.get('production', {}).get('version') != 2 for item in draft['items']):
|
|
raise HTTPException(409, 'Quote uses an obsolete production layout; customer must request a new quote')
|
|
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')
|
|
if item.production.model_dump(mode='json') != original['production'] or item.quality_status != original['quality_status'] or item.quality_acknowledged != original['quality_acknowledged']:
|
|
raise HTTPException(422, 'Production instructions and customer acknowledgement cannot change during commercial 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'], 'production': original['production'],
|
|
'quality_status': original['quality_status'],
|
|
'quality_acknowledged': original['quality_acknowledged']})
|
|
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}
|