修复设计计算错误并优化分享
This commit is contained in:
parent
dd21623cb5
commit
30cdd05914
5 changed files with 916 additions and 28 deletions
539
service/account.py
Normal file
539
service/account.py
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
import zlib
|
||||
from http.cookies import SimpleCookie
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
def _json_blob(value):
|
||||
if value is None:
|
||||
return None
|
||||
raw = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
return sqlite3.Binary(zlib.compress(raw, level=6))
|
||||
|
||||
|
||||
def _blob_json(value):
|
||||
if value is None:
|
||||
return None
|
||||
return json.loads(zlib.decompress(value).decode("utf-8"))
|
||||
|
||||
|
||||
def _payload_summary(payload):
|
||||
payload = payload or {}
|
||||
workflow = payload.get("workflow", "analysis")
|
||||
mode = payload.get("mode", "tube")
|
||||
model = payload.get("model") or {}
|
||||
strands = payload.get("strands") or []
|
||||
if workflow == "design":
|
||||
complexes = payload.get("design_complexes") or payload.get("design_targets") or []
|
||||
tube_sizes = [int(row.get("max_size", 0) or 0) for row in (payload.get("design_tubes") or [])]
|
||||
max_size = max(tube_sizes, default=int((payload.get("design") or {}).get("off_target_max_size", 0) or 0))
|
||||
else:
|
||||
complexes = [line for line in str(payload.get("complexes_text") or "").splitlines() if line.strip()]
|
||||
max_size = int((payload.get("tube") or {}).get("max_size", 0) or 0)
|
||||
return {
|
||||
"workflow": workflow,
|
||||
"mode": mode,
|
||||
"material": model.get("material", "rna"),
|
||||
"celsius": float(model.get("celsius", 37) or 37),
|
||||
"sodium": float(model.get("sodium", 0) or 0),
|
||||
"magnesium": float(model.get("magnesium", 0) or 0),
|
||||
"max_size": max_size,
|
||||
"strand_count": len(strands),
|
||||
"complex_count": len(complexes),
|
||||
"compute": list(payload.get("compute") or (["design"] if workflow == "design" else [])),
|
||||
"trials": int((payload.get("design") or {}).get("trials", 0) or 0),
|
||||
"stop_condition": float((payload.get("design") or {}).get("stop_condition", 0) or 0),
|
||||
"max_time_seconds": int((payload.get("design") or {}).get("max_time_seconds", 0) or 0),
|
||||
}
|
||||
|
||||
|
||||
class AccountStore:
|
||||
def __init__(self, path):
|
||||
self.path = Path(path)
|
||||
self._init_lock = threading.Lock()
|
||||
self._initialized = False
|
||||
|
||||
def _connect(self):
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=30)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA busy_timeout = 30000")
|
||||
connection.execute("PRAGMA foreign_keys = ON")
|
||||
return connection
|
||||
|
||||
def initialize(self):
|
||||
with self._init_lock:
|
||||
if self._initialized:
|
||||
return
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA journal_mode = WAL")
|
||||
connection.execute("PRAGMA synchronous = NORMAL")
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
email TEXT,
|
||||
display_name TEXT,
|
||||
groups_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at REAL NOT NULL,
|
||||
last_seen_at REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
job_id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'job',
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL,
|
||||
elapsed_seconds REAL,
|
||||
workflow TEXT NOT NULL,
|
||||
mode TEXT NOT NULL,
|
||||
material TEXT NOT NULL,
|
||||
celsius REAL NOT NULL,
|
||||
sodium REAL NOT NULL,
|
||||
magnesium REAL NOT NULL,
|
||||
max_size INTEGER NOT NULL DEFAULT 0,
|
||||
strand_count INTEGER NOT NULL DEFAULT 0,
|
||||
complex_count INTEGER NOT NULL DEFAULT 0,
|
||||
compute_json TEXT NOT NULL DEFAULT '[]',
|
||||
trials INTEGER NOT NULL DEFAULT 0,
|
||||
stop_condition REAL NOT NULL DEFAULT 0,
|
||||
max_time_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
payload_blob BLOB NOT NULL,
|
||||
result_blob BLOB,
|
||||
error_blob BLOB,
|
||||
stored_bytes INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS jobs_user_created_idx ON jobs(user_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS jobs_user_status_idx ON jobs(user_id, status, created_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS shares (
|
||||
share_id TEXT PRIMARY KEY,
|
||||
job_id TEXT NOT NULL REFERENCES jobs(job_id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at REAL NOT NULL,
|
||||
expires_at REAL,
|
||||
last_access_at REAL,
|
||||
access_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS shares_user_created_idx ON shares(user_id, created_at DESC);
|
||||
"""
|
||||
)
|
||||
self._initialized = True
|
||||
|
||||
def upsert_user(self, user):
|
||||
self.initialize()
|
||||
now = time.time()
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO users(user_id, username, email, display_name, groups_json, created_at, last_seen_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
username=excluded.username,
|
||||
email=excluded.email,
|
||||
display_name=excluded.display_name,
|
||||
groups_json=excluded.groups_json,
|
||||
last_seen_at=excluded.last_seen_at
|
||||
""",
|
||||
(
|
||||
user["user_id"], user.get("username") or user["user_id"], user.get("email"),
|
||||
user.get("display_name"), json.dumps(user.get("groups") or [], ensure_ascii=False), now, now,
|
||||
),
|
||||
)
|
||||
|
||||
def create_job(self, job_id, user, payload, status="queued", source="job", created_at=None, result=None, error=None):
|
||||
self.upsert_user(user)
|
||||
created_at = float(created_at or time.time())
|
||||
summary = _payload_summary(payload)
|
||||
payload_blob = _json_blob(payload)
|
||||
result_blob = _json_blob(result)
|
||||
error_blob = _json_blob(error)
|
||||
stored_bytes = sum(len(item) for item in (payload_blob, result_blob, error_blob) if item is not None)
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO jobs(
|
||||
job_id, user_id, status, source, created_at, updated_at, workflow, mode, material,
|
||||
celsius, sodium, magnesium, max_size, strand_count, complex_count, compute_json,
|
||||
trials, stop_condition, max_time_seconds, payload_blob, result_blob, error_blob, stored_bytes
|
||||
) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
job_id, user["user_id"], status, source, created_at, created_at,
|
||||
summary["workflow"], summary["mode"], summary["material"], summary["celsius"],
|
||||
summary["sodium"], summary["magnesium"], summary["max_size"], summary["strand_count"],
|
||||
summary["complex_count"], json.dumps(summary["compute"]), summary["trials"],
|
||||
summary["stop_condition"], summary["max_time_seconds"], payload_blob, result_blob,
|
||||
error_blob, stored_bytes,
|
||||
),
|
||||
)
|
||||
|
||||
def update_job(self, job_id, status, *, result=None, error=None, elapsed_seconds=None):
|
||||
self.initialize()
|
||||
fields = ["status = ?", "updated_at = ?"]
|
||||
values = [status, time.time()]
|
||||
if result is not None:
|
||||
fields.append("result_blob = ?")
|
||||
values.append(_json_blob(result))
|
||||
if error is not None:
|
||||
fields.append("error_blob = ?")
|
||||
values.append(_json_blob(error))
|
||||
if elapsed_seconds is not None:
|
||||
fields.append("elapsed_seconds = ?")
|
||||
values.append(float(elapsed_seconds))
|
||||
values.append(job_id)
|
||||
with self._connect() as connection:
|
||||
connection.execute(f"UPDATE jobs SET {', '.join(fields)} WHERE job_id = ?", values)
|
||||
connection.execute(
|
||||
"UPDATE jobs SET stored_bytes = length(payload_blob) + coalesce(length(result_blob), 0) + coalesce(length(error_blob), 0) WHERE job_id = ?",
|
||||
(job_id,),
|
||||
)
|
||||
|
||||
def owner_id(self, job_id):
|
||||
self.initialize()
|
||||
with self._connect() as connection:
|
||||
row = connection.execute("SELECT user_id FROM jobs WHERE job_id = ?", (job_id,)).fetchone()
|
||||
return row["user_id"] if row else None
|
||||
|
||||
def get_job(self, user_id, job_id, include_content=True):
|
||||
self.initialize()
|
||||
columns = "*" if include_content else "job_id,user_id,status,source,created_at,updated_at,elapsed_seconds,workflow,mode,material,celsius,sodium,magnesium,max_size,strand_count,complex_count,compute_json,trials,stop_condition,max_time_seconds,stored_bytes"
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(f"SELECT {columns} FROM jobs WHERE job_id = ? AND user_id = ?", (job_id, user_id)).fetchone()
|
||||
return self._job_row(row, include_content=include_content) if row else None
|
||||
|
||||
def list_jobs(self, user_id, filters=None):
|
||||
self.initialize()
|
||||
filters = filters or {}
|
||||
limit = min(100, max(1, int(filters.get("limit", 30))))
|
||||
offset = max(0, int(filters.get("offset", 0)))
|
||||
clauses = ["user_id = ?"]
|
||||
values = [user_id]
|
||||
for field in ("status", "workflow", "mode", "material"):
|
||||
value = str(filters.get(field) or "").strip()
|
||||
if value:
|
||||
clauses.append(f"{field} = ?")
|
||||
values.append(value)
|
||||
search = str(filters.get("q") or "").strip()
|
||||
if search:
|
||||
clauses.append("(job_id LIKE ? OR workflow LIKE ? OR mode LIKE ? OR material LIKE ?)")
|
||||
values.extend([f"%{search}%"] * 4)
|
||||
where = " AND ".join(clauses)
|
||||
columns = "job_id,user_id,status,source,created_at,updated_at,elapsed_seconds,workflow,mode,material,celsius,sodium,magnesium,max_size,strand_count,complex_count,compute_json,trials,stop_condition,max_time_seconds,stored_bytes"
|
||||
with self._connect() as connection:
|
||||
total = connection.execute(f"SELECT count(*) AS count FROM jobs WHERE {where}", values).fetchone()["count"]
|
||||
rows = connection.execute(
|
||||
f"SELECT {columns} FROM jobs WHERE {where} ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
[*values, limit, offset],
|
||||
).fetchall()
|
||||
return {"items": [self._job_row(row, include_content=False) for row in rows], "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
def usage(self, user_id):
|
||||
self.initialize()
|
||||
with self._connect() as connection:
|
||||
totals = connection.execute(
|
||||
"""SELECT count(*) AS total_jobs,
|
||||
coalesce(sum(CASE WHEN status='success' THEN 1 ELSE 0 END),0) AS success_jobs,
|
||||
coalesce(sum(CASE WHEN status='error' THEN 1 ELSE 0 END),0) AS error_jobs,
|
||||
coalesce(sum(CASE WHEN status IN ('queued','running','cancel_requested') THEN 1 ELSE 0 END),0) AS active_jobs,
|
||||
coalesce(sum(elapsed_seconds),0) AS compute_seconds,
|
||||
coalesce(sum(stored_bytes),0) AS stored_bytes FROM jobs WHERE user_id=?""",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
shares = connection.execute("SELECT count(*) AS count FROM shares WHERE user_id=? AND active=1", (user_id,)).fetchone()["count"]
|
||||
result = dict(totals)
|
||||
result["active_shares"] = shares
|
||||
return result
|
||||
|
||||
def import_history(self, user, entries):
|
||||
imported = 0
|
||||
for entry in entries:
|
||||
payload = entry.get("payload") if isinstance(entry, dict) else None
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
result = entry.get("result") if isinstance(entry.get("result"), dict) else None
|
||||
raw_id = str(entry.get("id") or entry.get("job_id") or uuid_token())
|
||||
job_id = f"import-{hashlib.sha256((user['user_id'] + ':' + raw_id).encode()).hexdigest()[:24]}"
|
||||
created = entry.get("created_at") or entry.get("created_at_iso")
|
||||
try:
|
||||
created_at = float(created)
|
||||
except (TypeError, ValueError):
|
||||
try:
|
||||
created_at = time.mktime(time.strptime(str(created).split(".")[0].replace("Z", ""), "%Y-%m-%dT%H:%M:%S"))
|
||||
except (TypeError, ValueError):
|
||||
created_at = time.time()
|
||||
before = self.get_job(user["user_id"], job_id, include_content=False)
|
||||
self.create_job(job_id, user, payload, status="success" if result else "input_only", source="import", created_at=created_at, result=result)
|
||||
if before is None:
|
||||
imported += 1
|
||||
return imported
|
||||
|
||||
def delete_job(self, user_id, job_id):
|
||||
self.initialize()
|
||||
with self._connect() as connection:
|
||||
row = connection.execute("SELECT status FROM jobs WHERE job_id=? AND user_id=?", (job_id, user_id)).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
if row["status"] in {"queued", "running", "cancel_requested"}:
|
||||
raise ValueError("Active jobs cannot be deleted.")
|
||||
connection.execute("DELETE FROM jobs WHERE job_id=? AND user_id=?", (job_id, user_id))
|
||||
return True
|
||||
|
||||
def create_share(self, user_id, job_id, expires_in=None):
|
||||
job = self.get_job(user_id, job_id, include_content=False)
|
||||
if job is None:
|
||||
raise ValueError("Job not found.")
|
||||
now = time.time()
|
||||
expires_at = now + int(expires_in) if expires_in else None
|
||||
share_id = uuid_token(18)
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO shares(share_id,job_id,user_id,active,created_at,expires_at) VALUES(?,?,?,?,?,?)",
|
||||
(share_id, job_id, user_id, 1, now, expires_at),
|
||||
)
|
||||
return self.get_share_for_owner(user_id, share_id)
|
||||
|
||||
def share_metadata(self, share_id):
|
||||
self.initialize()
|
||||
now = time.time()
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"""SELECT s.share_id, s.job_id, s.user_id, s.created_at, s.expires_at, s.active
|
||||
FROM shares s WHERE s.share_id=? AND s.active=1 AND (s.expires_at IS NULL OR s.expires_at>?)""",
|
||||
(share_id, now),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def record_share_access(self, share_id):
|
||||
self.initialize()
|
||||
now = time.time()
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE shares SET access_count=access_count+1,last_access_at=? WHERE share_id=?",
|
||||
(now, share_id),
|
||||
)
|
||||
|
||||
def list_shares(self, user_id):
|
||||
self.initialize()
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""SELECT s.*,j.status,j.workflow,j.mode,j.material,j.created_at AS job_created_at
|
||||
FROM shares s JOIN jobs j ON j.job_id=s.job_id WHERE s.user_id=? ORDER BY s.created_at DESC""",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def get_share_for_owner(self, user_id, share_id):
|
||||
self.initialize()
|
||||
with self._connect() as connection:
|
||||
row = connection.execute("SELECT * FROM shares WHERE share_id=? AND user_id=?", (share_id, user_id)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def update_share(self, user_id, share_id, *, active=None, expires_in="unchanged"):
|
||||
share = self.get_share_for_owner(user_id, share_id)
|
||||
if share is None:
|
||||
return None
|
||||
fields = []
|
||||
values = []
|
||||
if active is not None:
|
||||
fields.append("active=?")
|
||||
values.append(1 if active else 0)
|
||||
if expires_in != "unchanged":
|
||||
fields.append("expires_at=?")
|
||||
values.append(time.time() + int(expires_in) if expires_in else None)
|
||||
if fields:
|
||||
values.extend([share_id, user_id])
|
||||
with self._connect() as connection:
|
||||
connection.execute(f"UPDATE shares SET {', '.join(fields)} WHERE share_id=? AND user_id=?", values)
|
||||
return self.get_share_for_owner(user_id, share_id)
|
||||
|
||||
def resolve_share(self, share_id, *, record_access=True):
|
||||
self.initialize()
|
||||
now = time.time()
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"""SELECT
|
||||
s.share_id, s.created_at AS share_created_at,
|
||||
j.job_id, j.user_id, j.status, j.source, j.created_at, j.updated_at,
|
||||
j.elapsed_seconds, j.workflow, j.mode, j.material, j.celsius, j.sodium,
|
||||
j.magnesium, j.max_size, j.strand_count, j.complex_count, j.compute_json,
|
||||
j.trials, j.stop_condition, j.max_time_seconds, j.payload_blob,
|
||||
j.result_blob, j.error_blob, j.stored_bytes
|
||||
FROM shares s JOIN jobs j ON j.job_id=s.job_id
|
||||
WHERE s.share_id=? AND s.active=1 AND (s.expires_at IS NULL OR s.expires_at>?)""",
|
||||
(share_id, now),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
if record_access:
|
||||
connection.execute("UPDATE shares SET access_count=access_count+1,last_access_at=? WHERE share_id=?", (now, share_id))
|
||||
job = self._job_row(row, include_content=True)
|
||||
return {
|
||||
"id": share_id,
|
||||
"share_id": share_id,
|
||||
"job_id": job["job_id"],
|
||||
"created_at": row["share_created_at"],
|
||||
"status": job["status"],
|
||||
"updated_at": job["updated_at"],
|
||||
"elapsed_seconds": job["elapsed_seconds"],
|
||||
"payload": job["payload"],
|
||||
"result": job["result"],
|
||||
"error": job["error"],
|
||||
"result_summary": job["result_summary"],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _job_row(row, include_content):
|
||||
item = dict(row)
|
||||
item["compute"] = json.loads(item.pop("compute_json") or "[]")
|
||||
item["result_summary"] = {
|
||||
key: item[key] for key in (
|
||||
"workflow", "mode", "material", "celsius", "sodium", "magnesium", "max_size",
|
||||
"strand_count", "complex_count", "compute", "trials", "stop_condition", "max_time_seconds",
|
||||
)
|
||||
}
|
||||
if include_content:
|
||||
item["payload"] = _blob_json(item.pop("payload_blob"))
|
||||
item["result"] = _blob_json(item.pop("result_blob"))
|
||||
item["error"] = _blob_json(item.pop("error_blob"))
|
||||
return item
|
||||
|
||||
|
||||
def uuid_token(size=24):
|
||||
return secrets.token_urlsafe(size)
|
||||
|
||||
|
||||
class OIDCAuth:
|
||||
def __init__(self, redis_factory):
|
||||
self.redis_factory = redis_factory
|
||||
self.issuer = os.environ.get("NP_OIDC_ISSUER", "https://auth.lihato.icu/application/o/nupack-account/").rstrip("/") + "/"
|
||||
self.client_id = os.environ.get("NP_OIDC_CLIENT_ID", "np-replica-web")
|
||||
self.redirect_uri = os.environ.get("NP_OIDC_REDIRECT_URI", "https://np.lihato.icu/auth/callback")
|
||||
self.post_logout_uri = os.environ.get("NP_OIDC_POST_LOGOUT_URI", "https://np.lihato.icu/")
|
||||
self.scopes = os.environ.get("NP_OIDC_SCOPES", "openid profile email").strip()
|
||||
self.cookie_name = os.environ.get("NP_AUTH_COOKIE_NAME", "np_session")
|
||||
self.session_ttl = int(os.environ.get("NP_AUTH_SESSION_TTL_SECONDS", str(7 * 86400)))
|
||||
self.required = os.environ.get("NP_AUTH_REQUIRED", "1") != "0"
|
||||
self._configuration = None
|
||||
self._config_lock = threading.Lock()
|
||||
|
||||
def configuration(self):
|
||||
with self._config_lock:
|
||||
if self._configuration is None:
|
||||
self._configuration = self._request_json(self.issuer + ".well-known/openid-configuration")
|
||||
return self._configuration
|
||||
|
||||
@staticmethod
|
||||
def _request_json(url, *, data=None, headers=None):
|
||||
body = urlencode(data).encode("utf-8") if data is not None else None
|
||||
request = Request(url, data=body, headers=headers or {})
|
||||
with urlopen(request, timeout=15) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
|
||||
def begin_login(self, next_path="/"):
|
||||
if not next_path.startswith("/") or next_path.startswith("//"):
|
||||
next_path = "/"
|
||||
state = uuid_token(24)
|
||||
verifier = uuid_token(48)
|
||||
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
||||
self.redis_factory().setex(
|
||||
f"np_replica:oidc_state:{state}", 600,
|
||||
json.dumps({"verifier": verifier, "next": next_path}),
|
||||
)
|
||||
config = self.configuration()
|
||||
query = urlencode({
|
||||
"client_id": self.client_id,
|
||||
"response_type": "code",
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"scope": self.scopes,
|
||||
"state": state,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
})
|
||||
return f"{config['authorization_endpoint']}?{query}"
|
||||
|
||||
def complete_login(self, params):
|
||||
state = (params.get("state") or [""])[0]
|
||||
code = (params.get("code") or [""])[0]
|
||||
if not state or not code:
|
||||
raise ValueError("OIDC callback is missing code or state.")
|
||||
key = f"np_replica:oidc_state:{state}"
|
||||
client = self.redis_factory()
|
||||
raw = client.get(key)
|
||||
client.delete(key)
|
||||
if raw is None:
|
||||
raise ValueError("OIDC login state has expired.")
|
||||
pending = json.loads(raw)
|
||||
config = self.configuration()
|
||||
tokens = self._request_json(config["token_endpoint"], data={
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": self.client_id,
|
||||
"code": code,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"code_verifier": pending["verifier"],
|
||||
}, headers={"Content-Type": "application/x-www-form-urlencoded"})
|
||||
claims = self._request_json(config["userinfo_endpoint"], headers={"Authorization": f"Bearer {tokens['access_token']}"})
|
||||
subject = str(claims.get("sub") or "").strip()
|
||||
if not subject:
|
||||
raise ValueError("OIDC userinfo did not return a subject.")
|
||||
user = {
|
||||
"user_id": subject,
|
||||
"username": claims.get("preferred_username") or claims.get("nickname") or claims.get("email") or subject,
|
||||
"email": claims.get("email"),
|
||||
"display_name": claims.get("name") or claims.get("preferred_username") or subject,
|
||||
"groups": claims.get("groups") or [],
|
||||
}
|
||||
session_id = uuid_token(32)
|
||||
client.setex(
|
||||
f"np_replica:session:{session_id}", self.session_ttl,
|
||||
json.dumps({"user": user, "id_token": tokens.get("id_token")}, ensure_ascii=False),
|
||||
)
|
||||
return session_id, user, pending.get("next") or "/"
|
||||
|
||||
def current_session(self, headers):
|
||||
if not self.required:
|
||||
user = {"user_id": "development", "username": "development", "email": None, "display_name": "Development", "groups": []}
|
||||
return {"user": user, "session_id": None}
|
||||
cookie = SimpleCookie()
|
||||
cookie.load(headers.get("Cookie", ""))
|
||||
morsel = cookie.get(self.cookie_name)
|
||||
if morsel is None:
|
||||
return None
|
||||
session_id = morsel.value
|
||||
raw = self.redis_factory().get(f"np_replica:session:{session_id}")
|
||||
if raw is None:
|
||||
return None
|
||||
session = json.loads(raw)
|
||||
session["session_id"] = session_id
|
||||
return session
|
||||
|
||||
def logout_url(self, session):
|
||||
config = self.configuration()
|
||||
endpoint = config.get("end_session_endpoint")
|
||||
if not endpoint:
|
||||
return "/"
|
||||
query = {"post_logout_redirect_uri": self.post_logout_uri}
|
||||
if session and session.get("id_token"):
|
||||
query["id_token_hint"] = session["id_token"]
|
||||
return f"{endpoint}?{urlencode(query)}"
|
||||
|
||||
def delete_session(self, session):
|
||||
if session and session.get("session_id"):
|
||||
self.redis_factory().delete(f"np_replica:session:{session['session_id']}")
|
||||
|
||||
def cookie_header(self, session_id):
|
||||
return f"{self.cookie_name}={session_id}; Path=/; Max-Age={self.session_ttl}; HttpOnly; Secure; SameSite=Lax"
|
||||
|
||||
def clear_cookie_header(self):
|
||||
return f"{self.cookie_name}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax"
|
||||
|
|
@ -1703,6 +1703,13 @@ A+B</textarea>
|
|||
<option value="true" data-i18n="design_wobble_allow"></option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="design_fixed_target_policy"></span>
|
||||
<select id="designFixedTargetPolicy">
|
||||
<option value="exclude_from_optimization" data-i18n="design_fixed_target_exclude"></option>
|
||||
<option value="include" data-i18n="design_fixed_target_include"></option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="toggle-row" id="designTabToggle">
|
||||
<button class="secondary panel-toggle active" type="button" data-design-tab="targets" data-i18n="design_tab_targets"></button>
|
||||
|
|
@ -2221,6 +2228,9 @@ A+B</textarea>
|
|||
design_wobble_label: "Wobble mutations",
|
||||
design_wobble_allow: "允许",
|
||||
design_wobble_prohibit: "禁止",
|
||||
design_fixed_target_policy: "固定目标策略",
|
||||
design_fixed_target_exclude: "剥离出优化",
|
||||
design_fixed_target_include: "参与优化",
|
||||
design_tab_targets: "Target Tubes",
|
||||
design_tab_hard: "Hard Constraints",
|
||||
design_tab_soft: "Soft Constraints",
|
||||
|
|
@ -2440,6 +2450,7 @@ A+B</textarea>
|
|||
design_ensemble_defect: "Ensemble defect",
|
||||
design_trials_used: "试验数",
|
||||
design_seed: "最佳种子",
|
||||
design_fixed_excluded: "固定目标剥离",
|
||||
design_constraint: "约束",
|
||||
design_definition: "输入定义",
|
||||
design_sequence: "设计序列",
|
||||
|
|
@ -2568,6 +2579,9 @@ A+B</textarea>
|
|||
design_wobble_label: "Wobble Mutations",
|
||||
design_wobble_allow: "Allow",
|
||||
design_wobble_prohibit: "Prohibit",
|
||||
design_fixed_target_policy: "Fixed Target Policy",
|
||||
design_fixed_target_exclude: "Exclude from Optimization",
|
||||
design_fixed_target_include: "Include in Optimization",
|
||||
design_tab_targets: "Target Tubes",
|
||||
design_tab_hard: "Hard Constraints",
|
||||
design_tab_soft: "Soft Constraints",
|
||||
|
|
@ -2787,6 +2801,7 @@ A+B</textarea>
|
|||
design_ensemble_defect: "Ensemble Defect",
|
||||
design_trials_used: "Trials",
|
||||
design_seed: "Best Seed",
|
||||
design_fixed_excluded: "Fixed Targets Excluded",
|
||||
design_constraint: "Constraint",
|
||||
design_definition: "Input Definition",
|
||||
design_sequence: "Designed Sequence",
|
||||
|
|
@ -3803,9 +3818,14 @@ A+B</textarea>
|
|||
const result = entry?.result && typeof entry.result === "object" ? entry.result : null;
|
||||
return {
|
||||
id: String(entry.id || entry.job_id || `${Date.now()}-${index}-${Math.random().toString(16).slice(2)}`),
|
||||
job_id: entry.job_id || null,
|
||||
status: entry.status || (result ? "success" : "input_only"),
|
||||
created_at: entry.created_at_iso || entry.created_at || new Date().toISOString(),
|
||||
updated_at: entry.updated_at || null,
|
||||
elapsed_seconds: entry.elapsed_seconds ?? null,
|
||||
payload,
|
||||
result,
|
||||
error: entry.error || null,
|
||||
result_summary: buildHistorySummary(payload, entry.result_summary || {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -4050,6 +4070,7 @@ A+B</textarea>
|
|||
document.getElementById("designSeed").value = payload.design?.seed ?? 0;
|
||||
document.getElementById("designWobble").value = String(payload.design?.wobble_mutations ?? false);
|
||||
document.getElementById("designMaxTimeHours").value = ((payload.design?.max_time_seconds ?? 0) / 3600);
|
||||
document.getElementById("designFixedTargetPolicy").value = payload.design?.fixed_target_policy ?? "exclude_from_optimization";
|
||||
const payloadCompute = Array.isArray(payload.compute) ? payload.compute : [];
|
||||
document.querySelectorAll(".check input").forEach((input) => {
|
||||
input.checked = payloadCompute.includes(input.value);
|
||||
|
|
@ -4094,28 +4115,50 @@ A+B</textarea>
|
|||
} else {
|
||||
lastPayload = payload;
|
||||
lastResult = null;
|
||||
renderEmptyState();
|
||||
if (["queued", "running", "cancel_requested"].includes(item.status)) {
|
||||
const label = item.status === "queued"
|
||||
? t("status_queued")
|
||||
: (item.status === "cancel_requested" ? t("status_canceling") : t("status_polling"));
|
||||
results.innerHTML = `<div class="result-card"><h3>${escapeHtml(label)}</h3><p>${t("history_job_id")}: ${escapeHtml(item.job_id || item.id || "-")}</p></div>`;
|
||||
setStatus(formatStatus(label));
|
||||
} else {
|
||||
renderEmptyState();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sharedJobIsActive(item) {
|
||||
return ["queued", "running", "cancel_requested"].includes(item?.status);
|
||||
}
|
||||
|
||||
async function fetchSharedItem(shareId, { poll = false } = {}) {
|
||||
const suffix = poll ? "?poll=1" : "";
|
||||
const { response, data } = await fetchJsonOrThrow(`/api/shares/${encodeURIComponent(shareId)}${suffix}`);
|
||||
if (!response.ok || data.status !== "success") {
|
||||
throw new Error(data?.error || `Share request failed with HTTP ${response.status}`);
|
||||
}
|
||||
const item = normalizeHistoryEntry(data.share, 0);
|
||||
if (!item) throw new Error("Shared record is invalid.");
|
||||
return item;
|
||||
}
|
||||
|
||||
async function loadSharedHistoryFromUrl() {
|
||||
const pathMatch = window.location.pathname.match(/^\/share\/([A-Za-z0-9_-]{8,128})$/);
|
||||
const shareId = pathMatch?.[1] || new URLSearchParams(window.location.search).get("share");
|
||||
if (!shareId) return;
|
||||
document.body.classList.add("share-mode");
|
||||
try {
|
||||
const { response, data } = await fetchJsonOrThrow(`/api/shares/${encodeURIComponent(shareId)}`);
|
||||
if (!response.ok || data.status !== "success") {
|
||||
throw new Error(data?.error || `Share request failed with HTTP ${response.status}`);
|
||||
}
|
||||
const item = normalizeHistoryEntry(data.share, 0);
|
||||
if (!item) throw new Error("Shared record is invalid.");
|
||||
item.error = data.share.error || null;
|
||||
let item = await fetchSharedItem(shareId);
|
||||
applyHistoryItem(item);
|
||||
document.querySelectorAll(".control-panel input, .control-panel select, .control-panel textarea, .control-panel button").forEach((node) => {
|
||||
if (!node.closest(".toolbar") && !node.matches("[data-design-tab]")) node.disabled = true;
|
||||
});
|
||||
setStatus(formatStatus(t("history_shared_loaded")));
|
||||
while (sharedJobIsActive(item)) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
item = await fetchSharedItem(shareId, { poll: true });
|
||||
applyHistoryItem(item);
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus(`${t("history_share_failed")}: ${error.message}`, true);
|
||||
}
|
||||
|
|
@ -4133,7 +4176,7 @@ A+B</textarea>
|
|||
<div class="compact-actions">
|
||||
<button class="secondary" data-history-load="${item.job_id}" type="button">${t("history_load")}</button>
|
||||
<button class="secondary" data-history-export="${item.job_id}" type="button">${t("history_export_one")}</button>
|
||||
${["success", "error", "input_only", "canceled"].includes(item.status) ? `<button class="secondary" data-history-share="${item.job_id}" type="button">${t("history_share")}</button>` : ""}
|
||||
<button class="secondary" data-history-share="${item.job_id}" type="button">${t("history_share")}</button>
|
||||
${["queued", "running", "cancel_requested"].includes(item.status) ? "" : `<button class="secondary" data-history-delete="${item.job_id}" type="button">${t("history_delete")}</button>`}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -4329,6 +4372,7 @@ A+B</textarea>
|
|||
seed: Number(document.getElementById("designSeed").value),
|
||||
wobble_mutations: document.getElementById("designWobble").value === "true",
|
||||
max_time_seconds: Math.round(Number(document.getElementById("designMaxTimeHours").value || 0) * 3600),
|
||||
fixed_target_policy: document.getElementById("designFixedTargetPolicy").value,
|
||||
},
|
||||
hard_constraints: getHardConstraints(),
|
||||
soft_constraints: getSoftConstraints(),
|
||||
|
|
@ -4519,6 +4563,7 @@ A+B</textarea>
|
|||
|
||||
function renderDesignSummaryCard(result) {
|
||||
const stats = result.design?.stats || {};
|
||||
const excludedFixedCount = result.design?.optimization?.excluded_fixed_targets?.length || 0;
|
||||
return `
|
||||
<div class="result-card">
|
||||
<h3>${t("design_summary_card")}</h3>
|
||||
|
|
@ -4526,6 +4571,7 @@ A+B</textarea>
|
|||
<div><strong>${t("design_ensemble_defect")}</strong><span class="metric-value">${Number(result.design?.ensemble_defect || 0).toFixed(6)}</span></div>
|
||||
<div><strong>${t("design_trials_used")}</strong><span class="metric-value">${result.options?.trials ?? 1}</span></div>
|
||||
<div><strong>${t("design_seed")}</strong><span class="metric-value">${stats.seed ?? "-"}</span></div>
|
||||
<div><strong>${t("design_fixed_excluded")}</strong><span class="metric-value">${excludedFixedCount}</span></div>
|
||||
<div><strong>${t("summary_elapsed")}</strong><span class="metric-value">${stats.design_time ? `${Number(stats.design_time).toFixed(3)} s` : "-"}</span></div>
|
||||
</div>
|
||||
<div class="compact-actions" style="margin-top: 12px;">
|
||||
|
|
@ -6130,6 +6176,7 @@ A+B</textarea>
|
|||
document.getElementById("designSeed").value = example.design?.seed ?? 0;
|
||||
document.getElementById("designWobble").value = String(example.design?.wobble_mutations ?? false);
|
||||
document.getElementById("designMaxTimeHours").value = ((example.design?.max_time_seconds ?? 0) / 3600);
|
||||
document.getElementById("designFixedTargetPolicy").value = example.design?.fixed_target_policy ?? "exclude_from_optimization";
|
||||
document.getElementById("complexesText").value = example.complexes_text;
|
||||
const exampleCompute = Array.isArray(example.compute) ? example.compute : [];
|
||||
document.querySelectorAll(".check input").forEach((input) => {
|
||||
|
|
|
|||
|
|
@ -82,8 +82,11 @@ UNIT_SCALE = {
|
|||
}
|
||||
IUPAC_CODES = "ACGTUWSMKRYBDHVN"
|
||||
IUPAC_CONSTRAINT_TOKEN = re.compile(rf"[{IUPAC_CODES}](?:\d+)?")
|
||||
FIXED_BASES = set("ACGTU")
|
||||
COMPLEMENT_BASE = str.maketrans({"A": "T", "C": "G", "G": "C", "T": "A", "U": "A"})
|
||||
|
||||
VALID_COMPUTE = {"pfunc", "pairs", "mfe", "sample", "subopt", "ensemble_size"}
|
||||
FIXED_TARGET_POLICIES = {"exclude_from_optimization", "include"}
|
||||
CANCEL_REQUESTED_STATUS = "cancel_requested"
|
||||
CANCELED_STATUS = "canceled"
|
||||
TERMINAL_JOB_STATUSES = {"success", "error", CANCELED_STATUS}
|
||||
|
|
@ -245,6 +248,54 @@ def is_valid_iupac_constraint(sequence):
|
|||
return True
|
||||
|
||||
|
||||
def expand_iupac_constraint(sequence):
|
||||
seq = normalize_design_sequence(sequence)
|
||||
output = []
|
||||
index = 0
|
||||
while index < len(seq):
|
||||
match = IUPAC_CONSTRAINT_TOKEN.match(seq, index)
|
||||
if not match:
|
||||
raise ValueError(f"Invalid sequence constraint near: {seq[index:]}")
|
||||
token = match.group(0)
|
||||
base = token[0]
|
||||
count = int(token[1:] or "1")
|
||||
output.append(base * count)
|
||||
index = match.end()
|
||||
return "".join(output)
|
||||
|
||||
|
||||
def is_mutable_iupac_constraint(sequence):
|
||||
expanded = expand_iupac_constraint(sequence)
|
||||
return any(base not in FIXED_BASES for base in expanded)
|
||||
|
||||
|
||||
def reverse_complement_fixed(sequence):
|
||||
return sequence.upper().translate(COMPLEMENT_BASE)[::-1]
|
||||
|
||||
|
||||
def parse_domain_tokens(text):
|
||||
tokens = [token.strip() for token in re.split(r"[\s,]+", str(text or "").strip()) if token.strip()]
|
||||
output = []
|
||||
for token in tokens:
|
||||
complement = False
|
||||
domain_name = token
|
||||
if token.startswith("~"):
|
||||
complement = True
|
||||
domain_name = token[1:]
|
||||
elif token.endswith("*"):
|
||||
complement = True
|
||||
domain_name = token[:-1]
|
||||
output.append((domain_name, complement))
|
||||
return output
|
||||
|
||||
|
||||
def get_mapping_value(mapping, key, default=None):
|
||||
try:
|
||||
return mapping[key]
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def ensure_unit_interval_limits(lower, upper, label):
|
||||
if not (0 <= lower <= upper <= 1):
|
||||
raise ValueError(f"{label} limits must satisfy 0 <= lower <= upper <= 1.")
|
||||
|
|
@ -320,6 +371,7 @@ def parse_design_options(payload):
|
|||
"seed": int(raw.get("seed", 0)),
|
||||
"wobble_mutations": bool(raw.get("wobble_mutations", False)),
|
||||
"max_time_seconds": int(raw.get("max_time_seconds", 0)),
|
||||
"fixed_target_policy": str(raw.get("fixed_target_policy", "exclude_from_optimization")).strip().lower(),
|
||||
}
|
||||
if options["trials"] < 1 or options["trials"] > 8:
|
||||
raise ValueError("design trials must be between 1 and 8.")
|
||||
|
|
@ -331,6 +383,8 @@ def parse_design_options(payload):
|
|||
raise ValueError("design stop_condition must be between 0 and 1.")
|
||||
if options["max_time_seconds"] < 0:
|
||||
raise ValueError("design max_time_seconds must be non-negative.")
|
||||
if options["fixed_target_policy"] not in FIXED_TARGET_POLICIES:
|
||||
raise ValueError("design fixed_target_policy must be exclude_from_optimization or include.")
|
||||
return options
|
||||
|
||||
|
||||
|
|
@ -388,11 +442,14 @@ def build_design_domains(domain_payload):
|
|||
|
||||
domain = Domain(sequence_constraint, name=name)
|
||||
domain_map[name] = domain
|
||||
mutable = is_mutable_iupac_constraint(sequence_constraint)
|
||||
ordered.append(
|
||||
{
|
||||
"name": name,
|
||||
"constraint": sequence_constraint,
|
||||
"object": domain,
|
||||
"mutable": mutable,
|
||||
"fixed_sequence": None if mutable else expand_iupac_constraint(sequence_constraint),
|
||||
}
|
||||
)
|
||||
return domain_map, ordered
|
||||
|
|
@ -424,11 +481,12 @@ def parse_domain_composition(text, domain_map):
|
|||
return domains
|
||||
|
||||
|
||||
def build_design_strands(strand_payload, domain_map=None):
|
||||
def build_design_strands(strand_payload, domain_map=None, domain_rows=None):
|
||||
if not strand_payload:
|
||||
raise ValueError("At least one design strand is required.")
|
||||
|
||||
domain_map = domain_map or {}
|
||||
domain_info = {item["name"]: item for item in (domain_rows or [])}
|
||||
use_domain_composition = bool(domain_map)
|
||||
target_strand_map = {}
|
||||
ordered = []
|
||||
|
|
@ -457,11 +515,28 @@ def build_design_strands(strand_payload, domain_map=None):
|
|||
target_strand = TargetStrand(strand_domains, name=name)
|
||||
constraint_kind = "sequence_constraint"
|
||||
constraint_value = sequence_constraint
|
||||
mutable = is_mutable_iupac_constraint(sequence_constraint)
|
||||
fixed_sequence = None if mutable else expand_iupac_constraint(sequence_constraint)
|
||||
else:
|
||||
inline_domain = None
|
||||
target_strand = TargetStrand(strand_domains, name=name)
|
||||
constraint_kind = "domain_composition"
|
||||
constraint_value = raw_definition
|
||||
fixed_parts = []
|
||||
mutable = False
|
||||
for domain_name, complement in parse_domain_tokens(raw_definition):
|
||||
info = domain_info.get(domain_name)
|
||||
if info is None:
|
||||
mutable = True
|
||||
fixed_parts = []
|
||||
break
|
||||
if info.get("mutable"):
|
||||
mutable = True
|
||||
fixed_parts = []
|
||||
break
|
||||
part = info.get("fixed_sequence") or ""
|
||||
fixed_parts.append(reverse_complement_fixed(part) if complement else part)
|
||||
fixed_sequence = None if mutable else "".join(fixed_parts)
|
||||
else:
|
||||
if not is_valid_iupac_constraint(sequence_constraint):
|
||||
raise ValueError(f"Design strand {name} contains unsupported constraint characters.")
|
||||
|
|
@ -470,6 +545,8 @@ def build_design_strands(strand_payload, domain_map=None):
|
|||
target_strand = TargetStrand(strand_domains, name=name)
|
||||
constraint_kind = "sequence_constraint"
|
||||
constraint_value = sequence_constraint
|
||||
mutable = is_mutable_iupac_constraint(sequence_constraint)
|
||||
fixed_sequence = None if mutable else expand_iupac_constraint(sequence_constraint)
|
||||
|
||||
target_strand_map[name] = target_strand
|
||||
ordered.append(
|
||||
|
|
@ -481,6 +558,8 @@ def build_design_strands(strand_payload, domain_map=None):
|
|||
"object": target_strand,
|
||||
"domains": strand_domains,
|
||||
"domain": inline_domain,
|
||||
"mutable": mutable,
|
||||
"fixed_sequence": fixed_sequence,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -728,13 +807,14 @@ def build_soft_constraints(payload, domain_map, strand_map, target_complex_map):
|
|||
return constraints
|
||||
|
||||
|
||||
def parse_design_complexes(payload, target_strand_map):
|
||||
def parse_design_complexes(payload, target_strand_map, strand_rows=None):
|
||||
target_rows = payload.get("design_complexes") or payload.get("design_targets") or []
|
||||
if not target_rows:
|
||||
raise ValueError("At least one design target complex is required.")
|
||||
|
||||
targets = []
|
||||
target_complex_map = {}
|
||||
strand_info = {item["name"]: item for item in (strand_rows or [])}
|
||||
|
||||
for idx, row in enumerate(target_rows, start=1):
|
||||
name = (row.get("name") or f"target_{idx}").strip() or f"target_{idx}"
|
||||
|
|
@ -761,11 +841,14 @@ def parse_design_complexes(payload, target_strand_map):
|
|||
structure,
|
||||
name=name,
|
||||
)
|
||||
mutable = any(strand_info.get(token, {}).get("mutable", True) for token in tokens)
|
||||
target_payload = {
|
||||
"name": name,
|
||||
"strands": tokens,
|
||||
"structure": structure,
|
||||
"object": target_complex,
|
||||
"mutable": mutable,
|
||||
"optimization_status": "included",
|
||||
}
|
||||
targets.append(target_payload)
|
||||
target_complex_map[name] = target_complex
|
||||
|
|
@ -859,6 +942,21 @@ def parse_design_tubes(payload, target_rows, target_complex_map, default_max_siz
|
|||
return ordered_rows, tubes
|
||||
|
||||
|
||||
def payload_with_allowed_design_targets(payload, allowed_target_names):
|
||||
allowed = set(allowed_target_names)
|
||||
filtered_tubes = []
|
||||
for row in payload.get("design_tubes") or []:
|
||||
on_targets = [entry for entry in (row.get("on_targets") or []) if (entry.get("complex") or "").strip() in allowed]
|
||||
if on_targets:
|
||||
tube_row = dict(row)
|
||||
tube_row["on_targets"] = on_targets
|
||||
filtered_tubes.append(tube_row)
|
||||
|
||||
output = dict(payload)
|
||||
output["design_tubes"] = filtered_tubes
|
||||
return output
|
||||
|
||||
|
||||
def validate_design_object_names(design_domains, design_strands, target_rows, tube_rows):
|
||||
name_map = {}
|
||||
for kind, rows in (
|
||||
|
|
@ -1206,46 +1304,62 @@ def serialize_design_result(
|
|||
ordered_domains,
|
||||
ordered_strands,
|
||||
):
|
||||
analysis_map = getattr(design_result, "to_analysis", {}) or {}
|
||||
designed_domains = []
|
||||
designed_domain_map = getattr(design_result, "domains", {}) or {}
|
||||
for item in ordered_domains:
|
||||
designed_domain = designed_domain_map.get(item["object"])
|
||||
domain_sequence = str(designed_domain) if designed_domain is not None else item.get("fixed_sequence")
|
||||
designed_domains.append(
|
||||
{
|
||||
"name": item["name"],
|
||||
"constraint": item["constraint"],
|
||||
"sequence": str(designed_domain) if designed_domain is not None else None,
|
||||
"length": len(str(designed_domain)) if designed_domain is not None else None,
|
||||
"sequence": domain_sequence,
|
||||
"length": len(domain_sequence) if domain_sequence is not None else None,
|
||||
"mutable": bool(item.get("mutable", True)),
|
||||
}
|
||||
)
|
||||
|
||||
designed_strands = []
|
||||
designed_strand_by_name = {}
|
||||
for item in ordered_strands:
|
||||
target_strand = item["object"]
|
||||
analysis_strand = design_result.to_analysis[target_strand]
|
||||
analysis_strand = get_mapping_value(analysis_map, target_strand)
|
||||
sequence = str(analysis_strand) if analysis_strand is not None else item.get("fixed_sequence")
|
||||
designed_strands.append(
|
||||
{
|
||||
"name": item["name"],
|
||||
"constraint": item["constraint"],
|
||||
"constraint_kind": item.get("constraint_kind", "sequence_constraint"),
|
||||
"definition": item.get("definition", item["constraint"]),
|
||||
"sequence": str(analysis_strand),
|
||||
"length": len(str(analysis_strand)),
|
||||
"sequence": sequence,
|
||||
"length": len(sequence) if sequence is not None else None,
|
||||
"mutable": bool(item.get("mutable", True)),
|
||||
}
|
||||
)
|
||||
if sequence is not None:
|
||||
designed_strand_by_name[item["name"]] = sequence
|
||||
|
||||
target_complexes = []
|
||||
for target in target_rows:
|
||||
target_complex = target["object"]
|
||||
analysis_complex = design_result.to_analysis[target_complex]
|
||||
analysis_complex = get_mapping_value(analysis_map, target_complex)
|
||||
if analysis_complex is not None:
|
||||
display = stringify_complex(analysis_complex)
|
||||
sequence = flatten_sequence(analysis_complex)
|
||||
else:
|
||||
display = " + ".join(target["strands"])
|
||||
sequence = "".join(designed_strand_by_name.get(name, "") for name in target["strands"])
|
||||
target_complexes.append(
|
||||
{
|
||||
"name": target["name"],
|
||||
"display": stringify_complex(analysis_complex),
|
||||
"display": display,
|
||||
"strand_names": list(target["strands"]),
|
||||
"structure": target["structure"],
|
||||
"sequence": flatten_sequence(analysis_complex),
|
||||
"sequence": sequence,
|
||||
"target_concentration_M": target.get("target_concentration_M"),
|
||||
"optimization_status": target.get("optimization_status", "included"),
|
||||
"mutable": bool(target.get("mutable", True)),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -1291,6 +1405,11 @@ def serialize_design_result(
|
|||
"seed": design_options["seed"],
|
||||
"wobble_mutations": design_options["wobble_mutations"],
|
||||
"max_time_seconds": design_options["max_time_seconds"],
|
||||
"fixed_target_policy": design_options["fixed_target_policy"],
|
||||
},
|
||||
"optimization": {
|
||||
"included_targets": [row["name"] for row in target_rows if row.get("optimization_status") == "included"],
|
||||
"excluded_fixed_targets": [row["name"] for row in target_rows if row.get("optimization_status") == "fixed_excluded"],
|
||||
},
|
||||
"target_tubes": [
|
||||
{
|
||||
|
|
@ -1346,22 +1465,42 @@ def run_job_payload(payload):
|
|||
target_strand_map, design_strands = build_design_strands(
|
||||
payload.get("strands") or [],
|
||||
domain_map=design_domain_map,
|
||||
domain_rows=design_domains,
|
||||
)
|
||||
target_rows, target_complex_map = parse_design_complexes(payload, target_strand_map)
|
||||
target_rows, target_complex_map = parse_design_complexes(payload, target_strand_map, strand_rows=design_strands)
|
||||
optimization_target_rows = target_rows
|
||||
optimization_target_complex_map = target_complex_map
|
||||
if design_options["fixed_target_policy"] == "exclude_from_optimization":
|
||||
optimization_target_rows = [row for row in target_rows if row.get("mutable", True)]
|
||||
fixed_names = {row["name"] for row in target_rows if not row.get("mutable", True)}
|
||||
for row in target_rows:
|
||||
if row["name"] in fixed_names:
|
||||
row["optimization_status"] = "fixed_excluded"
|
||||
optimization_target_complex_map = {
|
||||
row["name"]: row["object"] for row in optimization_target_rows
|
||||
}
|
||||
if not optimization_target_rows:
|
||||
raise ValueError("Design contains no mutable target complexes after fixed-target filtering.")
|
||||
hard_constraints = build_hard_constraints(payload, design_domain_map, target_strand_map)
|
||||
soft_constraints = build_soft_constraints(
|
||||
payload,
|
||||
design_domain_map,
|
||||
target_strand_map,
|
||||
target_complex_map,
|
||||
optimization_target_complex_map,
|
||||
)
|
||||
tube_rows = []
|
||||
design_tubes = []
|
||||
if mode == "tube":
|
||||
optimization_payload = payload
|
||||
if design_options["fixed_target_policy"] == "exclude_from_optimization":
|
||||
optimization_payload = payload_with_allowed_design_targets(
|
||||
payload,
|
||||
{row["name"] for row in optimization_target_rows},
|
||||
)
|
||||
tube_rows, design_tubes = parse_design_tubes(
|
||||
payload,
|
||||
target_rows,
|
||||
target_complex_map,
|
||||
optimization_payload,
|
||||
optimization_target_rows,
|
||||
optimization_target_complex_map,
|
||||
design_options["off_target_max_size"],
|
||||
)
|
||||
validate_design_object_names(design_domains, design_strands, target_rows, tube_rows)
|
||||
|
|
@ -1386,7 +1525,7 @@ def run_job_payload(payload):
|
|||
)
|
||||
elif mode == "complex":
|
||||
design_job = complex_design(
|
||||
complexes=[row["object"] for row in target_rows],
|
||||
complexes=[row["object"] for row in optimization_target_rows],
|
||||
model=model,
|
||||
options=design_job_options,
|
||||
hard_constraints=hard_constraints,
|
||||
|
|
@ -1923,6 +2062,69 @@ def get_share(share_id):
|
|||
return dict(item) if item else None
|
||||
|
||||
|
||||
def public_share_payload(share_id, *, record_access=True):
|
||||
metadata = ACCOUNT_STORE.share_metadata(share_id)
|
||||
if metadata is None:
|
||||
return get_share(share_id)
|
||||
|
||||
live = get_job_data(metadata["job_id"], include_payload=True)
|
||||
if live is not None:
|
||||
if record_access:
|
||||
ACCOUNT_STORE.record_share_access(share_id)
|
||||
payload = live.get("payload")
|
||||
if payload is None:
|
||||
account_job = ACCOUNT_STORE.get_job(metadata["user_id"], metadata["job_id"], include_content=True)
|
||||
payload = account_job.get("payload") if account_job else None
|
||||
result = live.get("result")
|
||||
error = live.get("error")
|
||||
return {
|
||||
"id": share_id,
|
||||
"share_id": share_id,
|
||||
"job_id": metadata["job_id"],
|
||||
"created_at": metadata["created_at"],
|
||||
"status": live.get("status"),
|
||||
"updated_at": live.get("updated_at"),
|
||||
"elapsed_seconds": live.get("elapsed_seconds"),
|
||||
"payload": payload,
|
||||
"result": result,
|
||||
"error": error,
|
||||
"result_summary": build_history_summary(payload, live),
|
||||
}
|
||||
|
||||
return ACCOUNT_STORE.resolve_share(share_id, record_access=record_access)
|
||||
|
||||
|
||||
def build_history_summary(payload, fallback=None):
|
||||
payload = payload or {}
|
||||
fallback = fallback or {}
|
||||
model = payload.get("model") or {}
|
||||
workflow = payload.get("workflow") or fallback.get("workflow") or "analysis"
|
||||
mode = payload.get("mode") or fallback.get("mode") or "tube"
|
||||
strands = payload.get("strands") or []
|
||||
if workflow == "design":
|
||||
complexes = payload.get("design_complexes") or payload.get("design_targets") or []
|
||||
tube_sizes = [int(row.get("max_size", 0) or 0) for row in (payload.get("design_tubes") or [])]
|
||||
max_size = max(tube_sizes, default=int((payload.get("design") or {}).get("off_target_max_size", 0) or 0))
|
||||
else:
|
||||
complexes = [line for line in str(payload.get("complexes_text") or "").splitlines() if line.strip()]
|
||||
max_size = int((payload.get("tube") or {}).get("max_size", 0) or 0)
|
||||
return {
|
||||
"workflow": workflow,
|
||||
"mode": mode,
|
||||
"material": model.get("material", fallback.get("material", "rna")),
|
||||
"celsius": float(model.get("celsius", fallback.get("celsius", 37)) or 37),
|
||||
"sodium": float(model.get("sodium", fallback.get("sodium", 0)) or 0),
|
||||
"magnesium": float(model.get("magnesium", fallback.get("magnesium", 0)) or 0),
|
||||
"max_size": max_size,
|
||||
"strand_count": len(strands),
|
||||
"complex_count": len(complexes),
|
||||
"compute": list(payload.get("compute") or fallback.get("compute") or ([] if workflow != "design" else ["design"])),
|
||||
"trials": int((payload.get("design") or {}).get("trials", fallback.get("trials", 0)) or 0),
|
||||
"stop_condition": float((payload.get("design") or {}).get("stop_condition", fallback.get("stop_condition", 0)) or 0),
|
||||
"max_time_seconds": int((payload.get("design") or {}).get("max_time_seconds", fallback.get("max_time_seconds", 0)) or 0),
|
||||
}
|
||||
|
||||
|
||||
def recover_interrupted_jobs():
|
||||
client = redis_client()
|
||||
recovered = 0
|
||||
|
|
@ -2043,6 +2245,7 @@ EXAMPLE_PAYLOAD = {
|
|||
"seed": 0,
|
||||
"wobble_mutations": False,
|
||||
"max_time_seconds": 0,
|
||||
"fixed_target_policy": "exclude_from_optimization",
|
||||
},
|
||||
"design_domains": [
|
||||
{"name": "a", "sequence": "N10"},
|
||||
|
|
@ -2104,6 +2307,7 @@ DESIGN_TUBE_EXAMPLE_PAYLOAD = {
|
|||
"seed": 1,
|
||||
"wobble_mutations": False,
|
||||
"max_time_seconds": 0,
|
||||
"fixed_target_policy": "exclude_from_optimization",
|
||||
},
|
||||
"design_domains": [
|
||||
{"name": "a", "sequence": "N10"},
|
||||
|
|
@ -2329,7 +2533,7 @@ class AppHandler(BaseHTTPRequestHandler):
|
|||
|
||||
if parsed.path.startswith("/api/shares/"):
|
||||
share_id = parsed.path.rsplit("/", 1)[-1]
|
||||
share = ACCOUNT_STORE.resolve_share(share_id) or get_share(share_id)
|
||||
share = public_share_payload(share_id, record_access=(parse_qs(parsed.query).get("poll") or ["0"])[0] != "1")
|
||||
if share is None:
|
||||
self._respond(*json_bytes({"error": "Share not found"}, status=HTTPStatus.NOT_FOUND))
|
||||
return
|
||||
|
|
|
|||
98
service/test_account.py
Normal file
98
service/test_account.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from service.account import AccountStore
|
||||
|
||||
|
||||
def user(user_id):
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"username": user_id,
|
||||
"display_name": user_id.title(),
|
||||
"email": f"{user_id}@example.test",
|
||||
"groups": [],
|
||||
}
|
||||
|
||||
|
||||
class AccountStoreTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary_directory = tempfile.TemporaryDirectory()
|
||||
self.store = AccountStore(Path(self.temporary_directory.name) / "account.sqlite3")
|
||||
self.alice = user("alice")
|
||||
self.bob = user("bob")
|
||||
self.payload = {
|
||||
"workflow": "analysis",
|
||||
"mode": "tube",
|
||||
"model": {"material": "rna", "celsius": 37, "sodium": 1, "magnesium": 0},
|
||||
"strands": [{"name": "A", "sequence": "ACGU"}],
|
||||
"complexes_text": "A",
|
||||
"tube": {"max_size": 1},
|
||||
"compute": ["mfe"],
|
||||
}
|
||||
|
||||
def tearDown(self):
|
||||
self.temporary_directory.cleanup()
|
||||
|
||||
def test_jobs_are_private_and_content_round_trips(self):
|
||||
result = {"workflow": "analysis", "complex_results": [{"name": "A"}]}
|
||||
self.store.create_job("job-1", self.alice, self.payload)
|
||||
self.store.update_job("job-1", "success", result=result, elapsed_seconds=1.25)
|
||||
|
||||
self.assertIsNone(self.store.get_job(self.bob["user_id"], "job-1"))
|
||||
item = self.store.get_job(self.alice["user_id"], "job-1")
|
||||
self.assertEqual(item["payload"], self.payload)
|
||||
self.assertEqual(item["result"], result)
|
||||
self.assertEqual(item["elapsed_seconds"], 1.25)
|
||||
self.assertEqual(self.store.list_jobs("alice")["total"], 1)
|
||||
self.assertEqual(self.store.list_jobs("bob")["total"], 0)
|
||||
|
||||
def test_share_can_expire_and_be_disabled(self):
|
||||
self.store.create_job("job-2", self.alice, self.payload, status="success", result={"ok": True})
|
||||
share = self.store.create_share("alice", "job-2")
|
||||
resolved = self.store.resolve_share(share["share_id"])
|
||||
self.assertEqual(resolved["job_id"], "job-2")
|
||||
self.assertEqual(resolved["result"], {"ok": True})
|
||||
|
||||
self.store.update_share("alice", share["share_id"], active=False)
|
||||
self.assertIsNone(self.store.resolve_share(share["share_id"]))
|
||||
self.assertIsNone(self.store.update_share("bob", share["share_id"], active=True))
|
||||
|
||||
expiring = self.store.create_share("alice", "job-2", expires_in=1)
|
||||
with self.store._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE shares SET expires_at=? WHERE share_id=?",
|
||||
(time.time() - 1, expiring["share_id"]),
|
||||
)
|
||||
self.assertIsNone(self.store.resolve_share(expiring["share_id"]))
|
||||
|
||||
def test_running_job_can_be_shared_and_later_exposes_result(self):
|
||||
self.store.create_job("job-running", self.alice, self.payload, status="running")
|
||||
share = self.store.create_share("alice", "job-running")
|
||||
|
||||
resolved = self.store.resolve_share(share["share_id"])
|
||||
self.assertEqual(resolved["job_id"], "job-running")
|
||||
self.assertEqual(resolved["status"], "running")
|
||||
self.assertIsNone(resolved["result"])
|
||||
|
||||
self.store.update_job("job-running", "success", result={"ok": True}, elapsed_seconds=2.5)
|
||||
resolved = self.store.resolve_share(share["share_id"])
|
||||
self.assertEqual(resolved["status"], "success")
|
||||
self.assertEqual(resolved["result"], {"ok": True})
|
||||
self.assertEqual(resolved["elapsed_seconds"], 2.5)
|
||||
|
||||
self.assertIsNone(self.store.update_share("bob", share["share_id"], active=False))
|
||||
self.store.update_share("alice", share["share_id"], active=False)
|
||||
self.assertIsNone(self.store.resolve_share(share["share_id"]))
|
||||
|
||||
def test_active_job_cannot_be_deleted(self):
|
||||
self.store.create_job("job-3", self.alice, self.payload)
|
||||
with self.assertRaises(ValueError):
|
||||
self.store.delete_job("alice", "job-3")
|
||||
self.store.update_job("job-3", "canceled", error={"message": "canceled"})
|
||||
self.assertTrue(self.store.delete_job("alice", "job-3"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue