File size: 45,588 Bytes
90524df 9f2d8c2 90524df 9f2d8c2 8ba0d19 86fe0e2 8ba0d19 86fe0e2 8ba0d19 9f2d8c2 90524df 5c16f4d 90524df 6577f32 90524df 6577f32 4d6a819 6577f32 5c16f4d 6577f32 5c16f4d 6577f32 5c16f4d 90524df 4d6a819 5c16f4d 4d6a819 5c16f4d 4d6a819 90524df ba773cc 90524df ba773cc 90524df e1f0232 90524df a72dbd5 a9dff11 a72dbd5 90524df b65ddf5 90524df 9f2d8c2 90524df 9f2d8c2 90524df 39939a7 90524df a9dff11 39939a7 90524df 39939a7 90524df a9dff11 90524df a9dff11 90524df 9f2d8c2 90524df a72dbd5 90524df 25ca29c 90524df a72dbd5 84c85c2 90524df a72dbd5 90524df fe4f7d5 90524df a72dbd5 90524df 9b503b5 90524df 9b503b5 6c50e71 90524df 6c50e71 90524df 9b503b5 5faff18 9b503b5 90524df 9b503b5 90524df 9b503b5 90524df 9b503b5 90524df 9b503b5 90524df 9b503b5 90524df 9b503b5 6c50e71 9b503b5 90524df 5faff18 90524df e1f0232 d883ff7 e1f0232 90524df 1428965 90524df 1428965 a72dbd5 90524df 1428965 9f2d8c2 90524df a9dff11 c4e1e6b 299685f cf15a03 c4e1e6b 299685f cf15a03 299685f cf15a03 f1d96d6 cf15a03 c4e1e6b cf15a03 299685f cf15a03 c4e1e6b 299685f cf15a03 c4e1e6b 299685f c4e1e6b 90524df ba773cc 90524df 9f2d8c2 90524df 2a776a0 90524df 9f2d8c2 90524df ba773cc 90524df 9f2d8c2 90524df ba773cc 90524df 9f2d8c2 90524df 86fe0e2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 | # -*- coding: utf-8 -*-
"""
PRA Database — SQLite
儲存病人 PRA 分析結果,支援多次檢驗 MFI 比較
"""
import sqlite3
import shutil
import os
import threading
import logging
from pathlib import Path
from datetime import datetime
DB_PATH = Path(os.environ.get('DB_PATH', Path(__file__).parent / 'pra_data.db'))
BACKUP_DIR = DB_PATH.parent / 'backups'
# --- Auto-push:DB 寫入後立即背景推到雲端 repo ---
_push_lock = threading.Lock()
_push_dirty = False # 推送中又有新寫入 → 結束後再推一次
logger = logging.getLogger(__name__)
def _do_push():
"""背景執行:把 DB 推到雲端 repo,推完若有新變更再推一次"""
global _push_dirty
try:
token = os.environ.get('HF_TOKEN', '')
username = os.environ.get('HF_USERNAME', '')
if not token or not username:
return
from huggingface_hub import HfApi
api = HfApi(token=token)
repo_id = f'{username}/pradsa'
while True:
_push_dirty = False
api.upload_file(
path_or_fileobj=str(DB_PATH),
path_in_repo='pra_data.db',
repo_id=repo_id,
repo_type='space',
)
logger.info('Auto-push DB to repo OK')
if not _push_dirty:
break
# 推送期間有新寫入,再推一次
logger.info('DB changed during push, pushing again...')
except Exception as e:
logger.warning(f'Auto-push failed: {e}')
def schedule_auto_push():
"""DB 寫入後立即背景推到 repo。若已在推送中,標記 dirty 等推完再補推。"""
global _push_dirty
if not os.environ.get('HF_TOKEN'):
return
if _push_lock.locked():
_push_dirty = True
return
t = threading.Thread(target=_guarded_push, daemon=True)
t.start()
def push_upload_to_repo(local_path, filename):
"""同步把單一上傳 XLS 推到 repo 的 uploads/<filename>,
確保在 request 結束前完成(避免 daemon thread 被容器閒置/重啟中斷)。
失敗只記錄,不拋出(本地已存檔,可靠 startup 時再推)。"""
token = os.environ.get('HF_TOKEN', '')
username = os.environ.get('HF_USERNAME', '')
if not token or not username:
return False
try:
from huggingface_hub import HfApi
api = HfApi(token=token)
api.upload_file(
path_or_fileobj=str(local_path),
path_in_repo=f'uploads/{filename}',
repo_id=f'{username}/pradsa',
repo_type='space',
)
logger.info(f'Upload pushed to repo: uploads/{filename}')
return True
except Exception as e:
logger.warning(f'Upload push failed ({filename}): {e}')
return False
def pull_uploads_from_repo():
"""容器啟動時從 repo 拉 uploads/* 到本地 UPLOAD_DIR。
只下載本地沒有的檔案(以檔名判斷),避免重複下載。"""
token = os.environ.get('HF_TOKEN', '')
username = os.environ.get('HF_USERNAME', '')
if not token or not username:
return
try:
from huggingface_hub import HfApi
api = HfApi(token=token)
repo_id = f'{username}/pradsa'
UPLOAD_DIR.mkdir(exist_ok=True)
existing = {p.name for p in UPLOAD_DIR.iterdir() if p.is_file()}
files = api.list_repo_files(repo_id, repo_type='space')
pulled = 0
for rf in files:
if not rf.startswith('uploads/'):
continue
name = rf[len('uploads/'):]
if not name or name in existing:
continue
try:
local = api.hf_hub_download(
repo_id=repo_id, filename=rf, repo_type='space',
local_dir=str(UPLOAD_DIR.parent),
)
# hf_hub_download 會照 path_in_repo 結構存放;確保檔案在 UPLOAD_DIR
dest = UPLOAD_DIR / name
if not dest.exists():
shutil.copy2(local, dest)
pulled += 1
except Exception as e:
logger.warning(f'pull upload {name} failed: {e}')
if pulled:
logger.info(f'Pulled {pulled} upload(s) from repo')
except Exception as e:
logger.warning(f'pull_uploads_from_repo failed: {e}')
def _guarded_push():
with _push_lock:
_do_push()
def backup_db():
"""異動前自動備份 DB。當天最多 5 個,總共最多 15 個。"""
if not DB_PATH.exists():
return
BACKUP_DIR.mkdir(exist_ok=True)
today = datetime.now().strftime('%Y%m%d')
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
dst = BACKUP_DIR / f'pra_data_{ts}.db'
shutil.copy2(str(DB_PATH), str(dst))
# 當天只留最近 3 個
today_backups = sorted(BACKUP_DIR.glob(f'pra_data_{today}_*.db'))
for old in today_backups[:-5]:
old.unlink()
# 總共最多 15 個
all_backups = sorted(BACKUP_DIR.glob('pra_data_*.db'))
for old in all_backups[:-15]:
old.unlink()
def push_backups_to_repo():
"""雲端同步時:把 backup 資料夾推到 repo"""
token = os.environ.get('HF_TOKEN', '')
username = os.environ.get('HF_USERNAME', '')
if not token or not username:
return
try:
from huggingface_hub import HfApi
api = HfApi(token=token)
repo_id = f'{username}/pradsa'
# 上傳所有 local backup
backups = sorted(BACKUP_DIR.glob('pra_data_*.db'))
for bk in backups[-15:]: # 只推最新 10 個
api.upload_file(
path_or_fileobj=str(bk),
path_in_repo=f'backups/{bk.name}',
repo_id=repo_id,
repo_type='space',
)
# 清理 repo 舊 backup
all_files = api.list_repo_files(repo_id, repo_type='space')
repo_backups = sorted([f for f in all_files if f.startswith('backups/pra_data_')])
for old_f in repo_backups[:-15]:
try:
api.delete_file(old_f, repo_id=repo_id, repo_type='space')
except Exception:
pass
except Exception:
pass
import hashlib
def hash_pw(pw):
return hashlib.sha256(pw.encode()).hexdigest()
def get_conn():
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute('PRAGMA foreign_keys = ON')
return conn
def init_db():
conn = get_conn()
conn.executescript('''
CREATE TABLE IF NOT EXISTS patients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
patient_name TEXT NOT NULL,
chart_no TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now','localtime')),
donor_hla TEXT DEFAULT '',
UNIQUE(chart_no)
);
CREATE TABLE IF NOT EXISTS reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
patient_id INTEGER NOT NULL,
report_date TEXT NOT NULL,
pra_class TEXT NOT NULL,
pra_percent INTEGER NOT NULL,
overall TEXT NOT NULL,
specificity TEXT,
comment TEXT,
status TEXT DEFAULT 'draft',
created_at TEXT DEFAULT (datetime('now','localtime')),
updated_at TEXT DEFAULT (datetime('now','localtime')),
FOREIGN KEY (patient_id) REFERENCES patients(id)
);
CREATE TABLE IF NOT EXISTS antibody_strength (
id INTEGER PRIMARY KEY AUTOINCREMENT,
report_id INTEGER NOT NULL,
antigen TEXT NOT NULL,
allele TEXT,
max_mfi REAL,
mean_mfi REAL,
no_of_beads INTEGER,
bead_ids TEXT,
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
display_name TEXT DEFAULT '',
password_hash TEXT NOT NULL,
password_plain TEXT DEFAULT '',
role TEXT DEFAULT 'user',
created_at TEXT DEFAULT (datetime('now','localtime')),
edit_time TEXT DEFAULT (datetime('now','localtime'))
);
CREATE INDEX IF NOT EXISTS idx_reports_patient ON reports(patient_id);
CREATE INDEX IF NOT EXISTS idx_reports_date ON reports(report_date);
CREATE INDEX IF NOT EXISTS idx_ab_report ON antibody_strength(report_id);
CREATE TABLE IF NOT EXISTS dsa_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
patient_id INTEGER NOT NULL,
report_date TEXT NOT NULL,
dsa_class TEXT NOT NULL,
pct_sa INTEGER NOT NULL,
overall TEXT NOT NULL,
specificity TEXT,
comment TEXT,
status TEXT DEFAULT 'draft',
submitted_by TEXT DEFAULT '',
upload_file TEXT DEFAULT '',
is_deleted INTEGER DEFAULT 0,
deleted_at TEXT,
created_at TEXT DEFAULT (datetime('now','localtime')),
updated_at TEXT DEFAULT (datetime('now','localtime')),
FOREIGN KEY (patient_id) REFERENCES patients(id)
);
CREATE TABLE IF NOT EXISTS dsa_antibody_strength (
id INTEGER PRIMARY KEY AUTOINCREMENT,
report_id INTEGER NOT NULL,
antigen TEXT NOT NULL,
allele TEXT,
strength TEXT,
max_mfi REAL,
mean_mfi REAL,
no_of_beads INTEGER,
bead_ids TEXT,
FOREIGN KEY (report_id) REFERENCES dsa_reports(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_dsa_reports_patient ON dsa_reports(patient_id);
CREATE INDEX IF NOT EXISTS idx_dsa_reports_date ON dsa_reports(report_date);
CREATE INDEX IF NOT EXISTS idx_dsa_ab_report ON dsa_antibody_strength(report_id);
''')
# migrate patients table
pat_cols = [r[1] for r in conn.execute('PRAGMA table_info(patients)').fetchall()]
if 'donor_hla' not in pat_cols:
conn.execute("ALTER TABLE patients ADD COLUMN donor_hla TEXT DEFAULT ''")
# migrate: soft delete columns
report_cols_all = [r[1] for r in conn.execute('PRAGMA table_info(reports)').fetchall()]
if 'upload_file' not in report_cols_all:
try:
conn.execute("ALTER TABLE reports ADD COLUMN upload_file TEXT DEFAULT ''")
except Exception:
pass
if 'is_deleted' not in report_cols_all:
try:
conn.execute("ALTER TABLE reports ADD COLUMN is_deleted INTEGER DEFAULT 0")
conn.execute("ALTER TABLE reports ADD COLUMN deleted_at TEXT")
except Exception:
pass
# migrate users table
user_cols = [r[1] for r in conn.execute('PRAGMA table_info(users)').fetchall()]
if 'role' not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN role TEXT DEFAULT 'user'")
if 'display_name' not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN display_name TEXT DEFAULT ''")
if 'password_plain' not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN password_plain TEXT DEFAULT ''")
if 'edit_time' not in [c.lower() for c in user_cols]:
try:
conn.execute("ALTER TABLE users ADD COLUMN edit_time TEXT")
except Exception:
pass
# migrate reports table
report_cols = [r[1] for r in conn.execute('PRAGMA table_info(reports)').fetchall()]
if 'submitted_by' not in report_cols:
conn.execute("ALTER TABLE reports ADD COLUMN submitted_by TEXT DEFAULT ''")
# migrate: add status/updated_at if missing
cols = [r[1] for r in conn.execute('PRAGMA table_info(reports)').fetchall()]
if 'status' not in cols:
conn.execute("ALTER TABLE reports ADD COLUMN status TEXT DEFAULT 'draft'")
if 'updated_at' not in cols:
conn.execute("ALTER TABLE reports ADD COLUMN updated_at TEXT")
conn.commit()
conn.close()
def get_or_create_patient(patient_name, chart_no):
backup_db()
conn = get_conn()
row = conn.execute('SELECT id FROM patients WHERE chart_no = ?', (chart_no,)).fetchone()
if row:
conn.execute('UPDATE patients SET patient_name = ? WHERE id = ?', (patient_name, row['id']))
conn.commit()
pid = row['id']
else:
cur = conn.execute('INSERT INTO patients (patient_name, chart_no) VALUES (?, ?)',
(patient_name, chart_no))
conn.commit()
pid = cur.lastrowid
conn.close()
schedule_auto_push()
return pid
def save_donor_hla(chart_no, donor_hla_json):
"""儲存病人的 Donor HLA"""
backup_db()
conn = get_conn()
conn.execute('UPDATE patients SET donor_hla=? WHERE chart_no=?', (donor_hla_json, chart_no))
conn.commit()
conn.close()
schedule_auto_push()
def get_donor_hla(chart_no):
conn = get_conn()
row = conn.execute('SELECT donor_hla FROM patients WHERE chart_no=?', (chart_no,)).fetchone()
conn.close()
return row['donor_hla'] if row and row['donor_hla'] else ''
def find_active_duplicate(patient_id, report_date, pra_class):
"""回傳 active(未刪除)重複 report 的 id,沒有則 None。"""
conn = get_conn()
row = conn.execute(
'''SELECT id FROM reports
WHERE patient_id=? AND report_date=? AND pra_class=?
AND COALESCE(is_deleted,0)=0''',
(patient_id, report_date, pra_class)
).fetchone()
conn.close()
return row['id'] if row else None
def save_report(patient_id, report_date, pra_class, pra_percent, overall,
specificity, comment, sero_mfi_list, status='draft', submitted_by='',
upload_file='', mode='overwrite'):
"""mode: 'overwrite' 覆寫 active 重複;'new' 強制 INSERT 新列。"""
backup_db()
conn = get_conn()
existing = None
if mode == 'overwrite':
existing = conn.execute(
'''SELECT id FROM reports
WHERE patient_id=? AND report_date=? AND pra_class=?
AND COALESCE(is_deleted,0)=0''',
(patient_id, report_date, pra_class)
).fetchone()
if existing:
report_id = existing['id']
conn.execute('''UPDATE reports SET pra_percent=?, overall=?, specificity=?, comment=?,
status=?, submitted_by=?, upload_file=?, updated_at=datetime('now','localtime') WHERE id=?''',
(pra_percent, overall, specificity, comment, status, submitted_by, upload_file or '', report_id))
conn.execute('DELETE FROM antibody_strength WHERE report_id=?', (report_id,))
else:
cur = conn.execute(
'''INSERT INTO reports (patient_id, report_date, pra_class, pra_percent, overall,
specificity, comment, status, submitted_by, upload_file) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''',
(patient_id, report_date, pra_class, pra_percent, overall, specificity, comment, status, submitted_by, upload_file or ''))
report_id = cur.lastrowid
for m in sero_mfi_list:
conn.execute(
'''INSERT INTO antibody_strength
(report_id, antigen, allele, max_mfi, mean_mfi, no_of_beads, bead_ids)
VALUES (?, ?, ?, ?, ?, ?, ?)''',
(report_id, m['sero'], m['alleles'], m['max_mfi'], m['mean_mfi'],
m['count'], m['beads']))
conn.commit()
conn.close()
schedule_auto_push()
return report_id
def get_all_reports(limit=100):
"""取得所有報告 (含病人資訊,排除已刪除)"""
conn = get_conn()
rows = conn.execute('''
SELECT r.*, p.patient_name, p.chart_no, p.donor_hla
FROM reports r
JOIN patients p ON p.id = r.patient_id
WHERE COALESCE(r.is_deleted, 0) = 0
ORDER BY p.patient_name, p.chart_no, r.report_date DESC, r.pra_class
LIMIT ?
''', (limit,)).fetchall()
conn.close()
return [dict(r) for r in rows]
def get_all_patients():
conn = get_conn()
rows = conn.execute('''
SELECT p.id, p.patient_name, p.chart_no,
COUNT(r.id) as report_count,
MAX(r.report_date) as latest_date,
GROUP_CONCAT(DISTINCT r.pra_class) as classes
FROM patients p
LEFT JOIN reports r ON r.patient_id = p.id AND COALESCE(r.is_deleted, 0) = 0
GROUP BY p.id
HAVING report_count > 0
ORDER BY latest_date DESC
''').fetchall()
conn.close()
return [dict(r) for r in rows]
def get_patient_reports(chart_no):
"""取得病人所有報告 + antibody strength"""
conn = get_conn()
patient = conn.execute('SELECT * FROM patients WHERE chart_no=?', (chart_no,)).fetchone()
if not patient:
conn.close()
return None, []
reports = conn.execute('''
SELECT * FROM reports WHERE patient_id=? AND COALESCE(is_deleted, 0) = 0
ORDER BY report_date DESC, pra_class
''', (patient['id'],)).fetchall()
result = []
for r in reports:
ab = conn.execute(
'SELECT * FROM antibody_strength WHERE report_id=? ORDER BY id', (r['id'],)
).fetchall()
result.append({**dict(r), 'antibodies': [dict(a) for a in ab]})
conn.close()
return dict(patient), result
def get_mfi_comparison(chart_no, pra_class=None):
"""
取得同一病人跨日期的 MFI 比較資料。
同一天有多筆 report 時,label 加上 (A)/(B)/... 後綴(按 report_id 升冪)。
回傳:
dates: [label1, label2, ...] # 顯示用字串,同日多筆會帶後綴
antigens: [{antigen, allele, mfi_by_date: {label: {...}, ...}}, ...]
pra_by_date: {label: pra_percent}
labels_by_rid: {report_id: label} # 用於把 report row 對應到圖上 label
"""
conn = get_conn()
patient = conn.execute('SELECT id FROM patients WHERE chart_no=?', (chart_no,)).fetchone()
if not patient:
conn.close()
return [], [], {}, {}
rep_query = '''
SELECT id, report_date, pra_percent, specificity
FROM reports
WHERE patient_id = ? AND COALESCE(is_deleted, 0) = 0
'''
rep_params = [patient['id']]
if pra_class:
rep_query += ' AND pra_class = ?'
rep_params.append(pra_class)
rep_query += ' ORDER BY report_date, id'
reports = conn.execute(rep_query, rep_params).fetchall()
from collections import defaultdict
date_count = defaultdict(int)
for r in reports:
date_count[r['report_date']] += 1
date_seen = defaultdict(int)
labels_by_rid = {}
dates = []
pra_by_date = {}
for r in reports:
d = r['report_date']
if date_count[d] == 1:
label = d
else:
suffix = chr(ord('A') + date_seen[d])
label = f'{d} ({suffix})'
date_seen[d] += 1
labels_by_rid[r['id']] = label
dates.append(label)
pra_by_date[label] = r['pra_percent']
if not reports:
conn.close()
return [], [], {}, {}
rep_ids = [r['id'] for r in reports]
placeholders = ','.join('?' * len(rep_ids))
ab_rows = conn.execute(f'''
SELECT report_id, antigen, allele, max_mfi, mean_mfi, no_of_beads
FROM antibody_strength
WHERE report_id IN ({placeholders})
ORDER BY antigen
''', rep_ids).fetchall()
conn.close()
# 以 allele 為主 key 展開:每個 confident allele 一列。
# 每份 report 的 specificity 字串當作該 report 的 confident allele 來源真理(per-report 獨立)。
# 同一 sero 下的不同 allele(如 DRB1*11:01 vs DRB1*11:02)會拆成獨立列,
# 哪些 allele 只在部分 report confident,其他 report 值為 None → 趨勢自然顯示「消失」。
import re as _re
parsed = {} # rid -> {'sero_alleles': {sero: set(alleles)}, 'bare': set(alleles_no_sero)}
for r in reports:
spec = r['specificity'] or ''
sa_map = {}
bare = set()
for mt in _re.finditer(r'([A-Za-z][A-Za-z0-9]*)\(([^)]*)\)', spec):
s = mt.group(1).strip()
for a in mt.group(2).split():
a = a.strip()
if a:
sa_map.setdefault(s, set()).add(a)
# 無括號的 standalone allele(DQA1*, DPA1*)
no_parens = _re.sub(r'\([^)]*\)', ' ', spec)
for tok in no_parens.split():
tok = tok.strip()
if '*' in tok and ':' in tok:
bare.add(tok)
parsed[r['id']] = {'sero_alleles': sa_map, 'bare': bare}
# allele → sero 對照(全 report 累積,只記有括號對應者)
allele_to_sero = {}
for p in parsed.values():
for s, alleles in p['sero_alleles'].items():
for a in alleles:
allele_to_sero[a] = s
# antibody_strength 以 (rid, antigen) 查表;同時保留 sero 的 allele_list 字串供 fallback
ab_map = {}
for ab in ab_rows:
ab_map[(ab['report_id'], ab['antigen'])] = dict(ab)
# 收集所有 allele(聯集)
all_alleles = set()
for p in parsed.values():
for alleles in p['sero_alleles'].values():
all_alleles.update(alleles)
all_alleles.update(p['bare'])
# 對每個 allele,逐 report 建 mfi_by_date
def allele_sort_key(a):
# sero 排序:A < B < Cw/C < DR/DRB1 < DQ/DQB1/DQA1 < DP/DPB1/DPA1
locus_order = {'A': 0, 'B': 1, 'C': 2, 'Cw': 2,
'DR': 3, 'DRB': 3, 'DRB1': 3, 'DRB3': 3, 'DRB4': 3, 'DRB5': 3,
'DQ': 4, 'DQB': 4, 'DQB1': 4, 'DQA1': 5,
'DP': 6, 'DPB': 6, 'DPB1': 6, 'DPA1': 7}
if '*' in a:
loc = a.split('*')[0]
else:
m = _re.match(r'[A-Za-z]+', a)
loc = m.group() if m else ''
nums = _re.findall(r'\d+', a)
n1 = int(nums[0]) if nums else 0
n2 = int(nums[1]) if len(nums) > 1 else 0
return (locus_order.get(loc, 99), n1, n2, a)
antigens_out = []
for allele in sorted(all_alleles, key=allele_sort_key):
sero = allele_to_sero.get(allele)
mfi_by_date = {}
for r in reports:
rid = r['id']
lbl = labels_by_rid[rid]
p = parsed[rid]
present = False
if sero:
present = allele in p['sero_alleles'].get(sero, set())
else:
present = allele in p['bare']
if not present:
continue
# MFI 查表:sero 優先,allele 作 fallback(legacy DQB1/DPB1 存 antigen=allele)
ab = None
if sero:
ab = ab_map.get((rid, sero)) or ab_map.get((rid, allele))
else:
ab = ab_map.get((rid, allele))
if ab:
mfi_by_date[lbl] = {
'max_mfi': ab['max_mfi'],
'mean_mfi': ab['mean_mfi'],
'no_of_beads': ab['no_of_beads'],
}
if not mfi_by_date:
continue
antigens_out.append({
'antigen': sero if sero else allele,
'allele': allele,
'mfi_by_date': mfi_by_date,
})
return dates, antigens_out, pra_by_date, labels_by_rid
# ============================================================
# DSA helpers (Single-Antigen / Donor-Specific Antibody)
# ============================================================
def find_active_duplicate_dsa(patient_id, report_date, dsa_class):
conn = get_conn()
row = conn.execute(
'''SELECT id FROM dsa_reports
WHERE patient_id=? AND report_date=? AND dsa_class=?
AND COALESCE(is_deleted,0)=0''',
(patient_id, report_date, dsa_class)
).fetchone()
conn.close()
return row['id'] if row else None
def save_dsa_report(patient_id, report_date, dsa_class, pct_sa, overall,
specificity, comment, sero_mfi_list, status='draft',
submitted_by='', upload_file='', mode='overwrite'):
backup_db()
conn = get_conn()
existing = None
if mode == 'overwrite':
existing = conn.execute(
'''SELECT id FROM dsa_reports
WHERE patient_id=? AND report_date=? AND dsa_class=?
AND COALESCE(is_deleted,0)=0''',
(patient_id, report_date, dsa_class)
).fetchone()
if existing:
report_id = existing['id']
conn.execute('''UPDATE dsa_reports SET pct_sa=?, overall=?, specificity=?, comment=?,
status=?, submitted_by=?, upload_file=?,
updated_at=datetime('now','localtime') WHERE id=?''',
(pct_sa, overall, specificity, comment, status, submitted_by,
upload_file or '', report_id))
conn.execute('DELETE FROM dsa_antibody_strength WHERE report_id=?', (report_id,))
else:
cur = conn.execute(
'''INSERT INTO dsa_reports (patient_id, report_date, dsa_class, pct_sa, overall,
specificity, comment, status, submitted_by, upload_file)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''',
(patient_id, report_date, dsa_class, pct_sa, overall, specificity, comment,
status, submitted_by, upload_file or ''))
report_id = cur.lastrowid
for m in sero_mfi_list:
conn.execute(
'''INSERT INTO dsa_antibody_strength
(report_id, antigen, allele, strength, max_mfi, mean_mfi, no_of_beads, bead_ids)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)''',
(report_id, m.get('sero', ''), m.get('alleles', ''),
m.get('strength', ''), m.get('max_mfi', 0), m.get('mean_mfi', 0),
m.get('count', 0), m.get('beads', '')))
conn.commit()
conn.close()
schedule_auto_push()
return report_id
def get_all_dsa_reports(limit=200):
conn = get_conn()
rows = conn.execute('''
SELECT r.*, p.patient_name, p.chart_no, p.donor_hla
FROM dsa_reports r JOIN patients p ON p.id = r.patient_id
WHERE COALESCE(r.is_deleted, 0) = 0
ORDER BY p.patient_name, p.chart_no, r.report_date DESC, r.dsa_class
LIMIT ?
''', (limit,)).fetchall()
conn.close()
return [dict(r) for r in rows]
def get_all_dsa_patients():
conn = get_conn()
rows = conn.execute('''
SELECT p.id, p.patient_name, p.chart_no,
COUNT(r.id) as report_count,
MAX(r.report_date) as latest_date,
GROUP_CONCAT(DISTINCT r.dsa_class) as classes
FROM patients p
LEFT JOIN dsa_reports r ON r.patient_id = p.id AND COALESCE(r.is_deleted, 0) = 0
GROUP BY p.id
HAVING report_count > 0
ORDER BY latest_date DESC
''').fetchall()
conn.close()
return [dict(r) for r in rows]
def get_dsa_patient_reports(chart_no):
conn = get_conn()
patient = conn.execute('SELECT * FROM patients WHERE chart_no=?', (chart_no,)).fetchone()
if not patient:
conn.close()
return None, []
reports = conn.execute('''
SELECT * FROM dsa_reports WHERE patient_id=? AND COALESCE(is_deleted,0)=0
ORDER BY report_date DESC, dsa_class
''', (patient['id'],)).fetchall()
out = []
for r in reports:
ab = conn.execute('SELECT * FROM dsa_antibody_strength WHERE report_id=? ORDER BY id',
(r['id'],)).fetchall()
out.append({**dict(r), 'antibodies': [dict(a) for a in ab]})
conn.close()
return dict(patient), out
def get_dsa_mfi_comparison(chart_no, dsa_class=None):
"""Same shape as PRA's get_mfi_comparison but reads dsa_reports."""
conn = get_conn()
patient = conn.execute('SELECT id FROM patients WHERE chart_no=?', (chart_no,)).fetchone()
if not patient:
conn.close()
return [], [], {}, {}
q = '''SELECT id, report_date, pct_sa, specificity FROM dsa_reports
WHERE patient_id = ? AND COALESCE(is_deleted, 0) = 0'''
params = [patient['id']]
if dsa_class:
q += ' AND dsa_class = ?'
params.append(dsa_class)
q += ' ORDER BY report_date, id'
reports = conn.execute(q, params).fetchall()
from collections import defaultdict
date_count = defaultdict(int)
for r in reports:
date_count[r['report_date']] += 1
date_seen = defaultdict(int)
labels_by_rid = {}
dates = []
pct_by_date = {}
for r in reports:
d = r['report_date']
if date_count[d] == 1:
label = d
else:
suffix = chr(ord('A') + date_seen[d])
label = f'{d} ({suffix})'
date_seen[d] += 1
labels_by_rid[r['id']] = label
dates.append(label)
pct_by_date[label] = r['pct_sa']
if not reports:
conn.close()
return [], [], {}, {}
rep_ids = [r['id'] for r in reports]
placeholders = ','.join('?' * len(rep_ids))
ab_rows = conn.execute(f'''
SELECT report_id, antigen, allele, max_mfi, mean_mfi, no_of_beads
FROM dsa_antibody_strength
WHERE report_id IN ({placeholders})
ORDER BY antigen
''', rep_ids).fetchall()
conn.close()
import re as _re
parsed = {}
for r in reports:
spec = r['specificity'] or ''
sa_map = {}
bare = set()
for mt in _re.finditer(r'([A-Za-z][A-Za-z0-9]*)\(([^)]*)\)', spec):
s = mt.group(1).strip()
for a in mt.group(2).split():
a = a.strip()
if a:
sa_map.setdefault(s, set()).add(a)
no_parens = _re.sub(r'\([^)]*\)', ' ', spec)
for tok in no_parens.split():
tok = tok.strip()
if '*' in tok and ':' in tok:
bare.add(tok)
parsed[r['id']] = {'sero_alleles': sa_map, 'bare': bare}
allele_to_sero = {}
for p in parsed.values():
for s, alleles in p['sero_alleles'].items():
for a in alleles:
allele_to_sero[a] = s
ab_map = {}
for ab in ab_rows:
ab_map[(ab['report_id'], ab['antigen'])] = dict(ab)
all_alleles = set()
for p in parsed.values():
for alleles in p['sero_alleles'].values():
all_alleles.update(alleles)
all_alleles.update(p['bare'])
def sk(a):
order = {'A': 0, 'B': 1, 'C': 2, 'Cw': 2,
'DR': 3, 'DRB1': 3, 'DRB3': 3, 'DRB4': 3, 'DRB5': 3,
'DQ': 4, 'DQB1': 4, 'DQA1': 5,
'DP': 6, 'DPB1': 6, 'DPA1': 7}
loc = a.split('*')[0] if '*' in a else (_re.match(r'[A-Za-z]+', a) or _re.match(r'.', a)).group()
nums = _re.findall(r'\d+', a)
n1 = int(nums[0]) if nums else 0
n2 = int(nums[1]) if len(nums) > 1 else 0
return (order.get(loc, 99), n1, n2, a)
out = []
for allele in sorted(all_alleles, key=sk):
sero = allele_to_sero.get(allele)
mfi_by_date = {}
for r in reports:
rid = r['id']
lbl = labels_by_rid[rid]
p = parsed[rid]
present = allele in p['sero_alleles'].get(sero, set()) if sero else allele in p['bare']
if not present:
continue
ab = ab_map.get((rid, sero)) if sero else None
if not ab:
ab = ab_map.get((rid, allele))
if ab:
mfi_by_date[lbl] = {
'max_mfi': ab['max_mfi'],
'mean_mfi': ab['mean_mfi'],
'no_of_beads': ab['no_of_beads'],
}
if not mfi_by_date:
continue
out.append({'antigen': sero if sero else allele, 'allele': allele,
'mfi_by_date': mfi_by_date})
return dates, out, pct_by_date, labels_by_rid
def get_patients_with_both():
"""Patients having BOTH active PRA and DSA reports."""
conn = get_conn()
rows = conn.execute('''
SELECT p.id, p.patient_name, p.chart_no,
(SELECT COUNT(*) FROM reports WHERE patient_id=p.id AND COALESCE(is_deleted,0)=0) AS pra_count,
(SELECT COUNT(*) FROM dsa_reports WHERE patient_id=p.id AND COALESCE(is_deleted,0)=0) AS dsa_count,
(SELECT MAX(report_date) FROM reports WHERE patient_id=p.id AND COALESCE(is_deleted,0)=0) AS pra_latest,
(SELECT MAX(report_date) FROM dsa_reports WHERE patient_id=p.id AND COALESCE(is_deleted,0)=0) AS dsa_latest
FROM patients p
ORDER BY p.patient_name
''').fetchall()
conn.close()
out = []
for r in rows:
d = dict(r)
if d['pra_count'] > 0 and d['dsa_count'] > 0:
d['latest_date'] = max(d['pra_latest'] or '', d['dsa_latest'] or '')
out.append(d)
out.sort(key=lambda x: x['latest_date'], reverse=True)
return out
def get_combined_reports(chart_no):
"""Active PRA + DSA reports for a patient, interleaved by date."""
conn = get_conn()
pra_rows = conn.execute('''
SELECT r.id, r.report_date, r.pra_class AS class_label, r.pra_percent AS pct,
r.overall, r.specificity, r.comment, r.submitted_by,
'PRA' AS source
FROM reports r JOIN patients p ON p.id=r.patient_id
WHERE p.chart_no=? AND COALESCE(r.is_deleted,0)=0
''', (chart_no,)).fetchall()
dsa_rows = conn.execute('''
SELECT r.id, r.report_date, r.dsa_class AS class_label, r.pct_sa AS pct,
r.overall, r.specificity, r.comment, r.submitted_by,
'DSA' AS source
FROM dsa_reports r JOIN patients p ON p.id=r.patient_id
WHERE p.chart_no=? AND COALESCE(r.is_deleted,0)=0
''', (chart_no,)).fetchall()
pra_list = [{**dict(r), 'antibodies': []} for r in pra_rows]
dsa_list = [{**dict(r), 'antibodies': []} for r in dsa_rows]
# Pull antibodies for each
for r in pra_list:
ab = conn.execute('SELECT * FROM antibody_strength WHERE report_id=? ORDER BY id',
(r['id'],)).fetchall()
r['antibodies'] = [dict(a) for a in ab]
for r in dsa_list:
ab = conn.execute('SELECT * FROM dsa_antibody_strength WHERE report_id=? ORDER BY id',
(r['id'],)).fetchall()
r['antibodies'] = [dict(a) for a in ab]
conn.close()
all_rows = pra_list + dsa_list
all_rows.sort(key=lambda r: (r['report_date'], r['source'], r['class_label']), reverse=True)
return all_rows
def get_combined_mfi(chart_no, class_label='I'):
"""Combined (PRA + DSA) MFI trend for one Class.
Returns:
dates: sorted unique date labels from both sources
antigens: [{allele, antigen (sero), pra_mfi: {date: max_mfi}, dsa_mfi: {date: max_mfi}}, ...]
"""
pra_full = f'PRA Class {class_label}'
dsa_full = f'DSA Class {class_label}'
pra_dates, pra_ag, _, _ = get_mfi_comparison(chart_no, pra_full)
dsa_dates, dsa_ag, _, _ = get_dsa_mfi_comparison(chart_no, dsa_full)
combined = {}
for ag in pra_ag:
key = ag['allele']
rec = combined.setdefault(key, {'allele': key, 'antigen': ag['antigen'],
'pra_mfi': {}, 'dsa_mfi': {}})
rec['antigen'] = rec.get('antigen') or ag['antigen']
for d, v in ag['mfi_by_date'].items():
rec['pra_mfi'][d] = v.get('max_mfi')
for ag in dsa_ag:
key = ag['allele']
rec = combined.setdefault(key, {'allele': key, 'antigen': ag['antigen'],
'pra_mfi': {}, 'dsa_mfi': {}})
rec['antigen'] = rec.get('antigen') or ag['antigen']
for d, v in ag['mfi_by_date'].items():
rec['dsa_mfi'][d] = v.get('max_mfi')
import re as _re
def date_key(lbl):
parts = lbl.split(' ')[0].split('/')
try:
return (int(parts[0]), int(parts[1]), int(parts[2]), lbl)
except (IndexError, ValueError):
return (9999, 0, 0, lbl)
all_dates = sorted(set(pra_dates) | set(dsa_dates), key=date_key)
return all_dates, list(combined.values())
def delete_dsa_report(report_id):
backup_db()
conn = get_conn()
row = conn.execute('SELECT upload_file FROM dsa_reports WHERE id=?', (report_id,)).fetchone()
upload_file = row['upload_file'] if row and row['upload_file'] else ''
conn.execute("""UPDATE dsa_reports SET is_deleted=1, deleted_at=datetime('now','localtime'),
updated_at=datetime('now','localtime') WHERE id=?""", (report_id,))
conn.commit()
if upload_file:
still_used = conn.execute(
'SELECT COUNT(*) FROM dsa_reports WHERE upload_file=? AND id!=? AND COALESCE(is_deleted,0)=0',
(upload_file, report_id)
).fetchone()[0]
# Also check PRA reports
still_used += conn.execute(
'SELECT COUNT(*) FROM reports WHERE upload_file=? AND COALESCE(is_deleted,0)=0',
(upload_file,)
).fetchone()[0]
conn.close()
if still_used == 0:
delete_upload(upload_file)
else:
conn.close()
schedule_auto_push()
def delete_report(report_id):
"""軟刪除報告(標記 is_deleted=1,不真的刪)。
若 upload_file 不再被任何 active report 引用,同步刪除 uploads/ 下的檔案。"""
backup_db()
conn = get_conn()
row = conn.execute('SELECT upload_file FROM reports WHERE id=?', (report_id,)).fetchone()
upload_file = row['upload_file'] if row and row['upload_file'] else ''
conn.execute("""UPDATE reports SET is_deleted=1, deleted_at=datetime('now','localtime'),
updated_at=datetime('now','localtime') WHERE id=?""", (report_id,))
conn.commit()
if upload_file:
still_used = conn.execute(
'SELECT COUNT(*) FROM reports WHERE upload_file=? AND id!=? AND COALESCE(is_deleted,0)=0',
(upload_file, report_id)
).fetchone()[0]
conn.close()
if still_used == 0:
delete_upload(upload_file)
else:
conn.close()
schedule_auto_push()
UPLOAD_DIR = DB_PATH.parent / 'uploads'
def get_upload_dir():
UPLOAD_DIR.mkdir(exist_ok=True)
return UPLOAD_DIR
def get_storage_stats():
"""取得 uploads 資料夾使用量"""
d = get_upload_dir()
files = list(d.glob('*.xls')) + list(d.glob('*.xlsx')) + list(d.glob('*.csv'))
total_size = sum(f.stat().st_size for f in files)
return {'count': len(files), 'size_mb': round(total_size / 1024 / 1024, 2)}
def list_uploads():
"""列出 uploads/ 所有檔案。上傳時間優先取 reports.created_at(持久值),
orphan 檔案才 fallback 到 file mtime。每筆附 source ('PRA' / 'DSA')。
判定優先序:reports → dsa_reports → 檔名 (LS?A* / *PRA1* / *PRA2*) → 預設 PRA。"""
d = get_upload_dir()
files = list(d.glob('*.xls')) + list(d.glob('*.xlsx')) + list(d.glob('*.csv'))
conn = get_conn()
pra_rows = conn.execute("""SELECT upload_file, MAX(COALESCE(updated_at, created_at)) as ts
FROM reports WHERE upload_file != ''
GROUP BY upload_file""").fetchall()
dsa_rows = conn.execute("""SELECT upload_file, MAX(COALESCE(updated_at, created_at)) as ts
FROM dsa_reports WHERE upload_file != ''
GROUP BY upload_file""").fetchall()
conn.close()
pra_times = {r['upload_file']: r['ts'] for r in pra_rows if r['upload_file']}
dsa_times = {r['upload_file']: r['ts'] for r in dsa_rows if r['upload_file']}
def classify(name):
if name in pra_times: return 'PRA'
if name in dsa_times: return 'DSA'
u = name.upper()
# PRA panel filenames contain "PRA" (e.g. LS1PRA021_*, *PRA1*.xls).
# SA / DSA filenames are LS1*/LS2* without "PRA" (LS1A bead, plain LS1.xls etc).
if 'PRA' in u: return 'PRA'
if 'LS1' in u or 'LS2' in u: return 'DSA'
return 'PRA'
out = []
for f in files:
st = f.stat()
ts = pra_times.get(f.name) or dsa_times.get(f.name)
if not ts:
ts = datetime.fromtimestamp(st.st_mtime).strftime('%Y-%m-%d %H:%M')
else:
ts = ts[:16]
out.append({
'name': f.name,
'size_kb': round(st.st_size / 1024, 1),
'mtime': ts,
'source': classify(f.name),
})
out.sort(key=lambda x: x['mtime'], reverse=True)
return out
def delete_upload(filename):
"""刪除 uploads/<filename>(本地 + 同步刪除 repo 對應檔案)。"""
d = get_upload_dir()
# 防目錄穿越
safe_name = os.path.basename(filename)
p = d / safe_name
if not p.exists():
return False
try:
p.unlink()
except Exception as e:
logger.warning(f'local delete failed: {e}')
return False
token = os.environ.get('HF_TOKEN', '')
username = os.environ.get('HF_USERNAME', '')
if token and username:
def _go():
try:
from huggingface_hub import HfApi
api = HfApi(token=token)
api.delete_file(
path_in_repo=f'uploads/{safe_name}',
repo_id=f'{username}/pradsa',
repo_type='space',
)
logger.info(f'Deleted uploads/{safe_name} from repo')
except Exception as e:
logger.warning(f'repo delete failed: {e}')
threading.Thread(target=_go, daemon=True).start()
return True
def register_user(username, password, display_name=''):
"""註冊新使用者"""
backup_db()
conn = get_conn()
existing = conn.execute('SELECT id FROM users WHERE username=? OR display_name=?',
(username, display_name)).fetchone()
if existing:
conn.close()
return False, '此資料已經存在,請直接登入'
conn.execute('INSERT INTO users (username, password_hash, password_plain, display_name, edit_time) VALUES (?, ?, ?, ?, datetime("now","localtime"))',
(username, hash_pw(password), password, display_name))
conn.commit()
conn.close()
schedule_auto_push()
return True, '註冊成功'
def check_user(username, password):
"""驗證使用者"""
conn = get_conn()
row = conn.execute('SELECT password_hash FROM users WHERE username=?', (username,)).fetchone()
conn.close()
if row and row['password_hash'] == hash_pw(password):
return True
return False
def seed_default_users(users_dict, admin_user=None):
"""初始化預設帳號"""
conn = get_conn()
for username, password in users_dict.items():
role = 'admin' if username == admin_user else 'user'
conn.execute('INSERT OR IGNORE INTO users (username, password_hash, password_plain, role, edit_time) VALUES (?, ?, ?, ?, datetime("now","localtime"))',
(username, hash_pw(password), password, role))
if username == admin_user:
conn.execute('UPDATE users SET role = ? WHERE username = ?', ('admin', username))
conn.commit()
conn.close()
def get_user_role(username):
conn = get_conn()
row = conn.execute('SELECT role FROM users WHERE username=?', (username,)).fetchone()
conn.close()
return row['role'] if row else 'user'
def get_user_display_name(username):
conn = get_conn()
row = conn.execute('SELECT display_name FROM users WHERE username=?', (username,)).fetchone()
conn.close()
return row['display_name'] if row and row['display_name'] else username
def get_all_users():
conn = get_conn()
rows = conn.execute('SELECT id, username, display_name, password_plain, role, created_at FROM users ORDER BY id').fetchall()
conn.close()
return [dict(r) for r in rows]
def delete_user(user_id):
backup_db()
conn = get_conn()
conn.execute('DELETE FROM users WHERE id=?', (user_id,))
conn.commit()
conn.close()
schedule_auto_push()
def update_user_role(user_id, role):
backup_db()
conn = get_conn()
conn.execute('UPDATE users SET role=? WHERE id=?', (role, user_id))
conn.execute("UPDATE users SET edit_time=datetime('now','localtime') WHERE id=?", (user_id,))
conn.commit()
conn.close()
schedule_auto_push()
def update_user(user_id, display_name=None, username=None, password=None, role=None):
backup_db()
conn = get_conn()
if display_name is not None:
conn.execute('UPDATE users SET display_name=? WHERE id=?', (display_name, user_id))
if username:
conn.execute('UPDATE users SET username=? WHERE id=?', (username, user_id))
if password:
conn.execute('UPDATE users SET password_hash=?, password_plain=? WHERE id=?',
(hash_pw(password), password, user_id))
if role:
conn.execute('UPDATE users SET role=? WHERE id=?', (role, user_id))
conn.execute("UPDATE users SET edit_time=datetime('now','localtime') WHERE id=?", (user_id,))
conn.commit()
conn.close()
schedule_auto_push()
init_db()
# 容器啟動時把 repo 上的 uploads/ 拉下來,確保 list_uploads() 能看到其他 session 上傳的檔案
try:
pull_uploads_from_repo()
except Exception as _e:
logger.warning(f'startup pull_uploads skipped: {_e}')
|