All checks were successful
Build and deploy / Validate source (push) Successful in 1m25s
Build and deploy / Integration suite on a real stack (push) Successful in 3m16s
Build and deploy / Secret scan and release gate (push) Successful in 13s
Build and deploy / Publish images (push) Successful in 1m29s
The customer's browser sends the small picture it already makes of each file (at most 300 KB, WebP/JPEG/PNG read from the bytes, own uploads only); it goes when the file's bytes go. Board cards, quote rows, the order panel and the quote detail show it, with the layout drawing as a second view and as the fallback for files without a picture. Cotações gets a customer filter (e-mail, CNPJ, WhatsApp) and product, layout and resolution-warning chips, with the page bar always shown. The board adds Entrega, Retirada and stalled-order chips. The top search now covers orders in any stage and unpaid quotes; CNPJ and phone match by digits only when nothing but digits and punctuation was typed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
499 lines
28 KiB
Python
499 lines
28 KiB
Python
"""Kanban: sign-in, the board, commercial review and card movement."""
|
|
import hashlib
|
|
import os
|
|
import re
|
|
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 ..core import db
|
|
from ..core.auth import (COOKIE_SECURE, DUMMY_PASSWORD_HASH, audit, client_ip, operator,
|
|
login_failed, password_matches, throttle)
|
|
from ..core.models import Move, OperatorLogin, Resolution, Review
|
|
from ..printjobs import queue as queue_print_files
|
|
from .. import payments, quote_review
|
|
from .. import tiny
|
|
from ..runtime import (BACK, BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, ENVIRONMENT, STATES, TRANSITIONS, payment,
|
|
enqueue, freight, quote_view, storage)
|
|
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, 'Nenhuma conta de operador configurada.')
|
|
# 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:
|
|
login_failed('operator:'+email)
|
|
audit('operator_login_failed', ip=client_ip(request), operator=email)
|
|
raise HTTPException(401, 'E-mail ou senha inválidos.')
|
|
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}
|
|
|
|
def freight_status():
|
|
"""What delivery the Site offers, and the package rule it prices with."""
|
|
if freight.name != 'jadlog':
|
|
return {'provider': freight.name}
|
|
return {'provider': 'jadlog', 'base_kg': str(freight.base_kg), 'per_metre_kg': str(freight.per_metre_kg),
|
|
'production_days': freight.production_days}
|
|
|
|
|
|
def backup_status(c):
|
|
"""The latest database backup and the latest run, which may have failed."""
|
|
ok = c.execute('''SELECT finished_at,bytes FROM dtf_local.backups WHERE status='ok'
|
|
ORDER BY finished_at DESC LIMIT 1''').fetchone()
|
|
last = c.execute('SELECT finished_at,status,detail FROM dtf_local.backups ORDER BY finished_at DESC LIMIT 1').fetchone()
|
|
return {'last_ok': ok['finished_at'].isoformat() if ok else None, 'bytes': ok['bytes'] if ok else None,
|
|
'failed': last['detail'] if last and last['status'] == 'failed' else None,
|
|
'failed_at': last['finished_at'].isoformat() if last and last['status'] == 'failed' else None}
|
|
|
|
|
|
@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 = with_print_files(c, active + list(reversed(finished)))
|
|
# A paid notification that did not become an order is money received
|
|
# for nothing the factory will make. The board carries the count; the
|
|
# Pagamentos tab pages through them until someone records a resolution.
|
|
issues_total = c.execute('''SELECT count(*) AS n FROM dtf_local.payment_events
|
|
WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL''').fetchone()['n']
|
|
backup = backup_status(c)
|
|
quotes = [quote_view(c, q) for q in pending + approved]
|
|
return {'states': STATES, 'transitions': TRANSITIONS, 'back': BACK,
|
|
'orders': orders, 'payment_issues_total': issues_total, 'tiny': tiny_status(), 'operator': user,
|
|
'providers': {'payment': payment.name, 'freight': freight_status(), 'backup': backup}, 'environment': ENVIRONMENT,
|
|
'finished_shown': len(finished), 'finished_total': finished_total,
|
|
'quotes': quotes, 'thumbnails': thumbnails(c, orders, quotes),
|
|
'pending_total': pending_total, 'approved_total': approved_total}
|
|
|
|
|
|
def uploads_of(rows):
|
|
"""Every upload id an order (its snapshot) or a quote (its draft) refers to."""
|
|
ids = set()
|
|
for row in rows:
|
|
content = row.get('snapshot') or row.get('draft') or {}
|
|
for item in content.get('items') or []:
|
|
ids.update(str(uid) for uid in item.get('uploads') or [])
|
|
return ids
|
|
|
|
|
|
def thumbnails(c, *groups):
|
|
"""The uploads among these orders and quotes that have an artwork picture,
|
|
so the Kanban asks only for pictures that exist."""
|
|
ids = set().union(*(uploads_of(rows) for rows in groups))
|
|
if not ids:
|
|
return []
|
|
rows = c.execute('SELECT upload_id FROM dtf_local.upload_thumbnails WHERE upload_id=ANY(%s::uuid[])',
|
|
(list(ids),)).fetchall()
|
|
return [str(r['upload_id']) for r in rows]
|
|
|
|
|
|
def with_print_files(c, orders):
|
|
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']]
|
|
return orders
|
|
|
|
|
|
def cursor_pair(first, second):
|
|
if (first is None) != (second is None):
|
|
raise HTTPException(422, 'Both cursor fields are required')
|
|
return first is not None
|
|
|
|
|
|
@router.get('/api/operator/orders/finished')
|
|
def finished_page(before_created_at: datetime | None = None, before_id: UUID | None = None,
|
|
limit: int = Query(default=50, ge=1, le=100), user=Depends(operator)):
|
|
"""Older finished orders, newest first, beyond the board's recent window."""
|
|
paged = cursor_pair(before_created_at, before_id)
|
|
with db.connect() as c:
|
|
rows = c.execute('''SELECT * FROM dtf_local.orders WHERE state='fin'
|
|
''' + ('AND (created_at,id)<(%s,%s) ' if paged else '') + '''
|
|
ORDER BY created_at DESC,id DESC LIMIT %s''',
|
|
((before_created_at, before_id) if paged else ()) + (limit + 1,)).fetchall()
|
|
orders = with_print_files(c, rows[:limit])
|
|
return {'orders': orders, 'has_more': len(rows) > limit, 'thumbnails': thumbnails(c, orders)}
|
|
|
|
|
|
@router.get('/api/operator/payment-events')
|
|
def payment_events(state: Literal['open','resolved','all'] = 'open',
|
|
offset: int = Query(default=0, ge=0),
|
|
limit: int = Query(default=20, ge=1, le=100), user=Depends(operator)):
|
|
"""Payments that needed a person, newest first: open ones to act on, resolved ones as history."""
|
|
where = {'open': 'AND resolved_at IS NULL', 'resolved': 'AND resolved_at IS NOT NULL', 'all': ''}[state]
|
|
with db.connect() as c:
|
|
rows = c.execute('''SELECT id,provider,event_id,reference,status,amount_cents,received_at,outcome,
|
|
resolved_at,resolved_by,resolution
|
|
FROM dtf_local.payment_events WHERE (outcome LIKE 'refused%%' OR outcome LIKE 'attention%%') ''' + where + '''
|
|
ORDER BY received_at DESC,id DESC LIMIT %s OFFSET %s''', (limit, offset)).fetchall()
|
|
total = c.execute('''SELECT count(*) AS n FROM dtf_local.payment_events
|
|
WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') ''' + where).fetchone()['n']
|
|
return {'issues': rows, 'total': total}
|
|
|
|
|
|
@router.get('/api/operator/events')
|
|
def events(provider: Literal['tiny','whatsapp'] | None = None,
|
|
status: Literal['delivered','queued','failing'] | None = None,
|
|
event: Literal['payment_approved','production_started','correction_needed','ready'] | None = None,
|
|
order: int | None = Query(default=None, ge=1), offset: int = Query(default=0, ge=0),
|
|
limit: int = Query(default=20, ge=1, le=100), user=Depends(operator)):
|
|
"""The integration send log, newest first, filtered, by page with a total."""
|
|
clauses, params = [], []
|
|
if provider:
|
|
clauses.append('provider=%s'); params.append(provider)
|
|
if status == 'delivered':
|
|
clauses.append('delivered_at IS NOT NULL')
|
|
elif status == 'queued':
|
|
clauses.append('delivered_at IS NULL AND last_error IS NULL')
|
|
elif status == 'failing':
|
|
clauses.append('delivered_at IS NULL AND last_error IS NOT NULL')
|
|
if event:
|
|
clauses.append("payload->>'event'=%s"); params.append(event)
|
|
if order:
|
|
clauses.append("payload->>'number'=%s"); params.append(str(order))
|
|
where = ('WHERE ' + ' AND '.join(clauses)) if clauses else ''
|
|
with db.connect() as c:
|
|
rows = c.execute(f'SELECT * FROM dtf_local.outbox {where} ORDER BY id DESC LIMIT %s OFFSET %s',
|
|
(*params, limit, offset)).fetchall()
|
|
total = c.execute(f'SELECT count(*) AS n FROM dtf_local.outbox {where}', params).fetchone()['n']
|
|
return {'events': rows, 'total': total}
|
|
|
|
MODES = {'textil': {'file', 'avulsa'}, 'uv': {'uvfile', 'uv'},
|
|
'folha': {'file', 'uvfile'}, 'avulsa': {'avulsa', 'uv'}}
|
|
|
|
|
|
def like(text):
|
|
"""A LIKE pattern that matches the text anywhere, its own % and _ literal."""
|
|
return '%' + re.sub(r'([\\%_])', r'\\\1', text) + '%'
|
|
|
|
|
|
def numeric(q):
|
|
"""The digits of a query made of digits and punctuation only ("#1042",
|
|
"11.222.333/0001-81", "(16) 99999-0000"); empty when it has letters."""
|
|
return '' if re.search(r'[^\W\d_]', q) else re.sub(r'\D', '', q)
|
|
|
|
|
|
def customer_match(column, q):
|
|
"""SQL and parameters matching a customer (e-mail, CNPJ or WhatsApp) in an
|
|
order snapshot or a quote draft. CNPJ and phone match by their digits, and
|
|
only when nothing but digits and punctuation was typed: the digits of an
|
|
e-mail address are not a phone number."""
|
|
text, digits = q.strip().lower(), numeric(q)
|
|
sql = f"lower({column}->'customer'->>'mail') LIKE %s"
|
|
params = [like(text)]
|
|
if len(digits) >= 3:
|
|
sql += (f" OR regexp_replace(coalesce({column}->'customer'->>'cnpj',''),'\\D','','g') LIKE %s"
|
|
f" OR regexp_replace(coalesce({column}->'customer'->>'zap',''),'\\D','','g') LIKE %s")
|
|
params += [like(digits), like(digits)]
|
|
return '(' + sql + ')', params
|
|
|
|
|
|
@router.get('/api/operator/quotes')
|
|
def quote_page(kind: Literal['pending','approved'], before_created_at: datetime | None = None,
|
|
before_id: UUID | None = None, offset: int = Query(default=0, ge=0),
|
|
limit: int = Query(default=50, ge=1, le=100),
|
|
q: str | None = Query(default=None, max_length=100),
|
|
product: Literal['textil','uv'] | None = None, layout: Literal['folha','avulsa'] | None = None,
|
|
flag: Literal['ressalva'] | None = None, user=Depends(operator)):
|
|
"""Unpaid quotes, newest first: by cursor, or by page (offset) with a total;
|
|
filtered by customer, product, layout and an accepted resolution warning."""
|
|
if (before_created_at is None) != (before_id is None):
|
|
raise HTTPException(422, 'Both quote cursor fields are required')
|
|
clauses = ['o.id IS NULL', 'q.approved IS NULL' if kind == 'pending' else 'q.approved IS NOT NULL']
|
|
params = []
|
|
if q and q.strip():
|
|
sql, values = customer_match('q.draft', q)
|
|
clauses.append(sql); params += values
|
|
if product or layout:
|
|
modes = set.intersection(*(MODES[k] for k in (product, layout) if k))
|
|
clauses.append("EXISTS (SELECT 1 FROM jsonb_array_elements(q.draft->'items') i WHERE i->>'mode'=ANY(%s))")
|
|
params.append(sorted(modes))
|
|
if flag:
|
|
clauses.append("EXISTS (SELECT 1 FROM jsonb_array_elements(q.draft->'items') i WHERE i->>'quality_status'='warning')")
|
|
where = ' AND '.join(clauses)
|
|
cursor = 'AND (q.created_at,q.id)<(%s,%s)' if before_created_at else ''
|
|
skip = 0 if before_created_at else offset
|
|
page = params + ([before_created_at, before_id] if before_created_at else []) + [limit+1, skip]
|
|
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 {where} {cursor}
|
|
ORDER BY q.created_at DESC,q.id DESC LIMIT %s OFFSET %s''', page).fetchall()
|
|
total = c.execute(f'''SELECT count(*) AS n FROM dtf_local.quotes q
|
|
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
|
|
WHERE {where}''', params).fetchone()['n']
|
|
quotes = [quote_view(c,row) for row in rows[:limit]]
|
|
return {'quotes': quotes, 'has_more': len(rows)>limit, 'total': total,
|
|
'thumbnails': thumbnails(c, quotes)}
|
|
|
|
|
|
@router.get('/api/operator/search')
|
|
def search(q: str = Query(min_length=2, max_length=100), user=Depends(operator)):
|
|
"""One search over orders (any stage) and unpaid quotes: customer e-mail,
|
|
CNPJ or WhatsApp, the delivery recipient, or the order number."""
|
|
match, params = customer_match('snapshot', q)
|
|
digits = numeric(q)
|
|
sql = match + " OR lower(coalesce(snapshot->'destination'->>'recipient','')) LIKE %s"
|
|
params.append(like(q.strip().lower()))
|
|
if digits and len(digits) <= 9:
|
|
sql += ' OR number=%s'
|
|
params.append(int(digits))
|
|
quote_match, quote_params = customer_match('q.draft', q)
|
|
with db.connect() as c:
|
|
orders = c.execute(f'''SELECT * FROM dtf_local.orders WHERE {sql}
|
|
ORDER BY created_at DESC LIMIT 8''', params).fetchall()
|
|
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 {quote_match}
|
|
ORDER BY q.created_at DESC LIMIT 8''', quote_params).fetchall()
|
|
orders = with_print_files(c, orders)
|
|
quotes = [quote_view(c, row) for row in rows]
|
|
return {'orders': orders, 'quotes': quotes, 'thumbnails': thumbnails(c, orders, quotes)}
|
|
|
|
@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')
|
|
return quote_review.approve(c, row, body.items, user)
|
|
|
|
@router.post('/api/operator/quotes/{uid}/test-order')
|
|
def test_order(uid: UUID, user=Depends(operator)):
|
|
"""An order for an approved quote without payment, to try the Kanban,
|
|
files and print flow in production. Marked TEST on the board, never sent
|
|
to Tiny or WhatsApp, and audited."""
|
|
with db.connect() as c:
|
|
try:
|
|
quote = payments.approved_quote(c, uid)
|
|
except payments.PaymentRefused as refusal:
|
|
raise HTTPException(404 if 'not found' in str(refusal) else 409, str(refusal))
|
|
receipt = {'provider': payments.TEST_PROVIDER, 'id': f'teste-{uid}', 'status': 'paid',
|
|
'total_cents': quote['approved']['total_cents'], 'operator': user}
|
|
order, created = payments.create_order(c, quote, receipt)
|
|
if created:
|
|
audit('test_order_created', order=str(order['id']), operator=user)
|
|
return order
|
|
|
|
|
|
@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, 'O pedido mudou. Clique em Atualizar.')
|
|
if body.state == row['state']:
|
|
return row
|
|
back = BACK.get(row['state']) == body.state
|
|
if body.state not in TRANSITIONS[row['state']] and not back:
|
|
raise HTTPException(409, f"Não dá para ir de {STATES[row['state']]} para {STATES[body.state]}.")
|
|
if body.state == 'cor' and not body.reason.strip():
|
|
raise HTTPException(422, 'Informe o motivo da correção.')
|
|
if back and not body.reason.strip():
|
|
raise HTTPException(422, 'Informe por que o pedido está voltando de etapa.')
|
|
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, 'Aprove os arquivos finais de todos os itens antes de colocar na fila.')
|
|
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,back) VALUES(%s,%s,%s,%s,%s,%s)',
|
|
(uid,row['state'],body.state,user,body.reason,back))
|
|
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 and not back and not payments.is_test(row):
|
|
# "Production started" and "ready" reach the customer once per order,
|
|
# even if a mistaken move is undone and made again. Each correction
|
|
# is a new request, so it keeps one message per movement.
|
|
once = body.state in ('imp','fin')
|
|
event = {'order_id':str(uid), 'number':row['number'], 'event':events[body.state], 'reason':body.reason,
|
|
'customer_path': f'/portal.html?order={uid}'}
|
|
# Tiny needs the order (customer, pickup or delivery) and, once the
|
|
# sale reached it, its id, to set the situação without a search.
|
|
sale = c.execute("SELECT receipt FROM dtf_local.outbox WHERE event_key=%s AND delivered_at IS NOT NULL",
|
|
(f'{uid}:paid:tiny',)).fetchone()
|
|
tiny = {**event, 'order': row['snapshot'], 'tiny_id': ((sale or {}).get('receipt') or {}).get('tiny_id')}
|
|
for provider, payload in (('tiny', tiny), ('whatsapp', event)):
|
|
key = f'{uid}:{events[body.state]}:{provider}' if once else f'{uid}:{changed["version"]}:{provider}'
|
|
enqueue(c, key, provider, payload)
|
|
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.post('/api/operator/tiny/test')
|
|
def tiny_test(user=Depends(operator)):
|
|
"""Read one order and one contact from Tiny. Creates nothing."""
|
|
if not tiny.configured():
|
|
raise HTTPException(503, 'Tiny application is not configured')
|
|
try:
|
|
results = tiny.check()
|
|
except tiny.TinyNotConnected as exc:
|
|
raise HTTPException(409, str(exc))
|
|
audit('tiny_tested', operator=user, ok=all(v == 'ok' for v in results.values()))
|
|
return {'ok': all(v == 'ok' for v in results.values()), 'results': results}
|
|
|
|
@router.post('/api/operator/mercadopago/check')
|
|
def mercadopago_check(user=Depends(operator)):
|
|
"""The Mercado Pago account behind the configured token; reads only."""
|
|
if payment.name != 'mercadopago':
|
|
raise HTTPException(409, 'Mercado Pago não está configurado')
|
|
from ..mercadopago_probe import check
|
|
audit('mercadopago_check', operator=user)
|
|
result = check(payment.access_token)
|
|
# Whether Mercado Pago's notifications reach this server and pass the
|
|
# signature: every accepted one is recorded, a refused one is audited.
|
|
with db.connect() as c:
|
|
received = c.execute('''SELECT count(*) AS n, max(received_at) AS last FROM dtf_local.payment_events
|
|
WHERE provider='mercadopago' AND received_at > now()-interval '24 hours' ''').fetchone()
|
|
last = c.execute('''SELECT received_at,status,outcome FROM dtf_local.payment_events
|
|
WHERE provider='mercadopago' ORDER BY received_at DESC LIMIT 1''').fetchone()
|
|
refused = c.execute('''SELECT count(*) AS n FROM dtf_local.security_events
|
|
WHERE event='payment_webhook_rejected' AND created_at > now()-interval '24 hours' ''').fetchone()
|
|
result['webhooks'] = {'accepted_24h': received['n'], 'refused_24h': refused['n'], 'last': last}
|
|
return result
|
|
|
|
|
|
@router.get('/api/operator/tiny/callback')
|
|
def tiny_callback(code: str = Query('', max_length=4096), state: str = Query('', max_length=128),
|
|
error: 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. Tiny reports a
|
|
refusal (the operator declined, or offline access is not allowed for this
|
|
application) with `error` instead of a code."""
|
|
if not tiny.configured():
|
|
raise HTTPException(503, 'Tiny application is not configured')
|
|
try:
|
|
if error == 'invalid_scope':
|
|
retry = tiny.TinyAuth().without_offline(state)
|
|
audit('tiny_offline_refused')
|
|
return RedirectResponse(retry, status_code=303)
|
|
if error or not code:
|
|
raise tiny.TinyError(f'Tiny returned {error or "no code"}')
|
|
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}/thumbnail')
|
|
def thumbnail(uid: UUID, user=Depends(operator)):
|
|
"""The browser-made picture of an artwork. Cached by the operator's browser:
|
|
the board is redrawn on every refresh."""
|
|
with db.connect() as c:
|
|
row = c.execute('SELECT mime,data FROM dtf_local.upload_thumbnails WHERE upload_id=%s', (uid,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, 'No picture for this upload')
|
|
return Response(bytes(row['data']), media_type=row['mime'],
|
|
headers={'Cache-Control': 'private, max-age=86400', 'Content-Disposition': 'inline'})
|
|
|
|
@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}
|