All checks were successful
Build and deploy / Validate source (push) Successful in 9s
Build and deploy / Integration suite on a real stack (push) Successful in 2m49s
Build and deploy / Secret scan and release gate (push) Successful in 9s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
Tiny v3 replaces the v2 token adapter. An operator connects Tiny once from the Kanban; the callback is authorised by a single-use state, because Tiny's cross-site redirect does not carry the SameSite=Strict operator cookie. Tokens are kept in provider_tokens, the refresh token rotates under a row lock, and the worker keeps the connection alive while order creation is off. Orders find or create the customer's contact by CNPJ, then POST /pedidos with product ids from TINY_PRODUCT_TEXTIL_FOLHA, _TEXTIL_AVULSA, _UV_FOLHA and _UV_AVULSA and numeroOrdemCompra DTF-<number>; a retry searches the customer's recent orders for that number first. The product settings avoid a _FILE suffix, which the secrets loader reads as a secret file path. Production passes the application credentials through but keeps TINY_ADAPTER fake: Tiny has no sandbox, so creating real orders waits for a supervised test. compose.providers.yaml gives the local API and worker an internet route for provider testing; the default local stack still has none. Verified with the full CI integration sequence locally, including the new tiny_oauth_test against the real database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
255 lines
15 KiB
Python
255 lines
15 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 fastapi.responses import RedirectResponse
|
|
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, Resolution, Review
|
|
from ..core.pricing import price
|
|
from ..printjobs import queue as queue_print_files
|
|
from .. import tiny
|
|
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']
|
|
orders = active + list(reversed(finished))
|
|
generated = c.execute('''SELECT p.order_id,p.item_index,p.status,p.upload_id,p.detail,u.name
|
|
FROM dtf_local.print_files p LEFT JOIN dtf_local.uploads u ON u.id=p.upload_id
|
|
WHERE p.order_id=ANY(%s) ORDER BY p.item_index''', ([o['id'] for o in orders],)).fetchall()
|
|
for order in orders:
|
|
order['print_files'] = [row for row in generated if row['order_id'] == order['id']]
|
|
# A paid notification that did not become an order is money received
|
|
# for nothing the factory will make. It stays on the board until a
|
|
# person records what was done about it.
|
|
refused = c.execute('''SELECT id,provider,event_id,reference,status,amount_cents,received_at,outcome
|
|
FROM dtf_local.payment_events WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL
|
|
ORDER BY received_at LIMIT 100''').fetchall()
|
|
return {'states': STATES, 'transitions': TRANSITIONS,
|
|
'orders': orders, 'payment_issues': refused, 'tiny': tiny_status(),
|
|
'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,
|
|
'destination': draft.get('destination'),
|
|
'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.post('/api/operator/orders/{uid}/print-files')
|
|
def regenerate(uid: UUID, user=Depends(operator)):
|
|
"""Queue generation again for items that failed or went to manual preparation,
|
|
and for orders paid before the generator existed."""
|
|
with db.connect() as c:
|
|
row = c.execute('SELECT id,snapshot,state FROM dtf_local.orders WHERE id=%s FOR UPDATE', (uid,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, 'Order not found')
|
|
if row['state'] not in ('rec','tra'):
|
|
raise HTTPException(409, 'Print files are generated only before the order is queued')
|
|
if c.execute("SELECT 1 FROM dtf_local.order_files WHERE order_id=%s AND kind='correction' LIMIT 1", (uid,)).fetchone():
|
|
raise HTTPException(409, 'A customer correction replaced the original artwork; prepare the final file by hand')
|
|
queue_print_files(c, uid, len(row['snapshot']['items']), only_missing=True)
|
|
audit('print_file_requeued', order=str(uid), operator=user)
|
|
return c.execute('SELECT * FROM dtf_local.print_files WHERE order_id=%s ORDER BY item_index', (uid,)).fetchall()
|
|
|
|
@router.post('/api/operator/payment-events/{uid}/resolve')
|
|
def resolve_payment(uid: UUID, body: Resolution, user=Depends(operator)):
|
|
with db.connect() as c:
|
|
row = c.execute('''UPDATE dtf_local.payment_events SET resolved_at=now(), resolved_by=%s, resolution=%s
|
|
WHERE id=%s AND (outcome LIKE 'refused%%' OR outcome LIKE 'attention%%') AND resolved_at IS NULL RETURNING id''',
|
|
(user, body.note, uid)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, 'No open payment issue with this id')
|
|
audit('payment_issue_resolved', payment_event=str(uid), operator=user)
|
|
return {'ok': True}
|
|
|
|
def tiny_status():
|
|
if not tiny.configured():
|
|
return {'configured': False}
|
|
return {'configured': True, 'orders_enabled': os.environ.get('TINY_ADAPTER') == 'tiny',
|
|
**tiny.TinyAuth().status()}
|
|
|
|
@router.post('/api/operator/tiny/connect')
|
|
def tiny_connect(user=Depends(operator)):
|
|
"""Start the one-time authorisation of this system in the client's Tiny."""
|
|
if not tiny.configured():
|
|
raise HTTPException(503, 'Tiny application is not configured')
|
|
audit('tiny_connect_started', operator=user)
|
|
return {'url': tiny.TinyAuth().authorize_url(user)}
|
|
|
|
@router.get('/api/operator/tiny/callback')
|
|
def tiny_callback(code: str = Query(max_length=4096), state: str = Query(max_length=128)):
|
|
"""Tiny's redirect back. Cross-site, so the operator cookie is absent: the
|
|
single-use state an operator created is what authorises it."""
|
|
if not tiny.configured():
|
|
raise HTTPException(503, 'Tiny application is not configured')
|
|
try:
|
|
who = tiny.TinyAuth().complete(code, state)
|
|
except tiny.TinyError:
|
|
audit('tiny_connect_failed')
|
|
return RedirectResponse('/?tiny=failed', status_code=303)
|
|
audit('tiny_connected', operator=who)
|
|
return RedirectResponse('/?tiny=connected', status_code=303)
|
|
|
|
@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}
|