326 lines
18 KiB
Python
326 lines
18 KiB
Python
|
|
"""V8.5 restricted MySQL connector. Only approved tables and selected columns.
|
||
|
|
|
||
|
|
Database-side SELECT-only account and TLS are mandatory. Results are staged
|
||
|
|
through V8 import batches; no connector directly publishes or edits content.
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import re
|
||
|
|
import ssl
|
||
|
|
import uuid
|
||
|
|
from fastapi import APIRouter, Request, Body, HTTPException
|
||
|
|
from . import db, enterprise, jobs, secret_refs, v8_workflows, redis_signal
|
||
|
|
|
||
|
|
router=APIRouter(prefix='/api/connectors/sites',tags=['V8.5 read-only MySQL'])
|
||
|
|
IDENT=re.compile(r'^[A-Za-z_][A-Za-z0-9_]{0,63}$')
|
||
|
|
HOST=re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}$')
|
||
|
|
ALLOWED_TYPES={'product','vehicle','post','page','brand','case','service','other'}
|
||
|
|
|
||
|
|
|
||
|
|
def setup():
|
||
|
|
with db.connect() as c:
|
||
|
|
c.executescript('''
|
||
|
|
CREATE TABLE IF NOT EXISTS v85_mysql_sources (
|
||
|
|
id TEXT PRIMARY KEY,site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
||
|
|
host TEXT NOT NULL,port INTEGER NOT NULL,db_name TEXT NOT NULL,
|
||
|
|
table_name TEXT NOT NULL,key_column TEXT NOT NULL,columns_json TEXT NOT NULL,
|
||
|
|
user_ref TEXT NOT NULL,password_ref TEXT NOT NULL,
|
||
|
|
mapping_json TEXT NOT NULL,kind TEXT NOT NULL,page_size INTEGER NOT NULL,
|
||
|
|
max_pages INTEGER NOT NULL,next_page INTEGER NOT NULL DEFAULT 1,
|
||
|
|
cursor_json TEXT NOT NULL DEFAULT 'null',scanned_pages INTEGER NOT NULL DEFAULT 0,
|
||
|
|
staged_count INTEGER NOT NULL DEFAULT 0,state TEXT NOT NULL DEFAULT 'ready',
|
||
|
|
created_at TEXT NOT NULL,updated_at TEXT NOT NULL,error TEXT NOT NULL DEFAULT '');
|
||
|
|
CREATE INDEX IF NOT EXISTS idx_v85_mysql_site ON v85_mysql_sources(site_id,created_at);
|
||
|
|
CREATE TABLE IF NOT EXISTS v85_mysql_pages (
|
||
|
|
source_id TEXT NOT NULL REFERENCES v85_mysql_sources(id) ON DELETE CASCADE,
|
||
|
|
page INTEGER NOT NULL,batch_id TEXT REFERENCES v8_import_batches(id),
|
||
|
|
input_count INTEGER NOT NULL,last_cursor_json TEXT NOT NULL,
|
||
|
|
created_at TEXT NOT NULL,PRIMARY KEY(source_id,page));
|
||
|
|
''')
|
||
|
|
|
||
|
|
|
||
|
|
def _approved_env(name):
|
||
|
|
return {v.strip().lower() for v in os.environ.get(name,'').split(',') if v.strip()}
|
||
|
|
|
||
|
|
|
||
|
|
def _identifier(value):
|
||
|
|
if not isinstance(value,str) or not IDENT.fullmatch(value):
|
||
|
|
raise ValueError('数据库/表/列标识符不合法')
|
||
|
|
return value
|
||
|
|
|
||
|
|
|
||
|
|
def create_source(site_id,payload):
|
||
|
|
v8_workflows._require_site(site_id)
|
||
|
|
if not isinstance(payload,dict):raise ValueError('MySQL 配置必须为对象')
|
||
|
|
host=str(payload.get('host','')).lower().rstrip('.')
|
||
|
|
if not HOST.fullmatch(host) or host not in _approved_env('SITEFORGE_MYSQL_ALLOWED_HOSTS'):
|
||
|
|
raise ValueError('MySQL 主机未获管理员许可')
|
||
|
|
port=payload.get('port',3306)
|
||
|
|
if type(port)!=int or not 1<=port<=65535:raise ValueError('数据库端口不合法')
|
||
|
|
db_name=_identifier(payload.get('database'))
|
||
|
|
table=_identifier(payload.get('table'))
|
||
|
|
approved=_approved_env('SITEFORGE_MYSQL_ALLOWED_TABLES')
|
||
|
|
if f'{db_name}.{table}'.lower() not in approved:
|
||
|
|
raise ValueError('MySQL 表未被管理员加入白名单')
|
||
|
|
key=_identifier(payload.get('key_column'))
|
||
|
|
columns=payload.get('columns')
|
||
|
|
if not isinstance(columns,list) or not 1<=len(columns)<=60 or any(not isinstance(x,str) for x in columns):
|
||
|
|
raise ValueError('MySQL 选择列必须为 1-60 个标识符')
|
||
|
|
columns=[_identifier(x) for x in columns]
|
||
|
|
if len(columns)!=len(set(columns)) or key not in columns:
|
||
|
|
raise ValueError('唯一游标列必须属于选择列,且列不得重复')
|
||
|
|
kind=payload.get('content_type','product')
|
||
|
|
if kind not in ALLOWED_TYPES:raise ValueError('不支持的内容类型')
|
||
|
|
size=payload.get('page_size',100);limit=payload.get('max_pages',20)
|
||
|
|
if type(size)!=int or type(limit)!=int or not 1<=size<=250 or not 1<=limit<=50 or size*limit>5000:
|
||
|
|
raise ValueError('每次导入最多 5000 条、50 页、单页 250 条')
|
||
|
|
mapping=payload.get('mapping') or {}
|
||
|
|
if not isinstance(mapping,dict) or len(mapping)>80 or any(not isinstance(k,str) or not isinstance(v,str) or v not in columns for k,v in mapping.items()):
|
||
|
|
raise ValueError('字段映射必须引用已经批准的列')
|
||
|
|
user_ref=secret_refs.validate_reference(payload.get('user_ref'))
|
||
|
|
password_ref=secret_refs.validate_reference(payload.get('password_ref'))
|
||
|
|
source_id=uuid.uuid4().hex
|
||
|
|
db.insert('''INSERT INTO v85_mysql_sources(id,site_id,host,port,db_name,table_name,key_column,
|
||
|
|
columns_json,user_ref,password_ref,mapping_json,kind,page_size,max_pages,created_at,updated_at)
|
||
|
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',
|
||
|
|
(source_id,site_id,host,port,db_name,table,key,json.dumps(columns),user_ref,password_ref,
|
||
|
|
json.dumps(mapping,ensure_ascii=False),kind,size,limit,db.now(),db.now()))
|
||
|
|
return status(site_id,source_id)
|
||
|
|
|
||
|
|
|
||
|
|
def _one(site_id,source_id):
|
||
|
|
row=db.one('SELECT * FROM v85_mysql_sources WHERE id=? AND site_id=?',(source_id,site_id))
|
||
|
|
if row is None:raise HTTPException(404,'MySQL 数据源不存在')
|
||
|
|
return row
|
||
|
|
|
||
|
|
|
||
|
|
def status(site_id,source_id):
|
||
|
|
row=_one(site_id,source_id)
|
||
|
|
# Neither secret reference nor credential is emitted to browsers or logs.
|
||
|
|
safe={k:row[k] for k in ('id','site_id','host','db_name','table_name','kind','state',
|
||
|
|
'next_page','scanned_pages','staged_count','max_pages','page_size','error')}
|
||
|
|
safe['pages']=db.rows('SELECT page,batch_id,input_count FROM v85_mysql_pages WHERE source_id=? ORDER BY page',(source_id,))
|
||
|
|
return safe
|
||
|
|
|
||
|
|
|
||
|
|
def relationship_preview(site_id,batch_id):
|
||
|
|
"""Read-only assessment of brand/category references before batch commit."""
|
||
|
|
row=db.one('SELECT id,state FROM v8_import_batches WHERE id=? AND site_id=?',(batch_id,site_id))
|
||
|
|
if not row:raise HTTPException(404,'导入批次不存在')
|
||
|
|
data=db.rows('SELECT route,content_json FROM v8_import_items WHERE batch_id=? ORDER BY row_number LIMIT 1001',(batch_id,))
|
||
|
|
if len(data)>1000:raise ValueError('批次超过预览上限')
|
||
|
|
existing_brands={r['title'].strip().casefold() for r in db.rows("SELECT title FROM entries WHERE site_id=? AND type='brand'",(site_id,))}
|
||
|
|
batch_brands=set();brands={};categories={};routes={};missing=[]
|
||
|
|
for record in data:
|
||
|
|
item=json.loads(record['content_json'])
|
||
|
|
if item.get('type')=='brand':batch_brands.add(str(item.get('title','')).strip().casefold())
|
||
|
|
for record in data:
|
||
|
|
item=json.loads(record['content_json']);meta=item.get('meta') or {};fields=meta.get('fields') or {}
|
||
|
|
brand=str(fields.get('brand') or '').strip();category=str(fields.get('category') or '').strip()
|
||
|
|
if brand:brands[brand]=brands.get(brand,0)+1
|
||
|
|
if category:categories[category]=categories.get(category,0)+1
|
||
|
|
route=item.get('route','');routes[route]=routes.get(route,0)+1
|
||
|
|
if brand and brand.casefold() not in existing_brands|batch_brands:
|
||
|
|
missing.append({'route':route,'brand':brand,'reason':'未找到已存在或本批次品牌页面'})
|
||
|
|
return {'batch_id':batch_id,'state':row['state'],'items_checked':len(data),
|
||
|
|
'brands':brands,'categories':categories,'unlinked_brand_references':missing[:100],
|
||
|
|
'unlinked_brand_total':len(missing),
|
||
|
|
'duplicate_routes':[k for k,v in routes.items() if v>1],
|
||
|
|
'note':'关系预览只提示需要核对的品牌页面,不自动创建或修改记录。'}
|
||
|
|
|
||
|
|
|
||
|
|
def query_for(source,last_cursor):
|
||
|
|
"""Identifier allowlisting occurs at source creation; no arbitrary SQL input."""
|
||
|
|
key=_identifier(source['key_column'])
|
||
|
|
cols=json.loads(source['columns_json'])
|
||
|
|
col_sql=', '.join('`'+_identifier(x)+'`' for x in cols)
|
||
|
|
table='`'+_identifier(source['db_name'])+'`.`'+_identifier(source['table_name'])+'`'
|
||
|
|
where=(' WHERE `'+key+'` > %s') if last_cursor is not None else ''
|
||
|
|
sql='SELECT '+col_sql+' FROM '+table+where+' ORDER BY `'+key+'` ASC LIMIT %s'
|
||
|
|
return sql,((last_cursor,int(source['page_size'])) if last_cursor is not None else (int(source['page_size']),))
|
||
|
|
|
||
|
|
|
||
|
|
def _fetch_page(source,last_cursor):
|
||
|
|
# Approved host/table must remain approved when the Worker executes a job.
|
||
|
|
if source['host'] not in _approved_env('SITEFORGE_MYSQL_ALLOWED_HOSTS') or (
|
||
|
|
source['db_name']+'.'+source['table_name']).lower() not in _approved_env('SITEFORGE_MYSQL_ALLOWED_TABLES'):
|
||
|
|
raise ValueError('MySQL 主机或表已经失去授权')
|
||
|
|
try:
|
||
|
|
import pymysql
|
||
|
|
except ImportError as exc:
|
||
|
|
raise RuntimeError('MySQL 驱动缺失,请安装 requirements.lock') from exc
|
||
|
|
username=secret_refs.resolve(source['user_ref'])
|
||
|
|
password=secret_refs.resolve(source['password_ref'])
|
||
|
|
# Always verify TLS server certificate and hostname, including Docker use.
|
||
|
|
ca=os.environ.get('SITEFORGE_MYSQL_CA_FILE','')
|
||
|
|
ctx=ssl.create_default_context(cafile=ca or None)
|
||
|
|
ctx.verify_mode=ssl.CERT_REQUIRED
|
||
|
|
ctx.check_hostname=True
|
||
|
|
sql,params=query_for(source,last_cursor)
|
||
|
|
conn=None
|
||
|
|
try:
|
||
|
|
conn=pymysql.connect(host=source['host'],port=int(source['port']),user=username,
|
||
|
|
password=password,database=source['db_name'],ssl=ctx,ssl_verify_cert=True,
|
||
|
|
ssl_verify_identity=True,connect_timeout=8,read_timeout=20,write_timeout=8,
|
||
|
|
autocommit=True,cursorclass=pymysql.cursors.DictCursor)
|
||
|
|
with conn.cursor() as cur:
|
||
|
|
cur.execute('START TRANSACTION READ ONLY')
|
||
|
|
cur.execute(sql,params)
|
||
|
|
rows=cur.fetchall()
|
||
|
|
return rows
|
||
|
|
except Exception as exc:
|
||
|
|
# Never surface driver exception text because it can contain credentials.
|
||
|
|
raise ConnectionError('MySQL 安全连接或只读查询失败,请检查 Worker 服务端日志') from exc
|
||
|
|
finally:
|
||
|
|
if conn is not None:
|
||
|
|
try:conn.rollback()
|
||
|
|
finally:conn.close()
|
||
|
|
|
||
|
|
|
||
|
|
def _safe_data(rows,columns):
|
||
|
|
if not isinstance(rows,(list,tuple)) or len(rows)>250:raise ValueError('MySQL 每页记录超限')
|
||
|
|
cleaned=[]
|
||
|
|
for item in rows:
|
||
|
|
if not isinstance(item,dict):raise ValueError('MySQL 应返回字典格式记录')
|
||
|
|
record={}
|
||
|
|
for col in columns:
|
||
|
|
value=item.get(col)
|
||
|
|
if isinstance(value,(dict,list,tuple)):value=json.dumps(value,ensure_ascii=False,default=str)
|
||
|
|
if isinstance(value,bytes):value=value.decode('utf-8',errors='replace')
|
||
|
|
if value is not None and len(str(value))>20000:raise ValueError('字段超出 20000 字符限制')
|
||
|
|
record[col]=None if value is None else str(value)
|
||
|
|
cleaned.append(record)
|
||
|
|
return cleaned
|
||
|
|
|
||
|
|
|
||
|
|
def scan_page(site_id,source_id):
|
||
|
|
src=_one(site_id,source_id)
|
||
|
|
if src['state'] in ('cancelled','completed','limit_reached'):
|
||
|
|
return {'state':src['state'],'next_page':src['next_page']}
|
||
|
|
page=int(src['next_page'])
|
||
|
|
if page>src['max_pages']:
|
||
|
|
db.execute("UPDATE v85_mysql_sources SET state='limit_reached' WHERE id=?",(source_id,))
|
||
|
|
return {'state':'limit_reached'}
|
||
|
|
existing=db.one('SELECT * FROM v85_mysql_pages WHERE source_id=? AND page=?',(source_id,page))
|
||
|
|
origin=f'mysql:{source_id}:page:{page}'
|
||
|
|
if existing:
|
||
|
|
count=existing['input_count'];end_cursor=json.loads(existing['last_cursor_json']);batch_id=existing['batch_id']
|
||
|
|
else:
|
||
|
|
prior=db.one('SELECT id,input_count FROM v8_import_batches WHERE site_id=? AND origin=?',(site_id,origin))
|
||
|
|
if prior:
|
||
|
|
# Only records staged for this exact source+page can be recovered.
|
||
|
|
batch_id=prior['id'];count=prior['input_count']
|
||
|
|
last=db.one('''SELECT source_key FROM v8_import_items WHERE batch_id=? ORDER BY row_number DESC LIMIT 1''',(batch_id,))
|
||
|
|
if not last:raise ValueError('导入检查点缺少最后来源主键')
|
||
|
|
end_cursor=json.loads(last['source_key'])
|
||
|
|
else:
|
||
|
|
rows=_fetch_page(src,json.loads(src['cursor_json']))
|
||
|
|
cols=json.loads(src['columns_json'])
|
||
|
|
clean=_safe_data(rows,cols)
|
||
|
|
count=len(clean);batch_id=None;end_cursor=json.loads(src['cursor_json'])
|
||
|
|
if count:
|
||
|
|
keys=[record.get(src['key_column']) for record in clean]
|
||
|
|
if any(x is None or x=='' for x in keys) or len(set(keys))!=len(keys):
|
||
|
|
raise ValueError('MySQL 游标列存在空值或重复值;请确保数据库具有唯一索引')
|
||
|
|
if end_cursor is not None and str(keys[0])<=str(end_cursor):
|
||
|
|
# Database ORDER BY uses its native type. Numeric keys may be
|
||
|
|
# converted to strings, so compare only for exact equality.
|
||
|
|
if str(keys[0])==str(end_cursor):raise ValueError('游标未向前推进')
|
||
|
|
end_cursor=keys[-1]
|
||
|
|
mapping=json.loads(src['mapping_json']) or None
|
||
|
|
staged=v8_workflows.stage(site_id,clean,mapping,src['kind'],origin,
|
||
|
|
[json.dumps(k,ensure_ascii=False) for k in keys])
|
||
|
|
batch_id=staged['batch_id']
|
||
|
|
with db.connect() as c:
|
||
|
|
c.execute('BEGIN IMMEDIATE')
|
||
|
|
c.execute('''INSERT INTO v85_mysql_pages(source_id,page,batch_id,input_count,last_cursor_json,created_at)
|
||
|
|
VALUES(?,?,?,?,?,?) ON CONFLICT(source_id,page) DO NOTHING''',
|
||
|
|
(source_id,page,batch_id,count,json.dumps(end_cursor,ensure_ascii=False),db.now()))
|
||
|
|
final='completed' if count<src['page_size'] else ('limit_reached' if page>=src['max_pages'] else 'ready')
|
||
|
|
with db.connect() as c:
|
||
|
|
c.execute('BEGIN IMMEDIATE')
|
||
|
|
current=c.execute('SELECT state,next_page FROM v85_mysql_sources WHERE id=? AND site_id=?',(source_id,site_id)).fetchone()
|
||
|
|
if current and current['state']=='cancelled':return {'state':'cancelled','page':page}
|
||
|
|
if current and current['next_page']==page:
|
||
|
|
c.execute('''UPDATE v85_mysql_sources SET state=?,next_page=?,cursor_json=?,
|
||
|
|
scanned_pages=scanned_pages+1,staged_count=staged_count+?,updated_at=? WHERE id=?''',
|
||
|
|
(final,page+1,json.dumps(end_cursor,ensure_ascii=False),count,db.now(),source_id))
|
||
|
|
return {'page':page,'state':final,'count':count,'next_page':page+1,'batch_id':batch_id}
|
||
|
|
|
||
|
|
|
||
|
|
def run_scan(site_id,source_id,job_id=None):
|
||
|
|
while True:
|
||
|
|
item=scan_page(site_id,source_id)
|
||
|
|
if item['state'] in ('completed','limit_reached','cancelled'):break
|
||
|
|
return status(site_id,source_id)
|
||
|
|
|
||
|
|
|
||
|
|
def submit_scan(site_id,source_id,actor,key=None):
|
||
|
|
src=_one(site_id,source_id)
|
||
|
|
if src['state']=='cancelled':raise ValueError('数据源已取消')
|
||
|
|
if key is not None and not (8<=len(key)<=128 and all(c.isalnum() or c in '_-:.' for c in key)):
|
||
|
|
raise ValueError('幂等键不合法')
|
||
|
|
payload=json.dumps({'source_id':source_id},sort_keys=True,separators=(',',':'))
|
||
|
|
with db.connect() as c:
|
||
|
|
c.execute('BEGIN IMMEDIATE')
|
||
|
|
if key:
|
||
|
|
prior=c.execute('SELECT id,state,kind,payload_json FROM build_jobs WHERE site_id=? AND idempotency_key=?',(site_id,key)).fetchone()
|
||
|
|
if prior:
|
||
|
|
if prior['kind']!='mysql_scan' or prior['payload_json']!=payload:raise ValueError('幂等键已用于其他任务')
|
||
|
|
return {'id':prior['id'],'state':prior['state'],'existing':True}
|
||
|
|
prior=c.execute("SELECT id,state FROM build_jobs WHERE site_id=? AND kind='mysql_scan' AND payload_json=? AND state IN ('queued','running','retrying')",(site_id,payload)).fetchone()
|
||
|
|
if prior:return {'id':prior['id'],'state':prior['state'],'existing':True}
|
||
|
|
if src['state'] in ('completed','limit_reached'):
|
||
|
|
return {'state':src['state'],'existing':True,'source_id':source_id}
|
||
|
|
active="state IN ('queued','running','retrying')"
|
||
|
|
if c.execute(f'SELECT COUNT(*) FROM build_jobs WHERE {active}').fetchone()[0]>=jobs._limit('SITEFORGE_MAX_PENDING_JOBS',100):
|
||
|
|
raise jobs.JobCapacityError('全局任务容量已满')
|
||
|
|
if c.execute(f'SELECT COUNT(*) FROM build_jobs WHERE site_id=? AND {active}',(site_id,)).fetchone()[0]>=jobs._limit('SITEFORGE_MAX_PENDING_JOBS_PER_SITE',5):
|
||
|
|
raise jobs.JobCapacityError('当前站点任务容量已满')
|
||
|
|
jid=uuid.uuid4().hex[:16]
|
||
|
|
c.execute('''INSERT INTO build_jobs(id,site_id,kind,state,created_by,created_at,payload_json,
|
||
|
|
idempotency_key,max_attempts,timeout_seconds,available_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)''',
|
||
|
|
(jid,site_id,'mysql_scan','queued',actor,db.now(),payload,key,3,900,db.now()))
|
||
|
|
redis_signal.notify()
|
||
|
|
return {'id':jid,'state':'queued','existing':False}
|
||
|
|
|
||
|
|
|
||
|
|
@router.post('/{site_id}/mysql-sources',status_code=201)
|
||
|
|
def api_create(request:Request,site_id:str,payload:dict=Body(...)):
|
||
|
|
enterprise.require(request,site_id,'owner')
|
||
|
|
try:return create_source(site_id,payload)
|
||
|
|
except ValueError as exc:raise HTTPException(400,str(exc))
|
||
|
|
|
||
|
|
@router.get('/{site_id}/mysql-sources')
|
||
|
|
def api_list(request:Request,site_id:str):
|
||
|
|
enterprise.require(request,site_id,'viewer')
|
||
|
|
v8_workflows._require_site(site_id)
|
||
|
|
return {'items':db.rows('SELECT id,host,db_name,table_name,state,staged_count FROM v85_mysql_sources WHERE site_id=? ORDER BY created_at DESC LIMIT 100',(site_id,))}
|
||
|
|
|
||
|
|
@router.get('/{site_id}/mysql-sources/{source_id}')
|
||
|
|
def api_status(request:Request,site_id:str,source_id:str):
|
||
|
|
enterprise.require(request,site_id,'viewer')
|
||
|
|
return status(site_id,source_id)
|
||
|
|
|
||
|
|
@router.get('/{site_id}/import-batches/{batch_id}/relationships')
|
||
|
|
def api_relationships(request:Request,site_id:str,batch_id:str):
|
||
|
|
enterprise.require(request,site_id,'viewer')
|
||
|
|
return relationship_preview(site_id,batch_id)
|
||
|
|
|
||
|
|
@router.post('/{site_id}/mysql-sources/{source_id}/scan',status_code=202)
|
||
|
|
def api_scan(request:Request,site_id:str,source_id:str,payload:dict=Body(default_factory=dict)):
|
||
|
|
enterprise.require(request,site_id,'editor')
|
||
|
|
try:return submit_scan(site_id,source_id,request.state.actor,payload.get('idempotency_key'))
|
||
|
|
except (ValueError,jobs.JobCapacityError) as exc:raise HTTPException(409,str(exc))
|
||
|
|
|
||
|
|
@router.post('/{site_id}/mysql-sources/{source_id}/cancel')
|
||
|
|
def api_cancel(request:Request,site_id:str,source_id:str):
|
||
|
|
enterprise.require(request,site_id,'editor')
|
||
|
|
src=_one(site_id,source_id)
|
||
|
|
if src['state']!='completed':
|
||
|
|
db.execute("UPDATE v85_mysql_sources SET state='cancelled',updated_at=? WHERE id=?",(db.now(),source_id))
|
||
|
|
for j in db.rows("SELECT id,payload_json FROM build_jobs WHERE site_id=? AND kind='mysql_scan' AND state IN ('queued','running','retrying')",(site_id,)):
|
||
|
|
if json.loads(j['payload_json']).get('source_id')==source_id:jobs.cancel(site_id,j['id'])
|
||
|
|
return status(site_id,source_id)
|