A move back undoes an operator's mistake and its reason is internal. The customer's history now omits back moves and shows a reason only for a correction; the smoke test checks both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
123 lines
6.0 KiB
Python
123 lines
6.0 KiB
Python
"""Customer accounts, their orders, and the corrections they submit."""
|
|
from datetime import datetime, timedelta, timezone
|
|
from uuid import UUID, uuid4
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
|
from psycopg.errors import UniqueViolation
|
|
from psycopg.types.json import Jsonb
|
|
|
|
from ..core import db
|
|
from ..artwork import submit_files
|
|
from ..core.auth import (DUMMY_PASSWORD_HASH, audit, client_ip, new_session, owner,
|
|
login_failed, password_hash, password_matches, session_row, throttle, transfer_guest)
|
|
from ..core.models import ArtworkSubmission, Login, Register
|
|
from ..runtime import STATES, file_rows, owned_order, storage
|
|
from ..scanning import require_clean
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def current(request):
|
|
try: return session_row(request)
|
|
except HTTPException: return None
|
|
|
|
|
|
@router.post('/api/account/register')
|
|
def register(body: Register, request: Request, response: Response):
|
|
email = body.customer.mail.strip().lower()
|
|
throttle(email, request)
|
|
# Registration attempts keep counting against the email, as before.
|
|
login_failed(email)
|
|
previous = current(request)
|
|
encoded = password_hash(body.password)
|
|
identity = uuid4()
|
|
profile = body.customer.model_dump()
|
|
profile['mail'] = email
|
|
try:
|
|
with db.connect() as c:
|
|
if previous and c.execute('SELECT id FROM dtf_local.accounts WHERE id=%s', (previous['owner'],)).fetchone():
|
|
raise HTTPException(409, 'Sign out before registering another account')
|
|
c.execute('INSERT INTO dtf_local.accounts(id,email,password_hash,profile) VALUES(%s,%s,%s,%s)', (identity,email,encoded,Jsonb(profile)))
|
|
if previous: transfer_guest(c, previous, identity)
|
|
new_session(c, response, identity)
|
|
except UniqueViolation:
|
|
raise HTTPException(409, 'An account already exists; sign in')
|
|
audit('account_registered', account=str(identity))
|
|
return {'customer': profile}
|
|
|
|
@router.post('/api/account/login')
|
|
def login(body: Login, request: Request, response: Response):
|
|
email = body.email.strip().lower()
|
|
throttle(email, request)
|
|
with db.connect() as c:
|
|
account = c.execute('SELECT * FROM dtf_local.accounts WHERE email=%s', (email,)).fetchone()
|
|
# Comparable password work even when the email is absent.
|
|
stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH
|
|
matches = password_matches(body.password, stored)
|
|
if not account or not matches:
|
|
login_failed(email)
|
|
audit('customer_login_failed', ip=client_ip(request))
|
|
raise HTTPException(401, 'E-mail ou senha inválidos.')
|
|
previous = current(request)
|
|
with db.connect() as c:
|
|
if not stored.startswith('scrypt-v2$'):
|
|
c.execute('UPDATE dtf_local.accounts SET password_hash=%s WHERE id=%s', (password_hash(body.password),account['id']))
|
|
if previous:
|
|
transfer_guest(c, previous, account['id'])
|
|
c.execute('DELETE FROM dtf_local.sessions WHERE id=%s', (previous['id'],))
|
|
new_session(c, response, account['id'])
|
|
audit('customer_login_success', account=str(account['id']))
|
|
return {'customer': account['profile']}
|
|
|
|
@router.post('/api/account/logout')
|
|
def logout(request: Request, response: Response):
|
|
previous = current(request)
|
|
if previous:
|
|
with db.connect() as c:
|
|
c.execute('DELETE FROM dtf_local.sessions WHERE id=%s', (previous['id'],))
|
|
response.delete_cookie('dtf_session', httponly=True, samesite='strict')
|
|
response.headers['Clear-Site-Data'] = '"storage"'
|
|
audit('customer_logout')
|
|
return {'ok': True}
|
|
|
|
@router.get('/api/account/me')
|
|
def me(identity=Depends(owner)):
|
|
with db.connect() as c:
|
|
row = c.execute('SELECT profile FROM dtf_local.accounts WHERE id=%s', (identity,)).fetchone()
|
|
return {'customer': row['profile'] if row else None}
|
|
|
|
@router.get('/api/customer/orders')
|
|
def orders(identity=Depends(owner)):
|
|
with db.connect() as c:
|
|
rows = c.execute('SELECT id,number,state,version,snapshot,created_at,updated_at FROM dtf_local.orders WHERE owner=%s ORDER BY created_at DESC', (identity,)).fetchall()
|
|
quotes = c.execute('''SELECT q.id,q.approved,q.approved_at,q.created_at FROM dtf_local.quotes q
|
|
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id WHERE q.owner=%s AND o.id IS NULL ORDER BY q.created_at DESC''', (identity,)).fetchall()
|
|
return {'orders': rows, 'quotes': quotes, 'states': STATES}
|
|
|
|
@router.get('/api/customer/orders/{oid}')
|
|
def detail(oid: UUID, identity=Depends(owner)):
|
|
with db.connect() as c:
|
|
row = owned_order(c, oid, identity)
|
|
# A move back undoes an operator's mistake and its reason is internal;
|
|
# only a correction's reason is written for the customer.
|
|
history = c.execute('''SELECT from_state,to_state,CASE WHEN to_state='cor' THEN reason ELSE '' END AS reason,
|
|
created_at FROM dtf_local.movements WHERE order_id=%s AND NOT back ORDER BY id''', (oid,)).fetchall()
|
|
return {'id': row['id'], 'number': row['number'], 'state': row['state'], 'version': row['version'],
|
|
'snapshot': row['snapshot'], 'history': history, 'files': file_rows(c,oid)}
|
|
|
|
@router.post('/api/customer/orders/{oid}/corrections')
|
|
def correction(oid: UUID, body: ArtworkSubmission, identity=Depends(owner)):
|
|
with db.connect() as c:
|
|
order = owned_order(c, oid, identity, lock=True)
|
|
return submit_files(c,order,body,identity,'correction','customer')
|
|
|
|
@router.get('/api/customer/orders/{oid}/files/{fid}/download')
|
|
def customer_download(oid: UUID, fid: UUID, identity=Depends(owner)):
|
|
with db.connect() as c:
|
|
owned_order(c,oid,identity)
|
|
row = c.execute('SELECT u.* FROM dtf_local.order_files f JOIN dtf_local.uploads u ON u.id=f.upload_id WHERE f.id=%s AND f.order_id=%s AND f.active', (fid,oid)).fetchone()
|
|
if not row: raise HTTPException(404, 'Active file not found')
|
|
if row['expires_at'] <= datetime.now(timezone.utc): raise HTTPException(410, 'File expired')
|
|
require_clean(row)
|
|
return {'url':storage.download(row['object_key'],row['name']), 'name':row['name']}
|