first commit
This commit is contained in:
191
local/customer.py
Normal file
191
local/customer.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""Customer portal and manual artwork handoff, composed into the local API."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID, uuid4, uuid5, NAMESPACE_URL
|
||||
from fastapi import Depends, HTTPException, Request, Response
|
||||
from psycopg.errors import UniqueViolation
|
||||
from psycopg.types.json import Jsonb
|
||||
from . import db
|
||||
from .auth import owner, session_row, new_session, password_hash, password_matches, transfer_guest, throttle, DUMMY_PASSWORD_HASH, audit
|
||||
from .models import Register, Login, UploadStart, ArtworkSubmission
|
||||
from .scanning import require_clean
|
||||
|
||||
def install_routes(app, operator, storage, begin, status, part, complete, upload_row, states):
|
||||
def current(request):
|
||||
try: return session_row(request)
|
||||
except HTTPException: return None
|
||||
|
||||
@app.post('/api/account/register')
|
||||
def register(body: Register, request: Request, response: Response):
|
||||
email = body.customer.mail.strip().lower()
|
||||
throttle(email, request)
|
||||
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}
|
||||
|
||||
@app.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:
|
||||
audit('customer_login_failed')
|
||||
raise HTTPException(401, 'Invalid email or password')
|
||||
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']}
|
||||
|
||||
@app.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}
|
||||
|
||||
@app.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}
|
||||
|
||||
def owned_order(c, oid, identity, lock=False):
|
||||
row = c.execute('SELECT * FROM dtf_local.orders WHERE id=%s AND owner=%s'+(' FOR UPDATE' if lock else ''), (oid,identity)).fetchone()
|
||||
if not row: raise HTTPException(404, 'Order not found')
|
||||
return row
|
||||
|
||||
def file_rows(c, oid):
|
||||
return c.execute('''SELECT f.id,f.upload_id,f.item_index,f.kind,f.active,f.note,f.created_at,
|
||||
u.name,u.size,u.expires_at,(u.expires_at<=now()) AS expired
|
||||
FROM dtf_local.order_files f JOIN dtf_local.uploads u ON u.id=f.upload_id
|
||||
WHERE order_id=%s ORDER BY f.created_at''', (oid,)).fetchall()
|
||||
|
||||
@app.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}
|
||||
|
||||
@app.get('/api/customer/orders/{oid}')
|
||||
def detail(oid: UUID, identity=Depends(owner)):
|
||||
with db.connect() as c:
|
||||
row = owned_order(c, oid, identity)
|
||||
history = c.execute('SELECT from_state,to_state,reason,created_at FROM dtf_local.movements WHERE order_id=%s 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)}
|
||||
|
||||
def submit_files(c, order, body, identity, kind, actor):
|
||||
if order['version'] != body.version:
|
||||
raise HTTPException(409, 'Order changed. Refresh before submitting files.')
|
||||
if kind == 'final' and order['state'] not in ('rec','tra','cor'):
|
||||
raise HTTPException(409, 'Final files can only change during artwork review')
|
||||
if kind == 'correction' and order['state'] != 'cor':
|
||||
raise HTTPException(409, 'This order is not awaiting artwork correction')
|
||||
if len({f.upload_id for f in body.files}) != len(body.files):
|
||||
raise HTTPException(422, 'Each uploaded file must appear once')
|
||||
count = len(order['snapshot']['items'])
|
||||
if any(f.item_index >= count for f in body.files):
|
||||
raise HTTPException(422, 'Invalid order item')
|
||||
if kind == 'final' and {f.item_index for f in body.files} != set(range(count)):
|
||||
raise HTTPException(422, 'Final-file set must cover every order item')
|
||||
original_ids = [UUID(uid) for item in order['snapshot']['items'] for uid in item['uploads']]
|
||||
first = c.execute('SELECT min(created_at) AS first FROM dtf_local.uploads WHERE id=ANY(%s)', (original_ids,)).fetchone()['first']
|
||||
expiry = first + timedelta(days=30)
|
||||
if expiry <= datetime.now(timezone.utc):
|
||||
raise HTTPException(410, 'Order artwork retention has expired')
|
||||
for ref in body.files:
|
||||
upload = upload_row(c, ref.upload_id, identity, lock=True)
|
||||
if not upload['complete']:
|
||||
raise HTTPException(409, 'Complete all uploads first')
|
||||
require_clean(upload)
|
||||
if c.execute('SELECT id FROM dtf_local.order_files WHERE upload_id=%s', (ref.upload_id,)).fetchone():
|
||||
raise HTTPException(409, 'File is already attached. Upload a new revision.')
|
||||
c.execute('UPDATE dtf_local.order_files SET active=false WHERE order_id=%s AND kind=%s', (order['id'],kind))
|
||||
for ref in body.files:
|
||||
c.execute('INSERT INTO dtf_local.order_files(id,order_id,upload_id,item_index,kind,note,created_by) VALUES(%s,%s,%s,%s,%s,%s,%s)',
|
||||
(uuid4(),order['id'],ref.upload_id,ref.item_index,kind,body.note,actor))
|
||||
c.execute('UPDATE dtf_local.uploads SET expires_at=LEAST(expires_at,%s) WHERE id=%s', (expiry,ref.upload_id))
|
||||
c.execute('UPDATE dtf_local.orders SET version=version+1,updated_at=now() WHERE id=%s', (order['id'],))
|
||||
if kind == 'final':
|
||||
# Artwork approval, not commercial quote approval, starts original cleanup.
|
||||
c.execute("UPDATE dtf_local.uploads SET expires_at=LEAST(expires_at,now()+interval '7 days') WHERE id=ANY(%s)", (original_ids,))
|
||||
return {'ok': True, 'version': order['version']+1, 'expires_at': expiry}
|
||||
|
||||
@app.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')
|
||||
|
||||
@app.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']}
|
||||
|
||||
def operator_identity(user):
|
||||
return uuid5(NAMESPACE_URL, 'dtf-local-operator:'+user)
|
||||
|
||||
@app.post('/api/operator/orders/{oid}/uploads')
|
||||
def begin_final(oid: UUID, body: UploadStart, user=Depends(operator)):
|
||||
with db.connect() as c:
|
||||
row = c.execute('SELECT state FROM dtf_local.orders WHERE id=%s', (oid,)).fetchone()
|
||||
if not row: raise HTTPException(404, 'Order not found')
|
||||
if row['state'] not in ('rec','tra','cor'): raise HTTPException(409, 'Order is not in artwork review')
|
||||
return begin(body, session_id=operator_identity(user))
|
||||
|
||||
@app.get('/api/operator/uploads/{uid}')
|
||||
def final_status(uid: UUID, user=Depends(operator)):
|
||||
return status(uid,session_id=operator_identity(user))
|
||||
|
||||
@app.post('/api/operator/uploads/{uid}/parts/{number}')
|
||||
def final_part(uid: UUID, number: int, user=Depends(operator)):
|
||||
return part(uid,number,session_id=operator_identity(user))
|
||||
|
||||
@app.post('/api/operator/uploads/{uid}/complete')
|
||||
def final_complete(uid: UUID, user=Depends(operator)):
|
||||
return complete(uid,session_id=operator_identity(user))
|
||||
|
||||
@app.get('/api/operator/orders/{oid}/files')
|
||||
def operator_files(oid: UUID, user=Depends(operator)):
|
||||
with db.connect() as c:
|
||||
return file_rows(c,oid)
|
||||
|
||||
@app.post('/api/operator/orders/{oid}/final-files')
|
||||
def final_files(oid: UUID, body: ArtworkSubmission, user=Depends(operator)):
|
||||
with db.connect() as c:
|
||||
order = c.execute('SELECT * FROM dtf_local.orders WHERE id=%s FOR UPDATE', (oid,)).fetchone()
|
||||
if not order: raise HTTPException(404, 'Order not found')
|
||||
return submit_files(c,order,body,operator_identity(user),'final',user)
|
||||
Reference in New Issue
Block a user