升级 NUPACK 4.1 并支持混合材料设计
This commit is contained in:
parent
5daa60a464
commit
c6189d857d
33 changed files with 5830 additions and 466 deletions
|
|
@ -13,6 +13,9 @@ from urllib.parse import urlencode
|
|||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
DEFAULT_TRASH_RETENTION_SECONDS = 2 * 86400
|
||||
|
||||
|
||||
def _json_blob(value):
|
||||
if value is None:
|
||||
return None
|
||||
|
|
@ -127,8 +130,26 @@ class AccountStore:
|
|||
access_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS shares_user_created_idx ON shares(user_id, created_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at REAL NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
job_columns = {
|
||||
row["name"] for row in connection.execute("PRAGMA table_info(jobs)").fetchall()
|
||||
}
|
||||
if "deleted_at" not in job_columns:
|
||||
connection.execute("ALTER TABLE jobs ADD COLUMN deleted_at REAL")
|
||||
if "purge_after" not in job_columns:
|
||||
connection.execute("ALTER TABLE jobs ADD COLUMN purge_after REAL")
|
||||
connection.execute(
|
||||
"CREATE INDEX IF NOT EXISTS jobs_deleted_idx ON jobs(deleted_at, purge_after)"
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM shares WHERE job_id IN (SELECT job_id FROM jobs WHERE deleted_at IS NOT NULL)"
|
||||
)
|
||||
self._initialized = True
|
||||
|
||||
def upsert_user(self, user):
|
||||
|
|
@ -206,11 +227,15 @@ class AccountStore:
|
|||
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):
|
||||
def get_job(self, user_id, job_id, include_content=True, include_deleted=False):
|
||||
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"
|
||||
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,deleted_at,purge_after"
|
||||
deleted_clause = "" if include_deleted else " AND deleted_at IS NULL"
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(f"SELECT {columns} FROM jobs WHERE job_id = ? AND user_id = ?", (job_id, user_id)).fetchone()
|
||||
row = connection.execute(
|
||||
f"SELECT {columns} FROM jobs WHERE job_id = ? AND user_id = ?{deleted_clause}",
|
||||
(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):
|
||||
|
|
@ -218,7 +243,8 @@ class AccountStore:
|
|||
filters = filters or {}
|
||||
limit = min(100, max(1, int(filters.get("limit", 30))))
|
||||
offset = max(0, int(filters.get("offset", 0)))
|
||||
clauses = ["user_id = ?"]
|
||||
self.purge_expired_jobs()
|
||||
clauses = ["user_id = ?", "deleted_at IS NULL"]
|
||||
values = [user_id]
|
||||
for field in ("status", "workflow", "mode", "material"):
|
||||
value = str(filters.get(field) or "").strip()
|
||||
|
|
@ -230,7 +256,7 @@ class AccountStore:
|
|||
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"
|
||||
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,deleted_at,purge_after"
|
||||
with self._connect() as connection:
|
||||
total = connection.execute(f"SELECT count(*) AS count FROM jobs WHERE {where}", values).fetchone()["count"]
|
||||
rows = connection.execute(
|
||||
|
|
@ -239,6 +265,51 @@ class AccountStore:
|
|||
).fetchall()
|
||||
return {"items": [self._job_row(row, include_content=False) for row in rows], "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
def list_all_jobs(self, filters=None):
|
||||
self.initialize()
|
||||
filters = filters or {}
|
||||
limit = min(200, max(1, int(filters.get("limit", 50))))
|
||||
offset = max(0, int(filters.get("offset", 0)))
|
||||
self.purge_expired_jobs()
|
||||
deleted = str(filters.get("deleted") or "0").strip() == "1"
|
||||
clauses = ["j.deleted_at IS NOT NULL" if deleted else "j.deleted_at IS NULL"]
|
||||
values = []
|
||||
for field in ("status", "workflow", "mode", "material"):
|
||||
value = str(filters.get(field) or "").strip()
|
||||
if value:
|
||||
clauses.append(f"j.{field} = ?")
|
||||
values.append(value)
|
||||
search = str(filters.get("q") or "").strip()
|
||||
if search:
|
||||
clauses.append(
|
||||
"(j.job_id LIKE ? OR j.user_id LIKE ? OR u.username LIKE ? OR "
|
||||
"coalesce(u.email, '') LIKE ? OR j.workflow LIKE ? OR j.mode LIKE ?)"
|
||||
)
|
||||
values.extend([f"%{search}%"] * 6)
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
columns = (
|
||||
"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.stored_bytes,j.deleted_at,j.purge_after,u.username,u.email,u.display_name"
|
||||
)
|
||||
with self._connect() as connection:
|
||||
total = connection.execute(
|
||||
f"SELECT count(*) AS count FROM jobs j JOIN users u ON u.user_id=j.user_id {where}",
|
||||
values,
|
||||
).fetchone()["count"]
|
||||
rows = connection.execute(
|
||||
f"SELECT {columns} FROM jobs j JOIN users u ON u.user_id=j.user_id "
|
||||
f"{where} ORDER BY j.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:
|
||||
|
|
@ -248,7 +319,7 @@ class AccountStore:
|
|||
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=?""",
|
||||
coalesce(sum(stored_bytes),0) AS stored_bytes FROM jobs WHERE user_id=? AND deleted_at IS NULL""",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
shares = connection.execute("SELECT count(*) AS count FROM shares WHERE user_id=? AND active=1", (user_id,)).fetchone()["count"]
|
||||
|
|
@ -279,17 +350,86 @@ class AccountStore:
|
|||
imported += 1
|
||||
return imported
|
||||
|
||||
def delete_job(self, user_id, job_id):
|
||||
def trash_retention_seconds(self):
|
||||
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()
|
||||
row = connection.execute(
|
||||
"SELECT value FROM app_settings WHERE key='trash_retention_seconds'"
|
||||
).fetchone()
|
||||
try:
|
||||
return max(3600, int(row["value"])) if row else DEFAULT_TRASH_RETENTION_SECONDS
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_TRASH_RETENTION_SECONDS
|
||||
|
||||
def set_trash_retention_days(self, days):
|
||||
days = float(days)
|
||||
if not 1 / 24 <= days <= 365:
|
||||
raise ValueError("Trash retention must be between 1 hour and 365 days.")
|
||||
seconds = int(days * 86400)
|
||||
self.initialize()
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""INSERT INTO app_settings(key,value,updated_at) VALUES('trash_retention_seconds',?,?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at""",
|
||||
(str(seconds), time.time()),
|
||||
)
|
||||
return seconds
|
||||
|
||||
def purge_expired_jobs(self):
|
||||
self.initialize()
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM jobs WHERE deleted_at IS NOT NULL AND purge_after IS NOT NULL AND purge_after <= ?",
|
||||
(time.time(),),
|
||||
)
|
||||
return cursor.rowcount
|
||||
|
||||
def delete_job(self, user_id, job_id):
|
||||
self.initialize()
|
||||
now = time.time()
|
||||
retention_seconds = self.trash_retention_seconds()
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT status,deleted_at 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))
|
||||
if row["deleted_at"] is not None:
|
||||
return False
|
||||
connection.execute(
|
||||
"UPDATE jobs SET deleted_at=?,purge_after=?,updated_at=? WHERE job_id=? AND user_id=?",
|
||||
(now, now + retention_seconds, now, job_id, user_id),
|
||||
)
|
||||
connection.execute("DELETE FROM shares WHERE job_id=?", (job_id,))
|
||||
return True
|
||||
|
||||
def admin_trash_job(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 self.delete_job(row["user_id"], job_id) if row else False
|
||||
|
||||
def restore_job(self, job_id):
|
||||
self.initialize()
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"UPDATE jobs SET deleted_at=NULL,purge_after=NULL,updated_at=? WHERE job_id=? AND deleted_at IS NOT NULL",
|
||||
(time.time(), job_id),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def permanently_delete_job(self, job_id):
|
||||
self.initialize()
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM jobs WHERE job_id=? AND deleted_at IS NOT NULL",
|
||||
(job_id,),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
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:
|
||||
|
|
@ -298,6 +438,19 @@ class AccountStore:
|
|||
expires_at = now + int(expires_in) if expires_in else None
|
||||
share_id = uuid_token(18)
|
||||
with self._connect() as connection:
|
||||
existing = connection.execute(
|
||||
"""SELECT * FROM shares
|
||||
WHERE user_id=? AND job_id=? AND active=1
|
||||
AND (expires_at IS NULL OR expires_at>?)
|
||||
ORDER BY created_at DESC LIMIT 1""",
|
||||
(user_id, job_id, now),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
return dict(existing)
|
||||
connection.execute(
|
||||
"DELETE FROM shares WHERE user_id=? AND job_id=?",
|
||||
(user_id, job_id),
|
||||
)
|
||||
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),
|
||||
|
|
@ -329,7 +482,8 @@ class AccountStore:
|
|||
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""",
|
||||
FROM shares s JOIN jobs j ON j.job_id=s.job_id
|
||||
WHERE s.user_id=? AND j.deleted_at IS NULL ORDER BY s.created_at DESC""",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
|
@ -344,6 +498,14 @@ class AccountStore:
|
|||
share = self.get_share_for_owner(user_id, share_id)
|
||||
if share is None:
|
||||
return None
|
||||
if active is False:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"DELETE FROM shares WHERE share_id=? AND user_id=?",
|
||||
(share_id, user_id),
|
||||
)
|
||||
share["active"] = 0
|
||||
return share
|
||||
fields = []
|
||||
values = []
|
||||
if active is not None:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue