fix: harden week-two ordering, artwork and operations
This commit is contained in:
@@ -12,7 +12,7 @@ from ..artwork import submit_files
|
||||
from ..core.auth import operator
|
||||
from ..core.models import ArtworkSubmission, UploadStart
|
||||
from ..runtime import file_rows, operator_identity
|
||||
from .uploads import begin_upload, complete_upload, part_url, upload_status
|
||||
from .uploads import begin_upload, cancel_upload, complete_upload, part_url, upload_status
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -36,6 +36,10 @@ def final_part(uid: UUID, number: int, user=Depends(operator)):
|
||||
def final_complete(uid: UUID, user=Depends(operator)):
|
||||
return complete_upload(uid,session_id=operator_identity(user))
|
||||
|
||||
@router.delete('/api/operator/uploads/{uid}')
|
||||
def final_cancel(uid: UUID, user=Depends(operator)):
|
||||
return cancel_upload(uid,session_id=operator_identity(user))
|
||||
|
||||
@router.get('/api/operator/orders/{oid}/files')
|
||||
def operator_files(oid: UUID, user=Depends(operator)):
|
||||
with db.connect() as c:
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"""Health, session bootstrap and freight quoting."""
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
|
||||
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
|
||||
|
||||
@@ -33,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': int(os.environ.get('MAX_UPLOAD_BYTES', '5368709120'))}
|
||||
'max_upload_bytes': upload_limit_bytes()}
|
||||
|
||||
@router.post('/api/freight')
|
||||
def quote_freight(body: Freight):
|
||||
|
||||
@@ -3,9 +3,10 @@ import hashlib
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from ..core import db
|
||||
@@ -65,15 +66,44 @@ def board(user=Depends(operator)):
|
||||
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']
|
||||
quotes = 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
|
||||
ORDER BY q.created_at LIMIT %s''', (BOARD_QUOTE_LIMIT,)).fetchall()
|
||||
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']
|
||||
return {'states': STATES, 'transitions': TRANSITIONS,
|
||||
'orders': active + list(reversed(finished)),
|
||||
'finished_shown': len(finished), 'finished_total': finished_total,
|
||||
'quotes': [quote_view(c, q) for q in quotes],
|
||||
'quotes': [quote_view(c, q) for q in pending + approved],
|
||||
'pending_total': pending_total, 'approved_total': approved_total,
|
||||
'events': c.execute('SELECT * FROM dtf_local.outbox ORDER BY id DESC LIMIT 100').fetchall()}
|
||||
|
||||
@router.get('/api/operator/quotes')
|
||||
def quote_page(kind: Literal['pending','approved'], before_created_at: datetime | None = None,
|
||||
before_id: UUID | None = None, limit: int = Query(default=50, ge=1, le=100),
|
||||
user=Depends(operator)):
|
||||
if (before_created_at is None) != (before_id is None):
|
||||
raise HTTPException(422, 'Both quote cursor fields are required')
|
||||
approved_filter = 'q.approved IS NULL' if kind == 'pending' else 'q.approved IS NOT NULL'
|
||||
cursor = 'AND (q.created_at,q.id)<(%s,%s)' if before_created_at else ''
|
||||
params = ((before_created_at,before_id) if before_created_at else ()) + (limit+1,)
|
||||
with db.connect() as c:
|
||||
rows = c.execute(f'''SELECT q.* FROM dtf_local.quotes q
|
||||
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
|
||||
WHERE o.id IS NULL AND {approved_filter} {cursor}
|
||||
ORDER BY q.created_at DESC,q.id DESC LIMIT %s''', params).fetchall()
|
||||
return {'quotes':[quote_view(c,row) for row in rows[:limit]],
|
||||
'has_more':len(rows)>limit}
|
||||
|
||||
@router.post('/api/operator/quotes/{uid}/approve')
|
||||
def approve(uid: UUID, body: Review, user=Depends(operator)):
|
||||
with db.connect() as c:
|
||||
@@ -83,15 +113,22 @@ def approve(uid: UUID, body: Review, user=Depends(operator)):
|
||||
if row['approved']:
|
||||
raise HTTPException(409, 'Approved quotes are immutable; request a new quote')
|
||||
draft = row['draft']
|
||||
if any(item.get('production', {}).get('version') != 2 for item in draft['items']):
|
||||
raise HTTPException(409, 'Quote uses an obsolete production layout; customer must request a new quote')
|
||||
if len(body.items) != len(draft['items']):
|
||||
raise HTTPException(422, 'Review must cover every item')
|
||||
items = []
|
||||
for item, original in zip(body.items, draft['items']):
|
||||
if item.mode != original['mode'] or list(map(str,item.uploads)) != original['uploads']:
|
||||
raise HTTPException(422, 'Product mode and attached files cannot change during review')
|
||||
if item.production.model_dump(mode='json') != original['production'] or item.quality_status != original['quality_status'] or item.quality_acknowledged != original['quality_acknowledged']:
|
||||
raise HTTPException(422, 'Production instructions and customer acknowledgement cannot change during commercial review')
|
||||
for upload_id in item.uploads:
|
||||
require_clean(upload_row(c, upload_id, row['owner']))
|
||||
items.append({**price(item.mode, str(item.metres), item.grade), 'uploads': original['uploads']})
|
||||
items.append({**price(item.mode, str(item.metres), item.grade),
|
||||
'uploads': original['uploads'], 'production': original['production'],
|
||||
'quality_status': original['quality_status'],
|
||||
'quality_acknowledged': original['quality_acknowledged']})
|
||||
quoted_freight = freight.quote(**draft['freight'])
|
||||
approved = {'customer': draft['customer'], 'items': items, 'freight': quoted_freight,
|
||||
'total_cents': sum(i['total_cents'] for i in items)+quoted_freight['total_cents']}
|
||||
|
||||
@@ -26,8 +26,8 @@ def dev_paid(body: Pay, session_id=Depends(owner)):
|
||||
quote = payments.approved_quote(c, body.quote_id, session_id)
|
||||
except payments.PaymentRefused as refusal:
|
||||
# The quote may already be paid; that is not a refusal.
|
||||
existing = c.execute('SELECT * FROM dtf_local.orders WHERE quote_id=%s',
|
||||
(body.quote_id,)).fetchone()
|
||||
existing = c.execute('SELECT * FROM dtf_local.orders WHERE quote_id=%s AND owner=%s',
|
||||
(body.quote_id, session_id)).fetchone()
|
||||
if existing:
|
||||
return existing
|
||||
raise HTTPException(404 if 'not found' in str(refusal) else 409, str(refusal))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Quotes: the customer's cart, and the operator-reviewed version of it."""
|
||||
import hashlib
|
||||
import json
|
||||
from decimal import Decimal
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
@@ -16,6 +17,9 @@ router = APIRouter()
|
||||
|
||||
@router.post('/api/quotes')
|
||||
def create_quote(body: QuoteRequest, session_id=Depends(owner)):
|
||||
for item in body.items:
|
||||
if abs(item.metres*100-item.production.height_cm) > Decimal('0.02'):
|
||||
raise HTTPException(422, 'Quoted metres do not match the submitted layout height')
|
||||
draft = body.model_dump(mode='json', exclude={'request_key'})
|
||||
digest = hashlib.sha256(json.dumps(draft, sort_keys=True).encode()).hexdigest()
|
||||
try:
|
||||
|
||||
@@ -12,6 +12,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from ..core import db
|
||||
from ..core.auth import audit, owner, rate_limit
|
||||
from ..core.limits import upload_limit_bytes
|
||||
from ..core.models import UploadStart
|
||||
from ..runtime import PART_BYTES, storage, upload_row
|
||||
|
||||
@@ -19,8 +20,8 @@ router = APIRouter()
|
||||
|
||||
@router.post('/api/uploads')
|
||||
def begin_upload(body: UploadStart, session_id=Depends(owner)):
|
||||
if body.size > int(os.environ.get('MAX_UPLOAD_BYTES', '5368709120')):
|
||||
raise HTTPException(413, 'File exceeds the upload limit')
|
||||
if body.size > upload_limit_bytes():
|
||||
raise HTTPException(413, 'File exceeds the malware scan limit; select a smaller file')
|
||||
uid = uuid4()
|
||||
key = f'originals/{uid}'
|
||||
rate_limit('upload-start', str(session_id), 60, 900)
|
||||
@@ -30,14 +31,16 @@ def begin_upload(body: UploadStart, session_id=Depends(owner)):
|
||||
usage = c.execute('''SELECT COALESCE(sum(size),0) AS total,
|
||||
COALESCE(sum(size) FILTER(WHERE owner=%s),0) AS owned,
|
||||
count(*) FILTER(WHERE owner=%s AND NOT complete) AS pending
|
||||
FROM dtf_local.uploads WHERE purged_at IS NULL''', (session_id,session_id)).fetchone()
|
||||
FROM dtf_local.uploads WHERE purged_at IS NULL''',
|
||||
(session_id,session_id)).fetchone()
|
||||
if (usage['total']+body.size > int(os.environ.get('STORAGE_QUOTA_BYTES','53687091200')) or
|
||||
usage['owned']+body.size > int(os.environ.get('OWNER_UPLOAD_QUOTA_BYTES','10737418240')) or
|
||||
usage['pending'] >= int(os.environ.get('MAX_PENDING_UPLOADS','10'))):
|
||||
audit('upload_quota_rejected')
|
||||
raise HTTPException(429, 'Local storage quota or pending upload limit reached')
|
||||
multipart = storage.begin(key)
|
||||
c.execute('INSERT INTO dtf_local.uploads(id,owner,name,size,object_key,multipart_id) VALUES(%s,%s,%s,%s,%s,%s)',
|
||||
c.execute('''INSERT INTO dtf_local.uploads(id,owner,name,size,object_key,multipart_id,expires_at)
|
||||
VALUES(%s,%s,%s,%s,%s,%s,now()+interval '1 hour')''',
|
||||
(uid, session_id, body.name, body.size, key, multipart))
|
||||
return {'id': uid, 'part_bytes': PART_BYTES}
|
||||
|
||||
@@ -81,5 +84,16 @@ def complete_upload(uid: UUID, session_id=Depends(owner)):
|
||||
existing_size = storage.size(row['object_key'])
|
||||
if existing_size != row['size']:
|
||||
raise HTTPException(409, 'Stored size differs from declared size')
|
||||
c.execute('UPDATE dtf_local.uploads SET complete=true WHERE id=%s', (uid,))
|
||||
c.execute("UPDATE dtf_local.uploads SET complete=true,expires_at=now()+interval '30 days' WHERE id=%s", (uid,))
|
||||
return {'id': uid, 'complete': True}
|
||||
|
||||
@router.delete('/api/uploads/{uid}')
|
||||
def cancel_upload(uid: UUID, session_id=Depends(owner)):
|
||||
with db.connect() as c:
|
||||
row=upload_row(c,uid,session_id,lock=True)
|
||||
if row['complete']:
|
||||
raise HTTPException(409, 'Completed upload cannot be cancelled')
|
||||
storage.discard(row['object_key'],row['multipart_id'],False)
|
||||
c.execute('UPDATE dtf_local.uploads SET purged_at=now() WHERE id=%s',(uid,))
|
||||
audit('upload_cancelled', upload=str(uid))
|
||||
return {'id':uid,'cancelled':True}
|
||||
|
||||
@@ -38,7 +38,12 @@ def submit_files(c, order, body, identity, kind, actor):
|
||||
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))
|
||||
# A new customer correction supersedes every final prepared from earlier
|
||||
# artwork, including finals uploaded while this order was in correction.
|
||||
if kind == 'correction':
|
||||
c.execute("UPDATE dtf_local.order_files SET active=false WHERE order_id=%s AND kind IN ('correction','final')", (order['id'],))
|
||||
else:
|
||||
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))
|
||||
|
||||
13
app/core/limits.py
Normal file
13
app/core/limits.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Limits shared by upload admission and the malware scanner."""
|
||||
import os
|
||||
|
||||
CLAMAV_STREAM_MAX_BYTES = 128 * 1024 * 1024 # infra/clamd.conf
|
||||
|
||||
|
||||
def scan_limit_bytes():
|
||||
return min(CLAMAV_STREAM_MAX_BYTES, int(os.environ.get('SCAN_MAX_BYTES', '134217728')))
|
||||
|
||||
|
||||
def upload_limit_bytes():
|
||||
transport = int(os.environ.get('MAX_UPLOAD_BYTES', '5368709120'))
|
||||
return min(transport, scan_limit_bytes())
|
||||
@@ -2,7 +2,7 @@ import re
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
class StrictModel(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid', allow_inf_nan=False)
|
||||
@@ -56,11 +56,80 @@ class UploadStart(StrictModel):
|
||||
raise ValueError('Unsupported artwork file extension')
|
||||
return value
|
||||
|
||||
class ProductionSource(StrictModel):
|
||||
upload_id: UUID
|
||||
kind: Literal['sheet', 'artwork']
|
||||
width_cm: Decimal = Field(gt=0, le=57)
|
||||
length_cm: Decimal = Field(gt=0, le=6000)
|
||||
copies: int = Field(ge=1, le=200, strict=True)
|
||||
rotation_degrees: Literal[0, 90] = 0
|
||||
mirrored: bool = False
|
||||
measurement: Literal['file', 'customer']
|
||||
|
||||
class ProductionPlacement(StrictModel):
|
||||
source_index: int = Field(ge=0, le=19, strict=True)
|
||||
copy_index: int = Field(ge=0, le=199, strict=True)
|
||||
x_cm: Decimal = Field(ge=0, le=57)
|
||||
y_cm: Decimal = Field(ge=0, le=1200000)
|
||||
width_cm: Decimal = Field(gt=0, le=57)
|
||||
length_cm: Decimal = Field(gt=0, le=6000)
|
||||
rotation_degrees: Literal[0, 90, 180, 270]
|
||||
mirrored: bool
|
||||
|
||||
class ProductionSpec(StrictModel):
|
||||
version: Literal[2]
|
||||
film_width_cm: Decimal
|
||||
height_cm: Decimal = Field(gt=0, le=1200000)
|
||||
sources: list[ProductionSource] = Field(min_length=1, max_length=20)
|
||||
placements: list[ProductionPlacement] = Field(min_length=1, max_length=4000)
|
||||
|
||||
class Item(StrictModel):
|
||||
mode: Literal['file','avulsa','uvfile','uv']
|
||||
metres: Decimal = Field(gt=0, le=12000)
|
||||
grade: int = Field(ge=0, le=100, strict=True)
|
||||
uploads: list[UUID] = Field(min_length=1, max_length=20)
|
||||
production: ProductionSpec
|
||||
quality_status: Literal['ok', 'warning', 'unverified']
|
||||
quality_acknowledged: bool
|
||||
|
||||
@model_validator(mode='after')
|
||||
def production_matches_uploads(self):
|
||||
if [source.upload_id for source in self.production.sources] != self.uploads:
|
||||
raise ValueError('Production sources must match uploaded files in order')
|
||||
is_sheet = self.mode in ('file', 'uvfile')
|
||||
film_width = Decimal('28.5') if self.mode in ('uvfile', 'uv') else Decimal('57')
|
||||
if self.production.film_width_cm != film_width:
|
||||
raise ValueError('Production film width does not match the product')
|
||||
for source in self.production.sources:
|
||||
if (source.kind == 'sheet') != is_sheet or source.width_cm > film_width:
|
||||
raise ValueError('Production source does not fit the selected product')
|
||||
if is_sheet and (source.rotation_degrees or source.mirrored):
|
||||
raise ValueError('Finished sheets cannot be rotated or mirrored by the layout')
|
||||
expected={(index,copy) for index,source in enumerate(self.production.sources)
|
||||
for copy in range(source.copies)}
|
||||
placed=set()
|
||||
tolerance=Decimal('0.02')
|
||||
for placement in self.production.placements:
|
||||
key=(placement.source_index,placement.copy_index)
|
||||
if key not in expected or key in placed:
|
||||
raise ValueError('Production placement has a missing or duplicate source copy')
|
||||
placed.add(key)
|
||||
source=self.production.sources[placement.source_index]
|
||||
auto_rotation=(placement.rotation_degrees-source.rotation_degrees)%360
|
||||
if auto_rotation not in (0,90) or placement.mirrored != source.mirrored:
|
||||
raise ValueError('Production placement changes the source transform')
|
||||
width,length=(source.width_cm,source.length_cm) if auto_rotation==0 else (source.length_cm,source.width_cm)
|
||||
if abs(placement.width_cm-width)>tolerance or abs(placement.length_cm-length)>tolerance:
|
||||
raise ValueError('Production placement changes the source size')
|
||||
if placement.x_cm+placement.width_cm>film_width+tolerance or placement.y_cm+placement.length_cm>self.production.height_cm+tolerance:
|
||||
raise ValueError('Production placement is outside the film')
|
||||
if is_sheet and (placement.x_cm or auto_rotation):
|
||||
raise ValueError('Finished sheets must retain their original orientation')
|
||||
if placed != expected:
|
||||
raise ValueError('Production layout does not cover every source copy')
|
||||
if self.quality_status == 'warning' and not self.quality_acknowledged:
|
||||
raise ValueError('Resolution warning must be acknowledged')
|
||||
return self
|
||||
|
||||
class QuoteRequest(StrictModel):
|
||||
request_key: UUID
|
||||
|
||||
@@ -33,6 +33,8 @@ def approved_quote(c, quote_id, owner=None):
|
||||
raise PaymentRefused('quote not found')
|
||||
if not row['approved']:
|
||||
raise PaymentRefused('quote was never reviewed')
|
||||
if any(item.get('production', {}).get('version') != 2 for item in row['approved']['items']):
|
||||
raise PaymentRefused('quote uses an obsolete production layout; request a new quote')
|
||||
if row['approved_at'] < datetime.now(timezone.utc) - timedelta(hours=QUOTE_VALID_HOURS):
|
||||
raise PaymentRefused('quote expired before payment')
|
||||
return row
|
||||
@@ -94,7 +96,7 @@ def apply(c, event):
|
||||
# underpayment would ship artwork that was not paid for, and overpayment
|
||||
# means something is wrong that a person should look at.
|
||||
expected = quote['approved']['total_cents']
|
||||
if event.amount_cents is not None and event.amount_cents != expected:
|
||||
if type(event.amount_cents) is not int or event.amount_cents != expected:
|
||||
audit('payment_amount_mismatch', quote=str(quote_id),
|
||||
expected_cents=expected, paid_cents=event.amount_cents)
|
||||
return f'refused: paid {event.amount_cents} but quote total is {expected}'
|
||||
|
||||
@@ -58,7 +58,8 @@ def upload_row(c, upload_id, session_id, lock=False):
|
||||
def quote_view(c, row):
|
||||
order = c.execute('SELECT id,number,state FROM dtf_local.orders WHERE quote_id=%s', (row['id'],)).fetchone()
|
||||
expired = row['approved_at'] and row['approved_at'] < datetime.now(timezone.utc)-timedelta(hours=24)
|
||||
return {'id': row['id'], 'draft': row['draft'], 'approved': row['approved'],
|
||||
return {'id': row['id'], 'created_at': row['created_at'],
|
||||
'draft': row['draft'], 'approved': row['approved'],
|
||||
'status': 'paid' if order else 'expired' if expired else 'approved' if row['approved'] else 'pending_review',
|
||||
'order': order}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Local ClamAV boundary. Unknown/error/over-limit results NEVER release artwork."""
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
from fastapi import HTTPException
|
||||
from .core.auth import audit
|
||||
from .core.db import connect
|
||||
from .core.limits import scan_limit_bytes
|
||||
|
||||
def require_clean(row):
|
||||
if not row['complete'] or row['scan_state'] != 'clean':
|
||||
@@ -30,7 +30,7 @@ class ClamAV:
|
||||
return self.command(b'VERSION').decode('utf-8','replace')
|
||||
|
||||
def scan(self, stream, size):
|
||||
if size > min(134217728, int(os.environ.get('SCAN_MAX_BYTES','134217728'))):
|
||||
if size > scan_limit_bytes():
|
||||
return 'rejected', 'File exceeds the malware scan limit'
|
||||
with socket.create_connection(('scanner',3310),timeout=10) as sock:
|
||||
sock.settimeout(150)
|
||||
|
||||
Reference in New Issue
Block a user