feat: generate print files, collect delivery addresses, add provider adapters
All checks were successful
Build and deploy / Validate source (push) Successful in 1m45s
Build and deploy / Integration suite on a real stack (push) Successful in 4m48s
Build and deploy / Secret scan and release gate (push) Successful in 11s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
All checks were successful
Build and deploy / Validate source (push) Successful in 1m45s
Build and deploy / Integration suite on a real stack (push) Successful in 4m48s
Build and deploy / Secret scan and release gate (push) Successful in 11s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
Week 2 work that did not need client inputs. Print files (1.4): each paid item gets a PDF the width of the film and the length of the approved layout, with every copy at its reviewed position, rotation and mirror. Sources are embedded once at original resolution; JPEG bytes pass through and PNG alpha becomes a soft mask. Artwork the generator cannot reproduce goes to hand preparation with the reason. The worker renders outside any transaction, and the operator approves the generated file as the final one through the existing review. Delivery address (3.8): required for any non-pickup quote, bound to the quoted CEP, carried into the order snapshot, the Kanban card and Tiny. Kanban (1.5): print-file status per item, and a panel of payment events that need a person (money without an order, refunds after an order) until an operator records the resolution. Mercado Pago and Tiny (1.1, 1.3): adapters written from the public API documentation and tested against fake transports only. Selectable for sandbox testing with their credentials; the production preflight still blocks release. Adds payment intents and a PIX step on the Site. MinIO: Docker Hub and quay.io now refuse anonymous pulls, so local and CI storage use Chainguard's MinIO build, pinned by digest. Verified with the full CI integration sequence on a fresh local build, including the new print_file_test and both browser suites. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,9 +17,24 @@ def require_runtime():
|
||||
checkout until their audited implementations are added.
|
||||
"""
|
||||
environment = os.environ.get('APP_ENV', 'local')
|
||||
for name in ('PAYMENT', 'FREIGHT', 'TINY', 'WHATSAPP'):
|
||||
for name in ('FREIGHT', 'WHATSAPP'):
|
||||
if os.environ.get(f'{name}_ADAPTER') != 'fake':
|
||||
raise RuntimeError(f'{name} must use the currently supported fake adapter')
|
||||
tiny = os.environ.get('TINY_ADAPTER')
|
||||
if tiny == 'tiny':
|
||||
if not os.environ.get('TINY_TOKEN'):
|
||||
raise RuntimeError('TINY_TOKEN is required for the Tiny adapter')
|
||||
elif tiny != 'fake':
|
||||
raise RuntimeError('TINY must use the fake or tiny adapter')
|
||||
# Mercado Pago is selectable only with its credentials present; it has not
|
||||
# yet passed the sandbox flows, so production preflight still blocks it.
|
||||
payment = os.environ.get('PAYMENT_ADAPTER')
|
||||
if payment == 'mercadopago':
|
||||
for name in ('MP_ACCESS_TOKEN', 'MP_WEBHOOK_SECRET'):
|
||||
if not os.environ.get(name):
|
||||
raise RuntimeError(f'{name} is required for the Mercado Pago adapter')
|
||||
elif payment != 'fake':
|
||||
raise RuntimeError('PAYMENT must use the fake or mercadopago adapter')
|
||||
if environment == 'local':
|
||||
if os.environ.get('STORAGE_ADAPTER') != 's3-local':
|
||||
raise RuntimeError('Local runtime requires local S3 storage')
|
||||
@@ -60,14 +75,14 @@ class PaymentEvent(NamedTuple):
|
||||
|
||||
|
||||
class PaymentAdapter(Protocol):
|
||||
def create(self, quote_id: str, total_cents: int, customer: dict) -> dict:
|
||||
def create(self, quote_id: str, total_cents: int, customer: dict, method: dict | None = None) -> dict:
|
||||
"""Start a payment. Must be idempotent on quote_id: a retry after a
|
||||
timeout has to return the existing payment, never charge twice."""
|
||||
|
||||
def verify(self, headers: Mapping[str, str], body: bytes) -> bool:
|
||||
def verify(self, headers: Mapping[str, str], body: bytes, query: Mapping[str, str] | None = None) -> bool:
|
||||
"""Whether this delivery genuinely came from the provider."""
|
||||
|
||||
def parse(self, body: bytes) -> PaymentEvent | None:
|
||||
def parse(self, body: bytes, query: Mapping[str, str] | None = None) -> PaymentEvent | None:
|
||||
"""Normalise a verified delivery, or None if it is not about a payment."""
|
||||
|
||||
|
||||
@@ -80,13 +95,14 @@ class FakePayment:
|
||||
the service changes.
|
||||
"""
|
||||
|
||||
name = 'fake'
|
||||
header = 'x-payment-signature'
|
||||
|
||||
def _secret(self) -> bytes | None:
|
||||
secret = os.environ.get('PAYMENT_WEBHOOK_SECRET', '')
|
||||
return secret.encode() if secret else None
|
||||
|
||||
def create(self, quote_id: str, total_cents: int, customer: dict) -> dict:
|
||||
def create(self, quote_id: str, total_cents: int, customer: dict, method: dict | None = None) -> dict:
|
||||
return {'provider': 'fake', 'id': f'local-{quote_id}',
|
||||
'status': 'pending', 'total_cents': total_cents}
|
||||
|
||||
@@ -96,7 +112,7 @@ class FakePayment:
|
||||
raise RuntimeError('PAYMENT_WEBHOOK_SECRET is not configured')
|
||||
return hmac.new(secret, body, hashlib.sha256).hexdigest()
|
||||
|
||||
def verify(self, headers, body: bytes) -> bool:
|
||||
def verify(self, headers, body: bytes, query=None) -> bool:
|
||||
# No configured secret means nothing can be verified, so nothing is
|
||||
# accepted. A guessable default would let anyone forge an approval and
|
||||
# create an order that was never paid for.
|
||||
@@ -105,7 +121,7 @@ class FakePayment:
|
||||
supplied = headers.get(self.header) or headers.get(self.header.title()) or ''
|
||||
return hmac.compare_digest(supplied, self.sign(body))
|
||||
|
||||
def parse(self, body: bytes):
|
||||
def parse(self, body: bytes, query=None):
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except ValueError:
|
||||
@@ -158,6 +174,8 @@ class ObjectStorage(Protocol):
|
||||
def complete(self, key: str, upload_id: str, parts: list): ...
|
||||
def size(self, key: str) -> int: ...
|
||||
def download(self, key: str, name: str) -> str: ...
|
||||
def fetch(self, key: str, path: str): ...
|
||||
def store(self, key: str, path: str, content_type: str): ...
|
||||
def health(self): ...
|
||||
def discard(self, key: str, upload_id: str, complete: bool): ...
|
||||
|
||||
@@ -206,6 +224,14 @@ class LocalS3Storage:
|
||||
def size(self, key):
|
||||
return self.client.head_object(Bucket=self.bucket, Key=key)['ContentLength']
|
||||
|
||||
def fetch(self, key, path):
|
||||
"""Copy a stored object to a local file (the worker's scratch space)."""
|
||||
self.client.download_file(self.bucket, key, path)
|
||||
|
||||
def store(self, key, path, content_type):
|
||||
"""Upload a file the service generated itself, such as a print file."""
|
||||
self.client.upload_file(path, self.bucket, key, ExtraArgs={'ContentType': content_type})
|
||||
|
||||
def download(self, key, name):
|
||||
from urllib.parse import quote
|
||||
return self.public.generate_presigned_url('get_object', Params={
|
||||
|
||||
@@ -5,7 +5,7 @@ from ..core import db
|
||||
from ..core.auth import client_ip, owner, new_session, rate_limit
|
||||
from ..core.limits import upload_limit_bytes
|
||||
from ..core.models import Freight
|
||||
from ..runtime import ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, storage
|
||||
from ..runtime import ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, payment, storage
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -32,7 +32,7 @@ def session(request: Request, response: Response):
|
||||
with db.connect() as c:
|
||||
session_id = new_session(c, response)
|
||||
return {'environment': ENVIRONMENT, 'cart_scope': str(session_id), 'part_bytes': PART_BYTES,
|
||||
'max_upload_bytes': upload_limit_bytes()}
|
||||
'max_upload_bytes': upload_limit_bytes(), 'payment_provider': payment.name}
|
||||
|
||||
@router.post('/api/freight')
|
||||
def quote_freight(body: Freight):
|
||||
|
||||
@@ -12,8 +12,9 @@ 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.models import Move, OperatorLogin, Resolution, Review
|
||||
from ..core.pricing import price
|
||||
from ..printjobs import queue as queue_print_files
|
||||
from ..runtime import (BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, STATES, TRANSITIONS,
|
||||
enqueue, freight, quote_view, storage, upload_row)
|
||||
from ..scanning import require_clean
|
||||
@@ -80,8 +81,20 @@ def board(user=Depends(operator)):
|
||||
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': active + list(reversed(finished)),
|
||||
'orders': orders, 'payment_issues': refused,
|
||||
'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,
|
||||
@@ -131,6 +144,7 @@ def approve(uid: UUID, body: Review, user=Depends(operator)):
|
||||
'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
|
||||
@@ -166,6 +180,33 @@ def move(uid: UUID, body: Move, user=Depends(operator)):
|
||||
'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}
|
||||
|
||||
@router.get('/api/operator/orders/{uid}/history')
|
||||
def history(uid: UUID, user=Depends(operator)):
|
||||
with db.connect() as c:
|
||||
|
||||
@@ -5,11 +5,15 @@ signature is the only thing standing between this endpoint and an attacker
|
||||
creating orders. It is verified before the body is parsed, let alone acted on,
|
||||
and an unverified delivery is recorded and refused rather than retried.
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from .. import payments
|
||||
from ..core import db
|
||||
from ..core.auth import audit, client_ip, rate_limit
|
||||
from ..core.auth import audit, client_ip, owner, rate_limit
|
||||
from ..core.models import PaymentIntent
|
||||
from ..runtime import payment
|
||||
|
||||
router = APIRouter()
|
||||
@@ -24,11 +28,12 @@ async def webhook(request: Request):
|
||||
rate_limit('payment-webhook', client_ip(request), WEBHOOK_LIMIT, 900)
|
||||
body = await request.body()
|
||||
|
||||
if not payment.verify(request.headers, body):
|
||||
query = dict(request.query_params)
|
||||
if not payment.verify(request.headers, body, query):
|
||||
audit('payment_webhook_rejected', ip=client_ip(request), reason='signature')
|
||||
raise HTTPException(403, 'Invalid signature')
|
||||
|
||||
event = payment.parse(body)
|
||||
event = payment.parse(body, query)
|
||||
if event is None:
|
||||
# Verified, so genuinely from the provider, but not about a payment.
|
||||
# Acknowledge it: refusing would make the provider retry for ever.
|
||||
@@ -50,4 +55,37 @@ async def webhook(request: Request):
|
||||
|
||||
|
||||
def event_provider():
|
||||
return getattr(payment, 'name', payment.__class__.__name__.replace('Payment', '').lower() or 'fake')
|
||||
return payment.name
|
||||
|
||||
|
||||
@router.post('/api/payments/intent')
|
||||
def intent(body: PaymentIntent, session_id=Depends(owner)):
|
||||
"""Start paying an approved quote: a PIX code, or a card token from the
|
||||
provider's own form. Asking twice for the same method returns the same
|
||||
payment; a quote already paid returns 409."""
|
||||
rate_limit('payment-intent', str(session_id), 30, 900)
|
||||
with db.connect() as c:
|
||||
try:
|
||||
quote = payments.approved_quote(c, body.quote_id, session_id)
|
||||
except payments.PaymentRefused as refusal:
|
||||
raise HTTPException(404 if 'not found' in str(refusal) else 409, str(refusal))
|
||||
if c.execute('SELECT 1 FROM dtf_local.orders WHERE quote_id=%s', (body.quote_id,)).fetchone():
|
||||
raise HTTPException(409, 'Quote is already paid')
|
||||
existing = c.execute('''SELECT * FROM dtf_local.payment_intents WHERE quote_id=%s AND method=%s
|
||||
AND status='pending' ORDER BY created_at DESC LIMIT 1''', (body.quote_id, body.method.type)).fetchone()
|
||||
if existing:
|
||||
return existing['response']
|
||||
try:
|
||||
created = payment.create(str(body.quote_id), quote['approved']['total_cents'],
|
||||
quote['approved']['customer'], body.method.model_dump())
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, str(exc))
|
||||
except Exception:
|
||||
audit('payment_intent_failed', quote=str(body.quote_id))
|
||||
raise HTTPException(502, 'Payment provider unavailable; try again')
|
||||
c.execute('''INSERT INTO dtf_local.payment_intents(id,quote_id,provider,provider_payment_id,method,
|
||||
status,amount_cents,response) VALUES(%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
ON CONFLICT(provider,provider_payment_id) DO NOTHING''',
|
||||
(uuid4(), body.quote_id, payment.name, created['id'], body.method.type,
|
||||
created['status'], quote['approved']['total_cents'], Jsonb(created)))
|
||||
return created
|
||||
|
||||
@@ -9,9 +9,29 @@ from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from .printjobs import generated_identity
|
||||
from .runtime import upload_row
|
||||
from .scanning import require_clean
|
||||
|
||||
|
||||
def generated_owner(c, order, ref, kind):
|
||||
"""The owner to look a generated print file up under, or None if it is not one.
|
||||
|
||||
A generated file may be approved as the final for the item it was made
|
||||
from, and only while that item still has its original artwork: after a
|
||||
customer correction it reproduces a layout nobody wants printed.
|
||||
"""
|
||||
generated = c.execute('''SELECT item_index FROM dtf_local.print_files
|
||||
WHERE order_id=%s AND upload_id=%s AND status='ready' ''', (order['id'], ref.upload_id)).fetchone()
|
||||
if not generated:
|
||||
return None
|
||||
if kind != 'final' or generated['item_index'] != ref.item_index:
|
||||
raise HTTPException(422, 'A generated print file can only be the final file of its own item')
|
||||
if c.execute("SELECT 1 FROM dtf_local.order_files WHERE order_id=%s AND kind='correction' LIMIT 1",
|
||||
(order['id'],)).fetchone():
|
||||
raise HTTPException(409, 'A customer correction replaced the artwork this file was generated from')
|
||||
return generated_identity(order['id'])
|
||||
|
||||
def submit_files(c, order, body, identity, kind, actor):
|
||||
if order['version'] != body.version:
|
||||
raise HTTPException(409, 'Order changed. Refresh before submitting files.')
|
||||
@@ -32,7 +52,7 @@ def submit_files(c, order, body, identity, kind, actor):
|
||||
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)
|
||||
upload = upload_row(c, ref.upload_id, generated_owner(c, order, ref, kind) or identity, lock=True)
|
||||
if not upload['complete']:
|
||||
raise HTTPException(409, 'Complete all uploads first')
|
||||
require_clean(upload)
|
||||
|
||||
@@ -44,6 +44,29 @@ class Freight(StrictModel):
|
||||
service: Literal['pickup','mock-standard'] = 'pickup'
|
||||
postal_code: str = Field(default='', max_length=8)
|
||||
|
||||
UF = Literal['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG','PA','PB',
|
||||
'PR','PE','PI','RJ','RN','RS','RO','RR','SC','SP','SE','TO']
|
||||
|
||||
class Destination(StrictModel):
|
||||
"""Where a shipped order goes. A CEP alone quotes freight; it does not deliver."""
|
||||
recipient: str = Field(min_length=2, max_length=120)
|
||||
street: str = Field(min_length=2, max_length=160)
|
||||
number: str = Field(min_length=1, max_length=20)
|
||||
complement: str = Field(default='', max_length=80)
|
||||
district: str = Field(min_length=2, max_length=80)
|
||||
city: str = Field(min_length=2, max_length=80)
|
||||
state: UF
|
||||
postal_code: str = Field(pattern=r'^[0-9]{8}$')
|
||||
|
||||
@field_validator('recipient', 'street', 'number', 'complement', 'district', 'city', mode='before')
|
||||
@classmethod
|
||||
def trimmed(cls, value):
|
||||
if isinstance(value, str):
|
||||
value = ' '.join(value.split())
|
||||
if any(ord(ch) < 32 for ch in value):
|
||||
raise ValueError('Invalid characters')
|
||||
return value
|
||||
|
||||
class UploadStart(StrictModel):
|
||||
name: str = Field(min_length=1, max_length=200, pattern=r'^[^/\\\x00-\x1f]+$')
|
||||
size: int = Field(gt=0, strict=True)
|
||||
@@ -136,6 +159,18 @@ class QuoteRequest(StrictModel):
|
||||
customer: Customer
|
||||
items: list[Item] = Field(min_length=1, max_length=30)
|
||||
freight: Freight
|
||||
destination: Destination | None = None
|
||||
|
||||
@model_validator(mode='after')
|
||||
def destination_matches_freight(self):
|
||||
if self.freight.service == 'pickup':
|
||||
if self.destination is not None:
|
||||
raise ValueError('Pickup orders do not take a delivery address')
|
||||
elif self.destination is None:
|
||||
raise ValueError('Delivery requires the full address')
|
||||
elif self.destination.postal_code != self.freight.postal_code:
|
||||
raise ValueError('The delivery address CEP must be the CEP freight was quoted for')
|
||||
return self
|
||||
|
||||
class Review(StrictModel):
|
||||
items: list[Item] = Field(min_length=1, max_length=30)
|
||||
@@ -143,11 +178,33 @@ class Review(StrictModel):
|
||||
class Pay(StrictModel):
|
||||
quote_id: UUID
|
||||
|
||||
class PaymentMethod(StrictModel):
|
||||
type: Literal['pix', 'card']
|
||||
# Card fields come from the provider's own form, which tokenises the card
|
||||
# in the browser; the number never reaches this server.
|
||||
token: str | None = Field(default=None, max_length=200)
|
||||
payment_method_id: str | None = Field(default=None, max_length=40, pattern=r'^[a-z_]+$')
|
||||
installments: int = Field(default=1, ge=1, le=12, strict=True)
|
||||
issuer_id: str | None = Field(default=None, max_length=40)
|
||||
|
||||
@model_validator(mode='after')
|
||||
def card_needs_token(self):
|
||||
if self.type == 'card' and not (self.token and self.payment_method_id):
|
||||
raise ValueError('Card payment requires the provider token and method')
|
||||
return self
|
||||
|
||||
class PaymentIntent(StrictModel):
|
||||
quote_id: UUID
|
||||
method: PaymentMethod
|
||||
|
||||
class Move(StrictModel):
|
||||
state: Literal['rec','tra','fil','imp','cor','fin']
|
||||
version: int = Field(ge=0)
|
||||
reason: str = Field(default='', max_length=1000)
|
||||
|
||||
class Resolution(StrictModel):
|
||||
note: str = Field(min_length=3, max_length=1000)
|
||||
|
||||
class Register(StrictModel):
|
||||
customer: Customer
|
||||
password: str = Field(min_length=12, max_length=128)
|
||||
|
||||
160
app/mercadopago.py
Normal file
160
app/mercadopago.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Mercado Pago: payment creation, webhook verification and status lookup.
|
||||
|
||||
Written from the public API documentation and exercised only against a fake
|
||||
HTTP transport. It is not a verified integration until it has passed the
|
||||
sandbox flows in docs/PRODUCTION_INPUTS.md with the client's own account;
|
||||
until then the runtime refuses to select it without explicit credentials.
|
||||
|
||||
The notification is only a pointer. Its body says "payment 123 changed" and
|
||||
nothing about amount or status, so nothing in it is trusted beyond the id:
|
||||
the payment is fetched from the API with our access token, and that response
|
||||
is what the order service compares against the approved quote.
|
||||
|
||||
Signature (x-signature: "ts=<unix>,v1=<hex>"): HMAC-SHA256, keyed with the
|
||||
webhook secret from the integration panel, over the manifest
|
||||
"id:<data.id>;request-id:<x-request-id>;ts:<ts>;", where data.id comes from
|
||||
the notification URL's query string (lower-cased when alphanumeric). A part
|
||||
whose value is absent from the notification is left out of the manifest.
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Mapping
|
||||
|
||||
import httpx
|
||||
|
||||
from .adapters import PaymentEvent
|
||||
|
||||
API = 'https://api.mercadopago.com'
|
||||
# How old a signed timestamp may be. Mercado Pago retries a failed delivery
|
||||
# every 15 minutes, re-signing each attempt, so a replayed old one is refused.
|
||||
MAX_SIGNATURE_AGE = 30 * 60
|
||||
STATUSES = {'approved': 'approved', 'pending': 'pending', 'in_process': 'pending',
|
||||
'authorized': 'pending', 'in_mediation': 'pending', 'rejected': 'rejected',
|
||||
'cancelled': 'cancelled', 'refunded': 'refunded', 'charged_back': 'refunded'}
|
||||
|
||||
|
||||
class MercadoPagoPayment:
|
||||
name = 'mercadopago'
|
||||
|
||||
def __init__(self, access_token=None, webhook_secret=None, notification_url=None,
|
||||
transport=None, clock=time.time):
|
||||
self.access_token = access_token or os.environ.get('MP_ACCESS_TOKEN', '')
|
||||
self.webhook_secret = (webhook_secret or os.environ.get('MP_WEBHOOK_SECRET', '')).encode()
|
||||
self.notification_url = notification_url or os.environ.get('MP_NOTIFICATION_URL', '')
|
||||
if not self.access_token or not self.webhook_secret:
|
||||
raise RuntimeError('Mercado Pago needs MP_ACCESS_TOKEN and MP_WEBHOOK_SECRET')
|
||||
self.http = httpx.Client(base_url=API, transport=transport, timeout=15,
|
||||
headers={'Authorization': f'Bearer {self.access_token}'})
|
||||
self.clock = clock
|
||||
|
||||
# Payments ------------------------------------------------------------
|
||||
|
||||
def create(self, quote_id: str, total_cents: int, customer: dict, method: dict | None = None) -> dict:
|
||||
"""Create a payment for an approved quote.
|
||||
|
||||
The quote id is the idempotency key, so a retry after a timeout returns
|
||||
the payment already created rather than charging again. `method` is
|
||||
{'type': 'pix'} or {'type': 'card', 'token', 'payment_method_id',
|
||||
'installments', 'issuer_id'} from Mercado Pago's card form: card data is
|
||||
tokenised in the customer's browser and never reaches this server.
|
||||
"""
|
||||
method = method or {'type': 'pix'}
|
||||
body = {'transaction_amount': float(Decimal(total_cents) / 100),
|
||||
'description': f'DTF - cotação {quote_id[:8]}',
|
||||
'external_reference': quote_id,
|
||||
'payer': {'email': customer['mail'],
|
||||
'identification': {'type': 'CNPJ', 'number': customer['cnpj']}}}
|
||||
if self.notification_url:
|
||||
body['notification_url'] = self.notification_url
|
||||
if method['type'] == 'pix':
|
||||
body['payment_method_id'] = 'pix'
|
||||
elif method['type'] == 'card':
|
||||
body.update(token=method['token'], payment_method_id=method['payment_method_id'],
|
||||
installments=int(method.get('installments', 1)))
|
||||
if method.get('issuer_id'):
|
||||
body['issuer_id'] = method['issuer_id']
|
||||
else:
|
||||
raise ValueError('Unsupported payment method')
|
||||
response = self.http.post('/v1/payments', json=body,
|
||||
headers={'X-Idempotency-Key': f'dtf-quote-{quote_id}-{method["type"]}'})
|
||||
response.raise_for_status()
|
||||
payment = response.json()
|
||||
transaction = (payment.get('point_of_interaction') or {}).get('transaction_data') or {}
|
||||
return {'provider': self.name, 'id': str(payment['id']),
|
||||
'status': STATUSES.get(payment.get('status'), 'pending'),
|
||||
'status_detail': payment.get('status_detail'),
|
||||
'total_cents': total_cents,
|
||||
'pix_qr_code': transaction.get('qr_code'),
|
||||
'pix_qr_code_base64': transaction.get('qr_code_base64'),
|
||||
'ticket_url': transaction.get('ticket_url')}
|
||||
|
||||
def lookup(self, payment_id: str) -> dict:
|
||||
response = self.http.get(f'/v1/payments/{payment_id}')
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
# Webhooks ------------------------------------------------------------
|
||||
|
||||
def verify(self, headers: Mapping[str, str], body: bytes, query: Mapping[str, str] | None = None) -> bool:
|
||||
signature = headers.get('x-signature') or ''
|
||||
parts = dict(part.strip().split('=', 1) for part in signature.split(',') if '=' in part)
|
||||
ts, supplied = parts.get('ts', ''), parts.get('v1', '')
|
||||
if not ts.isdigit() or not supplied:
|
||||
return False
|
||||
if abs(self.clock() - int(ts[:10])) > MAX_SIGNATURE_AGE:
|
||||
return False
|
||||
data_id = (query or {}).get('data.id', '')
|
||||
if data_id.isalnum():
|
||||
data_id = data_id.lower()
|
||||
manifest = ''
|
||||
if data_id:
|
||||
manifest += f'id:{data_id};'
|
||||
request_id = headers.get('x-request-id') or ''
|
||||
if request_id:
|
||||
manifest += f'request-id:{request_id};'
|
||||
manifest += f'ts:{ts};'
|
||||
expected = hmac.new(self.webhook_secret, manifest.encode(), hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(expected, supplied.lower())
|
||||
|
||||
def parse(self, body: bytes, query: Mapping[str, str] | None = None) -> PaymentEvent | None:
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(data, dict) or data.get('type') != 'payment':
|
||||
return None
|
||||
payment_id = str((query or {}).get('data.id') or (data.get('data') or {}).get('id') or '')
|
||||
if not payment_id.isdigit():
|
||||
return None
|
||||
payment = self.lookup(payment_id)
|
||||
return event_from_payment(payment, notification_id=str(data.get('id', '')))
|
||||
|
||||
|
||||
def event_from_payment(payment: dict, notification_id: str = '') -> PaymentEvent:
|
||||
"""Normalise a payment fetched from the API. Amount is None unless it is BRL
|
||||
and a whole number of centavos, so a foreign or malformed amount is refused."""
|
||||
amount = None
|
||||
if payment.get('currency_id') == 'BRL':
|
||||
try:
|
||||
cents = Decimal(str(payment.get('transaction_amount'))) * 100
|
||||
if cents == cents.to_integral_value():
|
||||
amount = int(cents)
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
amount = None
|
||||
status = STATUSES.get(payment.get('status'), 'pending')
|
||||
# One event per payment state: a notification id alone would let the same
|
||||
# approval be applied twice under two notifications, and would collapse a
|
||||
# later refund into the earlier approval.
|
||||
event_id = f"{payment.get('id')}:{payment.get('status')}"
|
||||
return PaymentEvent(event_id=event_id, reference=str(payment.get('external_reference') or ''),
|
||||
status=status, amount_cents=amount,
|
||||
raw={'provider': 'mercadopago', 'payment_id': str(payment.get('id')),
|
||||
'status': payment.get('status'), 'status_detail': payment.get('status_detail'),
|
||||
'currency_id': payment.get('currency_id'),
|
||||
'transaction_amount': payment.get('transaction_amount'),
|
||||
'date_approved': payment.get('date_approved'),
|
||||
'notification_id': notification_id})
|
||||
@@ -15,6 +15,7 @@ from uuid import UUID, uuid4
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from .core.auth import audit
|
||||
from .printjobs import queue as queue_print_files
|
||||
from .runtime import enqueue, upload_row
|
||||
from .scanning import require_clean
|
||||
|
||||
@@ -59,6 +60,7 @@ def create_order(c, quote, payment):
|
||||
order = c.execute(
|
||||
'INSERT INTO dtf_local.orders(id,quote_id,owner,snapshot,payment) VALUES(%s,%s,%s,%s,%s) RETURNING *',
|
||||
(uuid4(), quote['id'], quote['owner'], Jsonb(approved), Jsonb(payment))).fetchone()
|
||||
queue_print_files(c, order['id'], len(approved['items']))
|
||||
for provider in ('tiny', 'whatsapp'):
|
||||
enqueue(c, f"{order['id']}:paid:{provider}", provider,
|
||||
{'order_id': str(order['id']), 'number': order['number'],
|
||||
@@ -77,7 +79,25 @@ def record(c, provider, event):
|
||||
|
||||
|
||||
def apply(c, event):
|
||||
"""Act on a payment notification. Returns the outcome recorded against it."""
|
||||
"""Act on a payment notification. Returns the outcome recorded against it.
|
||||
|
||||
Outcomes starting 'refused' (money arrived, no order) or 'attention' (an
|
||||
order exists but its payment was reversed) stay on the Kanban until an
|
||||
operator records a resolution.
|
||||
"""
|
||||
provider_id = event.raw.get('payment_id')
|
||||
if provider_id:
|
||||
c.execute('''UPDATE dtf_local.payment_intents SET status=%s, updated_at=now()
|
||||
WHERE provider_payment_id=%s''', (event.status, provider_id))
|
||||
if event.status in ('refunded', 'cancelled'):
|
||||
try:
|
||||
order = c.execute('SELECT number FROM dtf_local.orders WHERE quote_id=%s',
|
||||
(UUID(event.reference),)).fetchone()
|
||||
except (ValueError, AttributeError):
|
||||
order = None
|
||||
if order:
|
||||
audit('payment_reversed', order=order['number'], status=event.status)
|
||||
return f"attention: payment {event.status} for order {order['number']}"
|
||||
if event.status != 'approved':
|
||||
return f'ignored: {event.status}'
|
||||
|
||||
@@ -101,5 +121,5 @@ def apply(c, event):
|
||||
expected_cents=expected, paid_cents=event.amount_cents)
|
||||
return f'refused: paid {event.amount_cents} but quote total is {expected}'
|
||||
|
||||
order, created = create_order(c, quote, {'provider': 'webhook', **event.raw})
|
||||
order, created = create_order(c, quote, {'provider': event.raw.get('provider', 'webhook'), **event.raw})
|
||||
return f"order {order['number']}" + ('' if created else ' (already existed)')
|
||||
|
||||
364
app/printfile.py
Normal file
364
app/printfile.py
Normal file
@@ -0,0 +1,364 @@
|
||||
"""The print file: the reviewed layout, reproduced exactly, at the film's size.
|
||||
|
||||
The customer is quoted on a layout the Site computes: each copy of each artwork
|
||||
at a width, rotation, mirror and position on the film. Production spec v2 keeps
|
||||
that layout through the approved order. This module turns it into one PDF per
|
||||
order item, with a page exactly as wide as the film and as long as the layout,
|
||||
so the operator imports what the customer approved instead of rebuilding it.
|
||||
|
||||
Each source image is embedded once, at its original resolution, and every copy
|
||||
is a placement of it. Nothing is resampled: a 300 DPI artwork is still 300 DPI
|
||||
in the file, a JPEG keeps its original bytes, and transparency survives as a
|
||||
soft mask. The output is therefore about the size of the artwork, not of a
|
||||
57 cm x 20 m raster, and it never needs that raster in memory.
|
||||
|
||||
Only formats whose pixels can be read here are generated: JPEG, PNG, WebP and
|
||||
TIFF. Anything else (PDF, PSD, AI, CDR), or a file whose proportions do not
|
||||
match the size it was quoted at, is refused with a reason, and the operator
|
||||
prepares that item by hand exactly as before.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import zlib
|
||||
from decimal import Decimal
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
PT_PER_CM = 72 / 2.54
|
||||
# Acrobat's page limit. Longer layouts scale user space with /UserUnit (PDF 1.6)
|
||||
# rather than cutting the film into pages a RIP might print with gaps.
|
||||
MAX_PAGE_PT = 14400
|
||||
# How far a file's proportions may drift from the quoted size before the item
|
||||
# is refused: rounding in the Site keeps real files well inside this.
|
||||
ASPECT_TOLERANCE = 0.01
|
||||
# The quote may round up (10 cm steps, 1 m minimum) but never down.
|
||||
HEIGHT_TOLERANCE_CM = Decimal('0.05')
|
||||
SUPPORTED_FORMATS = {'JPEG', 'PNG', 'WEBP', 'TIFF'}
|
||||
STRIP_ROWS = 256
|
||||
# Decoding holds the whole image in memory (about 4 bytes a pixel). 57 cm x 3 m
|
||||
# at 300 DPI is 239 Mpx; above this the item goes to the operator instead.
|
||||
MAX_DECODED_PIXELS = int(os.environ.get('PRINT_MAX_PIXELS', '250000000'))
|
||||
# Pillow's own bomb guard would refuse a genuine long sheet before we can
|
||||
# decide; the explicit limit above is the one that applies.
|
||||
Image.MAX_IMAGE_PIXELS = None
|
||||
|
||||
|
||||
class Unsupported(Exception):
|
||||
"""This item cannot be generated automatically; the reason is for the operator."""
|
||||
|
||||
|
||||
class Name(str):
|
||||
pass
|
||||
|
||||
|
||||
class Ref(int):
|
||||
pass
|
||||
|
||||
|
||||
def serialize(value):
|
||||
if isinstance(value, Ref):
|
||||
return b'%d 0 R' % value
|
||||
if isinstance(value, Name):
|
||||
return b'/' + value.encode('ascii')
|
||||
if isinstance(value, bool):
|
||||
return b'true' if value else b'false'
|
||||
if isinstance(value, int):
|
||||
return b'%d' % value
|
||||
if isinstance(value, (float, Decimal)):
|
||||
text = f'{float(value):.4f}'.rstrip('0').rstrip('.')
|
||||
return (text if text not in ('', '-0') else '0').encode('ascii')
|
||||
if isinstance(value, dict):
|
||||
return b'<<' + b''.join(b'/' + k.encode('ascii') + b' ' + serialize(v)
|
||||
for k, v in value.items()) + b'>>'
|
||||
if isinstance(value, (list, tuple)):
|
||||
return b'[' + b' '.join(serialize(v) for v in value) + b']'
|
||||
if isinstance(value, str):
|
||||
escaped = value.replace('\\', '\\\\').replace('(', '\\(').replace(')', '\\)')
|
||||
return b'(' + escaped.encode('latin-1', 'replace') + b')'
|
||||
raise TypeError(f'Cannot serialize {type(value).__name__}')
|
||||
|
||||
|
||||
class PdfWriter:
|
||||
"""A sequential PDF writer: objects go straight to the file, streams included.
|
||||
|
||||
Stream lengths are indirect objects written after the data, so an image is
|
||||
compressed strip by strip into the output without being held in memory.
|
||||
"""
|
||||
|
||||
def __init__(self, fp):
|
||||
self.fp = fp
|
||||
self.offsets = {}
|
||||
self.next_id = 1
|
||||
self.write(b'%PDF-1.6\n%\xe2\xe3\xcf\xd3\n')
|
||||
|
||||
def write(self, data):
|
||||
self.fp.write(data)
|
||||
|
||||
def tell(self):
|
||||
return self.fp.tell()
|
||||
|
||||
def alloc(self):
|
||||
ref = Ref(self.next_id)
|
||||
self.next_id += 1
|
||||
return ref
|
||||
|
||||
def obj(self, value, ref=None):
|
||||
ref = ref or self.alloc()
|
||||
self.offsets[ref] = self.tell()
|
||||
self.write(b'%d 0 obj\n' % ref + serialize(value) + b'\nendobj\n')
|
||||
return ref
|
||||
|
||||
def stream(self, dictionary, chunks, ref=None):
|
||||
"""Write a stream from an iterable of already-encoded byte chunks."""
|
||||
ref = ref or self.alloc()
|
||||
length = self.alloc()
|
||||
self.offsets[ref] = self.tell()
|
||||
self.write(b'%d 0 obj\n' % ref + serialize({**dictionary, 'Length': length}) + b'\nstream\n')
|
||||
start = self.tell()
|
||||
for chunk in chunks:
|
||||
self.write(chunk)
|
||||
size = self.tell() - start
|
||||
self.write(b'\nendstream\nendobj\n')
|
||||
self.obj(size, length)
|
||||
return ref
|
||||
|
||||
def finish(self, root, info):
|
||||
xref = self.tell()
|
||||
count = self.next_id
|
||||
self.write(b'xref\n0 %d\n0000000000 65535 f \n' % count)
|
||||
for ref in range(1, count):
|
||||
self.write(b'%010d 00000 n \n' % self.offsets[ref])
|
||||
self.write(b'trailer\n' + serialize({'Size': count, 'Root': root, 'Info': info}) +
|
||||
b'\nstartxref\n%d\n%%%%EOF\n' % xref)
|
||||
|
||||
|
||||
def deflate(pieces):
|
||||
compressor = zlib.compressobj(6)
|
||||
for piece in pieces:
|
||||
out = compressor.compress(piece)
|
||||
if out:
|
||||
yield out
|
||||
yield compressor.flush()
|
||||
|
||||
|
||||
class SourceImage:
|
||||
"""One customer file, opened for reading pixels and measured as the browser sees it."""
|
||||
|
||||
def __init__(self, path, name):
|
||||
self.path = path
|
||||
self.name = name
|
||||
try:
|
||||
self.image = Image.open(path)
|
||||
self.format = self.image.format
|
||||
except Image.DecompressionBombError as exc:
|
||||
raise Unsupported(f'"{name}" is too large to generate automatically') from exc
|
||||
except Exception as exc:
|
||||
raise Unsupported(f'"{name}" is not an image this generator can read') from exc
|
||||
if self.format not in SUPPORTED_FORMATS:
|
||||
raise Unsupported(f'"{name}" is {self.format or "an unknown format"}; '
|
||||
'only JPEG, PNG, WebP and TIFF are generated automatically')
|
||||
if getattr(self.image, 'n_frames', 1) > 1 and self.format != 'TIFF':
|
||||
raise Unsupported(f'"{name}" is animated or has several frames')
|
||||
# Browsers draw a photo upright according to its EXIF orientation, and
|
||||
# the Site measured it that way, so the print must too.
|
||||
try:
|
||||
self.orientation = self.image.getexif().get(0x0112, 1)
|
||||
except Exception:
|
||||
self.orientation = 1
|
||||
width, height = self.image.size
|
||||
self.size = (height, width) if self.orientation in (5, 6, 7, 8) else (width, height)
|
||||
|
||||
def passthrough(self):
|
||||
"""Whether the original JPEG bytes can go into the PDF unchanged."""
|
||||
return (self.format == 'JPEG' and self.orientation == 1 and
|
||||
self.image.mode in ('L', 'RGB', 'CMYK'))
|
||||
|
||||
def embed(self, pdf):
|
||||
"""Write this image (and its alpha) as XObjects; returns the image reference."""
|
||||
if self.passthrough():
|
||||
return self._embed_jpeg(pdf)
|
||||
width, height = self.image.size
|
||||
if width * height > MAX_DECODED_PIXELS:
|
||||
raise Unsupported(f'"{self.name}" has {width} x {height} px, more than the '
|
||||
'generator decodes; prepare this item by hand')
|
||||
return self._embed_pixels(pdf)
|
||||
|
||||
def _colorspace(self, pdf, mode):
|
||||
components = {'L': 1, 'RGB': 3, 'CMYK': 4}[mode]
|
||||
device = Name({'L': 'DeviceGray', 'RGB': 'DeviceRGB', 'CMYK': 'DeviceCMYK'}[mode])
|
||||
profile = self.image.info.get('icc_profile')
|
||||
if not profile:
|
||||
return device
|
||||
icc = pdf.stream({'N': components, 'Alternate': device, 'Filter': Name('FlateDecode')},
|
||||
deflate([profile]))
|
||||
return [Name('ICCBased'), icc]
|
||||
|
||||
def _embed_jpeg(self, pdf):
|
||||
image = self.image
|
||||
extra = {}
|
||||
if image.mode == 'CMYK' and 'adobe' in image.info:
|
||||
# Adobe writes CMYK JPEGs inverted; PDF readers expect the Decode flip.
|
||||
extra['Decode'] = [1, 0, 1, 0, 1, 0, 1, 0]
|
||||
|
||||
def chunks():
|
||||
with open(self.path, 'rb') as source:
|
||||
while block := source.read(1 << 20):
|
||||
yield block
|
||||
return pdf.stream({'Type': Name('XObject'), 'Subtype': Name('Image'),
|
||||
'Width': image.width, 'Height': image.height,
|
||||
'ColorSpace': self._colorspace(pdf, image.mode),
|
||||
'BitsPerComponent': 8, 'Filter': Name('DCTDecode'), **extra},
|
||||
chunks())
|
||||
|
||||
def _upright(self):
|
||||
image = self.image
|
||||
image.load()
|
||||
if self.orientation != 1:
|
||||
image = ImageOps.exif_transpose(image)
|
||||
return image
|
||||
|
||||
def _embed_pixels(self, pdf):
|
||||
image = self._upright()
|
||||
mode = image.mode
|
||||
has_alpha = mode in ('RGBA', 'LA', 'PA', 'RGBa', 'La') or (
|
||||
mode == 'P' and 'transparency' in image.info) or (
|
||||
mode in ('L', 'RGB') and 'transparency' in image.info)
|
||||
if mode in ('I;16', 'I;16B', 'I;16L', 'I'):
|
||||
image = image.convert('I').point(lambda value: value * (1 / 257)).convert('L')
|
||||
mode = 'L'
|
||||
if mode == 'CMYK':
|
||||
color_mode = 'CMYK'
|
||||
elif mode in ('1', 'L', 'LA', 'La'):
|
||||
color_mode = 'L'
|
||||
elif mode in ('P', 'PA', 'RGB', 'RGBA', 'RGBa'):
|
||||
color_mode = 'RGB'
|
||||
else:
|
||||
raise Unsupported(f'"{self.name}" uses the {mode} colour mode, which is not generated automatically')
|
||||
if has_alpha:
|
||||
image = image.convert('RGBA' if color_mode == 'RGB' else 'LA')
|
||||
width, height = image.size
|
||||
|
||||
def strips(convert):
|
||||
for top in range(0, height, STRIP_ROWS):
|
||||
yield convert(image.crop((0, top, width, min(height, top + STRIP_ROWS)))).tobytes()
|
||||
|
||||
smask = None
|
||||
if has_alpha:
|
||||
smask = pdf.stream({'Type': Name('XObject'), 'Subtype': Name('Image'),
|
||||
'Width': width, 'Height': height,
|
||||
'ColorSpace': Name('DeviceGray'), 'BitsPerComponent': 8,
|
||||
'Filter': Name('FlateDecode')},
|
||||
deflate(strips(lambda strip: strip.getchannel('A'))))
|
||||
colorspace = self._colorspace(pdf, color_mode)
|
||||
dictionary = {'Type': Name('XObject'), 'Subtype': Name('Image'),
|
||||
'Width': width, 'Height': height, 'ColorSpace': colorspace,
|
||||
'BitsPerComponent': 8, 'Filter': Name('FlateDecode')}
|
||||
if smask:
|
||||
dictionary['SMask'] = smask
|
||||
return pdf.stream(dictionary, deflate(strips(lambda strip: strip.convert(color_mode))))
|
||||
|
||||
def close(self):
|
||||
self.image.close()
|
||||
|
||||
|
||||
def placement_matrix(placement, page_height_pt):
|
||||
"""Map the image's unit square onto its box on the film.
|
||||
|
||||
Matches the Site's canvas: the artwork is mirrored first, then turned
|
||||
clockwise about the centre of its box, and the turned image fills the box.
|
||||
"""
|
||||
x = float(placement['x_cm']) * PT_PER_CM
|
||||
top = page_height_pt - float(placement['y_cm']) * PT_PER_CM
|
||||
w = float(placement['width_cm']) * PT_PER_CM
|
||||
h = float(placement['length_cm']) * PT_PER_CM
|
||||
rotation = placement['rotation_degrees'] % 360
|
||||
mirrored = placement['mirrored']
|
||||
|
||||
def to_page(u, v):
|
||||
# Unit square (v up) -> image frame (s right, t down).
|
||||
s, t = u, 1 - v
|
||||
if mirrored:
|
||||
s = 1 - s
|
||||
s, t = {0: (s, t), 90: (1 - t, s), 180: (1 - s, 1 - t), 270: (t, 1 - s)}[rotation]
|
||||
return x + s * w, top - t * h
|
||||
|
||||
ox, oy = to_page(0, 0)
|
||||
ax, ay = to_page(1, 0)
|
||||
cx, cy = to_page(0, 1)
|
||||
return [ax - ox, ay - oy, cx - ox, cy - oy, ox, oy]
|
||||
|
||||
|
||||
def check_layout(item, sizes):
|
||||
"""Refuse a layout the file cannot reproduce faithfully. Returns the lowest DPI."""
|
||||
production = item['production']
|
||||
height = Decimal(str(production['height_cm']))
|
||||
billed = Decimal(str(item['billed_metres'])) * 100
|
||||
if height > billed + HEIGHT_TOLERANCE_CM:
|
||||
raise Unsupported(f'layout is {height} cm long but only {billed} cm were billed')
|
||||
lowest = None
|
||||
for placement in production['placements']:
|
||||
width_px, height_px = sizes[placement['source_index']]
|
||||
if placement['rotation_degrees'] % 180 == 90:
|
||||
width_px, height_px = height_px, width_px
|
||||
width_cm = float(placement['width_cm'])
|
||||
length_cm = float(placement['length_cm'])
|
||||
drift = abs((width_px / height_px) / (width_cm / length_cm) - 1)
|
||||
if drift > ASPECT_TOLERANCE:
|
||||
source = production['sources'][placement['source_index']]
|
||||
raise Unsupported(f'file {placement["source_index"] + 1} has proportions that do not match '
|
||||
f'the quoted {source["width_cm"]} x {source["length_cm"]} cm')
|
||||
dpi = width_px / (width_cm / 2.54)
|
||||
lowest = dpi if lowest is None else min(lowest, dpi)
|
||||
return lowest
|
||||
|
||||
|
||||
def render(item, files, out, title):
|
||||
"""Write the PDF for one approved order item.
|
||||
|
||||
`files` maps each source index to (local path, original name). Returns the
|
||||
evidence the Kanban shows: page size, what was billed, and the lowest DPI.
|
||||
"""
|
||||
production = item['production']
|
||||
if production.get('version') != 2:
|
||||
raise Unsupported('item uses an obsolete production layout')
|
||||
sources = []
|
||||
try:
|
||||
for index in range(len(production['sources'])):
|
||||
path, name = files[index]
|
||||
sources.append(SourceImage(path, name))
|
||||
lowest_dpi = check_layout(item, [source.size for source in sources])
|
||||
|
||||
width_pt = float(production['film_width_cm']) * PT_PER_CM
|
||||
height_pt = float(production['height_cm']) * PT_PER_CM
|
||||
unit = max(1, math.ceil(max(width_pt, height_pt) / MAX_PAGE_PT))
|
||||
|
||||
pdf = PdfWriter(out)
|
||||
images = [source.embed(pdf) for source in sources]
|
||||
commands = [b'%s 0 0 %s 0 0 cm\n' % (serialize(1 / unit), serialize(1 / unit))] if unit > 1 else []
|
||||
for placement in production['placements']:
|
||||
matrix = placement_matrix(placement, height_pt)
|
||||
commands.append(b'q ' + b' '.join(serialize(v) for v in matrix) +
|
||||
b' cm /Im%d Do Q\n' % placement['source_index'])
|
||||
content = pdf.stream({'Filter': Name('FlateDecode')}, deflate(commands))
|
||||
pages = pdf.alloc()
|
||||
page_box = [0, 0, width_pt / unit, height_pt / unit]
|
||||
page = {'Type': Name('Page'), 'Parent': pages, 'MediaBox': page_box, 'TrimBox': page_box,
|
||||
'Resources': {'XObject': {f'Im{index}': ref for index, ref in enumerate(images)}},
|
||||
'Contents': content}
|
||||
if unit > 1:
|
||||
page['UserUnit'] = unit
|
||||
page_ref = pdf.obj(page)
|
||||
pdf.obj({'Type': Name('Pages'), 'Kids': [page_ref], 'Count': 1}, pages)
|
||||
root = pdf.obj({'Type': Name('Catalog'), 'Pages': pages})
|
||||
info = pdf.obj({'Title': title, 'Producer': 'DTF System print-file generator'})
|
||||
pdf.finish(root, info)
|
||||
finally:
|
||||
for source in sources:
|
||||
source.close()
|
||||
return {'film_width_cm': str(production['film_width_cm']),
|
||||
'height_cm': str(production['height_cm']),
|
||||
'billed_metres': str(item['billed_metres']),
|
||||
'placements': len(production['placements']),
|
||||
'sources': len(sources),
|
||||
'min_dpi': round(lowest_dpi) if lowest_dpi else None,
|
||||
'user_unit': unit}
|
||||
139
app/printjobs.py
Normal file
139
app/printjobs.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Generating print files in the background, one order item at a time.
|
||||
|
||||
A paid order queues one job per item. The worker claims a job, commits the
|
||||
claim, and renders outside any transaction, so a long render never holds a row
|
||||
lock or a database connection. A claim older than CLAIM_TIMEOUT is assumed to
|
||||
belong to a worker that died and is taken again.
|
||||
|
||||
The result is an ordinary upload row owned by an identity derived from the
|
||||
order, already marked clean: its only inputs are artwork that passed the
|
||||
malware scan, and the bytes are written here. The operator still decides
|
||||
whether it becomes the final file; generation never approves anything.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from uuid import NAMESPACE_URL, UUID, uuid4, uuid5
|
||||
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from .core.auth import audit
|
||||
from .core.db import connect
|
||||
from .printfile import Unsupported, render
|
||||
|
||||
CLAIM_TIMEOUT = timedelta(minutes=15)
|
||||
MAX_ATTEMPTS = 3
|
||||
|
||||
|
||||
def generated_identity(order_id):
|
||||
return uuid5(NAMESPACE_URL, f'dtf-print-file:{order_id}')
|
||||
|
||||
|
||||
def queue(c, order_id, items, only_missing=False):
|
||||
"""Queue (or re-queue) generation for every item of an order."""
|
||||
for index in range(items):
|
||||
if only_missing:
|
||||
c.execute('''INSERT INTO dtf_local.print_files(id,order_id,item_index) VALUES(%s,%s,%s)
|
||||
ON CONFLICT(order_id,item_index) DO UPDATE SET status='pending', attempts=0,
|
||||
claimed_at=NULL, finished_at=NULL, detail='{}'
|
||||
WHERE dtf_local.print_files.status IN ('failed','manual')''',
|
||||
(uuid4(), order_id, index))
|
||||
else:
|
||||
c.execute('''INSERT INTO dtf_local.print_files(id,order_id,item_index) VALUES(%s,%s,%s)
|
||||
ON CONFLICT(order_id,item_index) DO NOTHING''', (uuid4(), order_id, index))
|
||||
|
||||
|
||||
def claim(c):
|
||||
return c.execute('''SELECT p.*, o.snapshot, o.number FROM dtf_local.print_files p
|
||||
JOIN dtf_local.orders o ON o.id=p.order_id
|
||||
WHERE p.status='pending' OR (p.status='rendering' AND p.claimed_at < now()-%s)
|
||||
ORDER BY p.created_at FOR UPDATE OF p SKIP LOCKED LIMIT 1''', (CLAIM_TIMEOUT,)).fetchone()
|
||||
|
||||
|
||||
def render_one(storage):
|
||||
with connect() as c:
|
||||
job = claim(c)
|
||||
if not job:
|
||||
return False
|
||||
c.execute('''UPDATE dtf_local.print_files SET status='rendering', claimed_at=now(),
|
||||
attempts=attempts+1 WHERE id=%s''', (job['id'],))
|
||||
item = job['snapshot']['items'][job['item_index']]
|
||||
uploads = c.execute('''SELECT id,name,object_key,scan_state,purged_at,expires_at,
|
||||
(expires_at<=now()) AS expired FROM dtf_local.uploads WHERE id=ANY(%s)''',
|
||||
([UUID(u) for u in item['uploads']],)).fetchall()
|
||||
# Every generated file shares its order's artwork retention deadline.
|
||||
expiry = c.execute('SELECT min(created_at)+interval \'30 days\' AS e FROM dtf_local.uploads WHERE id=ANY(%s)',
|
||||
([UUID(u) for u in item['uploads']],)).fetchone()['e']
|
||||
by_id = {str(row['id']): row for row in uploads}
|
||||
try:
|
||||
result = produce(storage, job, item, by_id)
|
||||
except Unsupported as reason:
|
||||
finish(job, 'manual', {'reason': str(reason)})
|
||||
audit('print_file_manual', order=str(job['order_id']), item=job['item_index'])
|
||||
return True
|
||||
except Exception as exc:
|
||||
logging.exception('Print file generation failed')
|
||||
final = job['attempts'] + 1 >= MAX_ATTEMPTS
|
||||
finish(job, 'failed' if final else 'pending', {'error': type(exc).__name__})
|
||||
return True
|
||||
path, name, size, detail = result
|
||||
try:
|
||||
uid = uuid4()
|
||||
key = f'originals/{uid}'
|
||||
storage.store(key, path, 'application/pdf')
|
||||
finally:
|
||||
os.unlink(path)
|
||||
with connect() as c:
|
||||
c.execute('''INSERT INTO dtf_local.uploads(id,owner,name,size,object_key,multipart_id,complete,
|
||||
expires_at,scan_state,scan_reason,scanned_at)
|
||||
VALUES(%s,%s,%s,%s,%s,'',true,%s,'clean','generated from scanned artwork',now())''',
|
||||
(uid, generated_identity(job['order_id']), name, size, key, expiry))
|
||||
c.execute('''UPDATE dtf_local.print_files SET status='ready', upload_id=%s, detail=%s,
|
||||
finished_at=now() WHERE id=%s''', (uid, Jsonb(detail), job['id']))
|
||||
audit('print_file_ready', order=str(job['order_id']), item=job['item_index'])
|
||||
return True
|
||||
|
||||
|
||||
def produce(storage, job, item, uploads):
|
||||
"""Fetch the item's artwork and render it. Returns (pdf path, name, size, evidence)."""
|
||||
for upload_id in item['uploads']:
|
||||
row = uploads.get(upload_id)
|
||||
if not row or row['scan_state'] != 'clean':
|
||||
raise Unsupported('an original file is missing or not cleared by the malware scan')
|
||||
if row['purged_at'] or row['expired']:
|
||||
raise Unsupported('an original file has passed its retention period')
|
||||
number = job['number']
|
||||
name = f'pedido-{number}-item-{job["item_index"] + 1}.pdf'
|
||||
with tempfile.TemporaryDirectory(prefix='print-') as scratch:
|
||||
files = {}
|
||||
for index, upload_id in enumerate(item['uploads']):
|
||||
local = os.path.join(scratch, f'source-{index}')
|
||||
storage.fetch(uploads[upload_id]['object_key'], local)
|
||||
files[index] = (local, uploads[upload_id]['name'])
|
||||
handle, output = tempfile.mkstemp(prefix='print-', suffix='.pdf')
|
||||
try:
|
||||
with os.fdopen(handle, 'wb') as out:
|
||||
detail = render(item, files, out, f'Pedido {number} - item {job["item_index"] + 1}')
|
||||
except BaseException:
|
||||
os.unlink(output)
|
||||
raise
|
||||
return output, name, os.path.getsize(output), detail
|
||||
|
||||
|
||||
def finish(job, status, detail):
|
||||
with connect() as c:
|
||||
c.execute('''UPDATE dtf_local.print_files SET status=%s, detail=%s,
|
||||
finished_at=CASE WHEN %s='pending' THEN NULL ELSE now() END,
|
||||
claimed_at=NULL WHERE id=%s''', (status, Jsonb(detail), status, job['id']))
|
||||
|
||||
|
||||
def render_loop(storage):
|
||||
while True:
|
||||
try:
|
||||
if render_one(storage):
|
||||
continue
|
||||
except Exception:
|
||||
logging.exception('Print render worker tick failed')
|
||||
time.sleep(2)
|
||||
@@ -20,7 +20,11 @@ load_secret_files()
|
||||
require_runtime()
|
||||
|
||||
storage = LocalS3Storage()
|
||||
payment = FakePayment()
|
||||
if os.environ.get('PAYMENT_ADAPTER') == 'mercadopago':
|
||||
from .mercadopago import MercadoPagoPayment
|
||||
payment = MercadoPagoPayment()
|
||||
else:
|
||||
payment = FakePayment()
|
||||
freight = FakeFreight()
|
||||
ENVIRONMENT = os.environ.get('APP_ENV', 'local')
|
||||
PUBLIC_ORIGIN = os.environ.get('PUBLIC_ORIGIN', 'http://localhost')
|
||||
|
||||
@@ -90,6 +90,28 @@ CREATE TABLE IF NOT EXISTS dtf_local.order_files (
|
||||
note text NOT NULL, created_by text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE(order_id,upload_id,kind)
|
||||
);
|
||||
-- A payment started at the provider for an approved quote: which provider
|
||||
-- payment belongs to which quote, and its latest known status.
|
||||
CREATE TABLE IF NOT EXISTS dtf_local.payment_intents (
|
||||
id uuid PRIMARY KEY, quote_id uuid NOT NULL REFERENCES dtf_local.quotes(id),
|
||||
provider text NOT NULL, provider_payment_id text NOT NULL, method text NOT NULL,
|
||||
status text NOT NULL, amount_cents bigint NOT NULL, response jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE(provider, provider_payment_id)
|
||||
);
|
||||
-- The print file generated from each paid item's approved layout. One row per
|
||||
-- item: the worker claims it, renders, and records either the file or why the
|
||||
-- item has to be prepared by hand. Regenerating replaces the row's result.
|
||||
CREATE TABLE IF NOT EXISTS dtf_local.print_files (
|
||||
id uuid PRIMARY KEY, order_id uuid NOT NULL REFERENCES dtf_local.orders(id),
|
||||
item_index integer NOT NULL,
|
||||
status text NOT NULL DEFAULT 'pending'
|
||||
CHECK(status IN ('pending','rendering','ready','manual','failed')),
|
||||
upload_id uuid REFERENCES dtf_local.uploads(id), detail jsonb NOT NULL DEFAULT '{}',
|
||||
attempts integer NOT NULL DEFAULT 0, claimed_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz,
|
||||
UNIQUE(order_id,item_index)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS uploads_owner ON dtf_local.uploads(owner);
|
||||
|
||||
@@ -131,3 +153,19 @@ CREATE INDEX IF NOT EXISTS security_events_created ON dtf_local.security_events(
|
||||
-- its own index, and it stays the size of the backlog.
|
||||
CREATE INDEX IF NOT EXISTS payment_events_unprocessed ON dtf_local.payment_events(received_at)
|
||||
WHERE processed_at IS NULL;
|
||||
|
||||
-- The render worker polls for unclaimed or abandoned jobs; the order view reads
|
||||
-- by order through the unique constraint.
|
||||
CREATE INDEX IF NOT EXISTS print_files_open ON dtf_local.print_files(created_at)
|
||||
WHERE status IN ('pending','rendering');
|
||||
|
||||
-- The Kanban lists payment events a person must act on (money without an
|
||||
-- order, or a reversed payment on an existing order) until resolved.
|
||||
ALTER TABLE dtf_local.payment_events ADD COLUMN IF NOT EXISTS resolved_at timestamptz;
|
||||
ALTER TABLE dtf_local.payment_events ADD COLUMN IF NOT EXISTS resolved_by text;
|
||||
ALTER TABLE dtf_local.payment_events ADD COLUMN IF NOT EXISTS resolution text;
|
||||
CREATE INDEX IF NOT EXISTS payment_events_open_issues ON dtf_local.payment_events(received_at)
|
||||
WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL;
|
||||
|
||||
-- Payment intents look up by quote (customer retry) and by provider id (webhook).
|
||||
CREATE INDEX IF NOT EXISTS payment_intents_quote ON dtf_local.payment_intents(quote_id, created_at DESC);
|
||||
|
||||
118
app/tiny.py
Normal file
118
app/tiny.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Tiny/Olist (API 2.0): create the sales order once a payment is approved.
|
||||
|
||||
Written from the public API documentation and exercised only against a fake
|
||||
HTTP transport. Endpoints, product codes, tags and rate limits still have to
|
||||
be confirmed against the client's account before this is a real integration.
|
||||
|
||||
Idempotency: the outbox may deliver the same event more than once (a timeout
|
||||
after Tiny accepted it, a worker restart). Every order is created with
|
||||
numero_pedido_ecommerce = "DTF-<order number>", and Tiny is searched for that
|
||||
number first, so a second delivery finds the first order instead of creating
|
||||
another one.
|
||||
|
||||
Tiny answers HTTP 200 for most failures and reports them in retorno.status,
|
||||
so the body decides success. Anything unexpected raises, and the outbox
|
||||
retries with backoff; nothing is marked delivered unless Tiny confirmed it.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from decimal import Decimal
|
||||
|
||||
import httpx
|
||||
|
||||
API = 'https://api.tiny.com.br/api2'
|
||||
PRODUCTS = {'file': 'DTF Têxtil 57 cm · folha montada',
|
||||
'avulsa': 'DTF Têxtil 57 cm · artes avulsas',
|
||||
'uvfile': 'DTF UV 28,5 cm · folha montada',
|
||||
'uv': 'DTF UV 28,5 cm · artes avulsas'}
|
||||
|
||||
|
||||
class TinyError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def ecommerce_number(number):
|
||||
return f'DTF-{number}'
|
||||
|
||||
|
||||
def money(cents):
|
||||
return f'{Decimal(cents) / 100:.2f}'
|
||||
|
||||
|
||||
def order_payload(payload):
|
||||
"""The Tiny 'pedido' for a paid order's approved snapshot."""
|
||||
order = payload['order']
|
||||
customer = order['customer']
|
||||
destination = order.get('destination')
|
||||
client = {'nome': (destination or {}).get('recipient') or customer['mail'],
|
||||
'tipo_pessoa': 'J', 'cpf_cnpj': customer['cnpj'],
|
||||
'fone': customer['zap'], 'email': customer['mail']}
|
||||
if destination:
|
||||
client.update(endereco=destination['street'], numero=destination['number'],
|
||||
complemento=destination.get('complement', ''), bairro=destination['district'],
|
||||
cep=destination['postal_code'], cidade=destination['city'], uf=destination['state'])
|
||||
items = []
|
||||
for item in order['items']:
|
||||
code = os.environ.get(f"TINY_SKU_{item['mode'].upper()}", '')
|
||||
entry = {'descricao': PRODUCTS[item['mode']] + f" · nota {item['grade']}",
|
||||
'unidade': 'M', 'quantidade': item['billed_metres'],
|
||||
'valor_unitario': money(item['unit_cents'])}
|
||||
if code:
|
||||
entry['codigo'] = code
|
||||
items.append({'item': entry})
|
||||
freight = order['freight']
|
||||
pedido = {'numero_pedido_ecommerce': ecommerce_number(payload['number']),
|
||||
'cliente': client, 'itens': items,
|
||||
'valor_frete': money(freight['total_cents']),
|
||||
'obs': f"Pedido DTF #{payload['number']} · pago · {payload['order_id']}"}
|
||||
if freight.get('service') == 'pickup':
|
||||
# What dispatch already receives for pickups today (see web/site-config.js).
|
||||
pedido.update(forma_envio='X', nome_transportador='DropStar', frete_por_conta='R')
|
||||
tag = os.environ.get('TINY_TAG', '')
|
||||
if tag:
|
||||
pedido['marcadores'] = [{'marcador': {'descricao': tag}}]
|
||||
return {'pedido': pedido}
|
||||
|
||||
|
||||
class TinyOrders:
|
||||
def __init__(self, token=None, transport=None):
|
||||
self.token = token or os.environ.get('TINY_TOKEN', '')
|
||||
if not self.token:
|
||||
raise RuntimeError('Tiny needs TINY_TOKEN')
|
||||
self.http = httpx.Client(base_url=API, transport=transport, timeout=30)
|
||||
|
||||
def call(self, method, **params):
|
||||
response = self.http.post(f'/{method}.php', data={'token': self.token, 'formato': 'json', **params})
|
||||
response.raise_for_status()
|
||||
body = response.json().get('retorno', {})
|
||||
if body.get('status') != 'OK':
|
||||
errors = body.get('erros') or body.get('registros') or []
|
||||
# "No records" is how the search reports an empty result.
|
||||
if str(body.get('codigo_erro')) == '20':
|
||||
return {'pedidos': []}
|
||||
raise TinyError(f"{method}: {body.get('codigo_erro')} {json.dumps(errors, ensure_ascii=False)[:300]}")
|
||||
return body
|
||||
|
||||
def find(self, number):
|
||||
found = self.call('pedidos.pesquisa', numeroEcommerce=ecommerce_number(number))
|
||||
for entry in found.get('pedidos') or []:
|
||||
pedido = entry.get('pedido', entry)
|
||||
if str(pedido.get('numero_ecommerce')) == ecommerce_number(number):
|
||||
return pedido
|
||||
return None
|
||||
|
||||
def deliver(self, event_key, payload):
|
||||
if payload.get('event') != 'payment_approved':
|
||||
# Production progress is not written to Tiny; only the sale is.
|
||||
return {'provider': 'tiny', 'event_key': event_key, 'status': 'not-applicable',
|
||||
'event': payload.get('event')}
|
||||
existing = self.find(payload['number'])
|
||||
if existing:
|
||||
return {'provider': 'tiny', 'event_key': event_key, 'status': 'already-created',
|
||||
'tiny_id': str(existing.get('id')), 'tiny_number': str(existing.get('numero'))}
|
||||
created = self.call('pedido.incluir', pedido=json.dumps(order_payload(payload), ensure_ascii=False))
|
||||
record = (created.get('registros') or [{}])[0].get('registro', {})
|
||||
if record.get('status') != 'OK':
|
||||
raise TinyError(f"pedido.incluir: {json.dumps(record, ensure_ascii=False)[:300]}")
|
||||
return {'provider': 'tiny', 'event_key': event_key, 'status': 'created',
|
||||
'tiny_id': str(record.get('id')), 'tiny_number': str(record.get('numero'))}
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Transactional outbox worker. Fake receipts persist; no messages leave the stack."""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
@@ -8,15 +9,21 @@ from psycopg.types.json import Jsonb
|
||||
from .adapters import FakeTiny, FakeWhatsApp, LocalS3Storage, require_runtime
|
||||
from .core.secrets import load as load_secret_files
|
||||
from .core.db import connect
|
||||
from .printjobs import render_loop
|
||||
from .scanning import ClamAV, scan_loop
|
||||
|
||||
load_secret_files()
|
||||
require_runtime()
|
||||
adapters = {'tiny': FakeTiny(), 'whatsapp': FakeWhatsApp()}
|
||||
# Not yet confirmed against the client's account; see app/tiny.py.
|
||||
if os.environ.get('TINY_ADAPTER') == 'tiny':
|
||||
from .tiny import TinyOrders
|
||||
adapters['tiny'] = TinyOrders()
|
||||
last_tick = 0.0
|
||||
last_cleanup = 0.0
|
||||
storage = LocalS3Storage()
|
||||
scan_thread = None
|
||||
render_thread = None
|
||||
|
||||
def cleanup():
|
||||
"""Delete only expired object bytes; retain order/file metadata and history."""
|
||||
@@ -64,16 +71,20 @@ class Health(BaseHTTPRequestHandler):
|
||||
scanner = bool(scan_thread and scan_thread.is_alive() and ClamAV().ping())
|
||||
except Exception:
|
||||
pass
|
||||
healthy = time.monotonic()-last_tick < 15 and scanner
|
||||
renderer = bool(render_thread and render_thread.is_alive())
|
||||
healthy = time.monotonic()-last_tick < 15 and scanner and renderer
|
||||
self.send_response(200 if self.path == '/health' and healthy else 503)
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({'worker': 'ok' if healthy else 'unavailable',
|
||||
'scanner': 'ok' if scanner else 'unavailable'}).encode())
|
||||
'scanner': 'ok' if scanner else 'unavailable',
|
||||
'print_files': 'ok' if renderer else 'unavailable'}).encode())
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
if __name__ == '__main__':
|
||||
scan_thread = threading.Thread(target=scan_loop,args=(storage,),daemon=True)
|
||||
scan_thread.start()
|
||||
render_thread = threading.Thread(target=render_loop,args=(storage,),daemon=True)
|
||||
render_thread.start()
|
||||
threading.Thread(target=loop, daemon=True).start()
|
||||
HTTPServer(('0.0.0.0',8002),Health).serve_forever()
|
||||
|
||||
Reference in New Issue
Block a user