Upload sync.py with huggingface_hub
Browse files
sync.py
CHANGED
|
@@ -1,14 +1,17 @@
|
|
| 1 |
# -*- coding: utf-8 -*-
|
| 2 |
"""
|
| 3 |
-
|
| 4 |
-
雲端
|
| 5 |
-
|
|
|
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
import configparser
|
| 9 |
import sqlite3
|
| 10 |
import shutil
|
| 11 |
import os
|
|
|
|
| 12 |
from pathlib import Path
|
| 13 |
from datetime import datetime
|
| 14 |
|
|
@@ -48,7 +51,6 @@ def get_db_info(db_path):
|
|
| 48 |
|
| 49 |
def download_cloud_db():
|
| 50 |
from huggingface_hub import hf_hub_download
|
| 51 |
-
import tempfile
|
| 52 |
try:
|
| 53 |
path = hf_hub_download(
|
| 54 |
repo_id=REPO_ID, filename='pra_data.db',
|
|
@@ -73,7 +75,169 @@ def upload_db(db_path):
|
|
| 73 |
)
|
| 74 |
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
def sync():
|
|
|
|
| 77 |
print('=== 雲端 → Local 單向同步 ===\n')
|
| 78 |
|
| 79 |
local_info = get_db_info(LOCAL_DB)
|
|
@@ -96,23 +260,7 @@ def sync():
|
|
| 96 |
print('兩邊一致,不需要同步')
|
| 97 |
return
|
| 98 |
|
| 99 |
-
|
| 100 |
-
if LOCAL_DB.exists():
|
| 101 |
-
BACKUP_DIR.mkdir(exist_ok=True)
|
| 102 |
-
today = datetime.now().strftime('%Y%m%d')
|
| 103 |
-
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
|
| 104 |
-
bk = BACKUP_DIR / f'pra_data_before_sync_{ts}.db'
|
| 105 |
-
shutil.copy2(str(LOCAL_DB), str(bk))
|
| 106 |
-
print(f'已備份 Local → {bk.name}')
|
| 107 |
-
|
| 108 |
-
# 當天最多 5 個
|
| 109 |
-
today_bks = sorted(BACKUP_DIR.glob(f'pra_data_before_sync_{today}_*.db'))
|
| 110 |
-
for old in today_bks[:-5]:
|
| 111 |
-
old.unlink()
|
| 112 |
-
# 總共最多 15 個
|
| 113 |
-
all_bks = sorted(BACKUP_DIR.glob('pra_data_before_sync_*.db'))
|
| 114 |
-
for old in all_bks[:-15]:
|
| 115 |
-
old.unlink()
|
| 116 |
|
| 117 |
# 直接用雲端 DB 覆蓋 Local
|
| 118 |
shutil.copy2(cloud_path, str(LOCAL_DB))
|
|
@@ -122,4 +270,8 @@ def sync():
|
|
| 122 |
|
| 123 |
|
| 124 |
if __name__ == '__main__':
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# -*- coding: utf-8 -*-
|
| 2 |
"""
|
| 3 |
+
雙向同步:
|
| 4 |
+
- pull: 雲端 → Local(雲端為主,覆蓋 Local)
|
| 5 |
+
- push: Local → 雲端(Append-only merge,不刪雲端資料)
|
| 6 |
+
用法: python sync.py # pull(預設)
|
| 7 |
+
python sync.py push # push
|
| 8 |
"""
|
| 9 |
|
| 10 |
import configparser
|
| 11 |
import sqlite3
|
| 12 |
import shutil
|
| 13 |
import os
|
| 14 |
+
import tempfile
|
| 15 |
from pathlib import Path
|
| 16 |
from datetime import datetime
|
| 17 |
|
|
|
|
| 51 |
|
| 52 |
def download_cloud_db():
|
| 53 |
from huggingface_hub import hf_hub_download
|
|
|
|
| 54 |
try:
|
| 55 |
path = hf_hub_download(
|
| 56 |
repo_id=REPO_ID, filename='pra_data.db',
|
|
|
|
| 75 |
)
|
| 76 |
|
| 77 |
|
| 78 |
+
def _backup_local(tag='sync'):
|
| 79 |
+
"""備份 Local DB(當天最多 5 個,總共最多 15 個)"""
|
| 80 |
+
if not LOCAL_DB.exists():
|
| 81 |
+
return None
|
| 82 |
+
BACKUP_DIR.mkdir(exist_ok=True)
|
| 83 |
+
today = datetime.now().strftime('%Y%m%d')
|
| 84 |
+
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
|
| 85 |
+
bk = BACKUP_DIR / f'pra_data_before_{tag}_{ts}.db'
|
| 86 |
+
shutil.copy2(str(LOCAL_DB), str(bk))
|
| 87 |
+
print(f'已備份 Local → {bk.name}')
|
| 88 |
+
|
| 89 |
+
today_bks = sorted(BACKUP_DIR.glob(f'pra_data_before_{tag}_{today}_*.db'))
|
| 90 |
+
for old in today_bks[:-5]:
|
| 91 |
+
old.unlink()
|
| 92 |
+
all_bks = sorted(BACKUP_DIR.glob(f'pra_data_before_{tag}_*.db'))
|
| 93 |
+
for old in all_bks[:-15]:
|
| 94 |
+
old.unlink()
|
| 95 |
+
return bk
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def merge_local_into_cloud(local_path, cloud_path, merged_path):
|
| 99 |
+
"""
|
| 100 |
+
Append-only merge:以雲端為底,把 Local 的新增/更新資料合併進去。
|
| 101 |
+
絕不刪除雲端已有的 patients / reports / users。
|
| 102 |
+
"""
|
| 103 |
+
shutil.copy2(cloud_path, merged_path)
|
| 104 |
+
|
| 105 |
+
merged = sqlite3.connect(str(merged_path))
|
| 106 |
+
merged.row_factory = sqlite3.Row
|
| 107 |
+
local = sqlite3.connect(str(local_path))
|
| 108 |
+
local.row_factory = sqlite3.Row
|
| 109 |
+
|
| 110 |
+
# --- patients: 以 chart_no 為 key,UPSERT ---
|
| 111 |
+
local_patients = local.execute('SELECT * FROM patients').fetchall()
|
| 112 |
+
for p in local_patients:
|
| 113 |
+
existing = merged.execute('SELECT id FROM patients WHERE chart_no=?', (p['chart_no'],)).fetchone()
|
| 114 |
+
if existing:
|
| 115 |
+
# 更新 patient_name, donor_hla(取較新的)
|
| 116 |
+
merged.execute('UPDATE patients SET patient_name=?, donor_hla=? WHERE chart_no=?',
|
| 117 |
+
(p['patient_name'], p['donor_hla'] or '', p['chart_no']))
|
| 118 |
+
else:
|
| 119 |
+
merged.execute(
|
| 120 |
+
'INSERT INTO patients (patient_name, chart_no, created_at, donor_hla) VALUES (?,?,?,?)',
|
| 121 |
+
(p['patient_name'], p['chart_no'], p['created_at'], p['donor_hla'] or ''))
|
| 122 |
+
|
| 123 |
+
# 建立 chart_no → merged patient_id 對照
|
| 124 |
+
merged_patients = {r['chart_no']: r['id'] for r in merged.execute('SELECT id, chart_no FROM patients').fetchall()}
|
| 125 |
+
# 建立 local patient_id → chart_no 對照
|
| 126 |
+
local_pt_map = {r['id']: r['chart_no'] for r in local.execute('SELECT id, chart_no FROM patients').fetchall()}
|
| 127 |
+
|
| 128 |
+
# --- reports: 以 (patient chart_no + report_date + pra_class) 為 key ---
|
| 129 |
+
local_reports = local.execute('SELECT * FROM reports').fetchall()
|
| 130 |
+
for r in local_reports:
|
| 131 |
+
chart_no = local_pt_map.get(r['patient_id'])
|
| 132 |
+
if not chart_no:
|
| 133 |
+
continue
|
| 134 |
+
merged_pid = merged_patients.get(chart_no)
|
| 135 |
+
if not merged_pid:
|
| 136 |
+
continue
|
| 137 |
+
|
| 138 |
+
existing = merged.execute(
|
| 139 |
+
'SELECT id, COALESCE(is_deleted,0) as is_deleted, updated_at FROM reports WHERE patient_id=? AND report_date=? AND pra_class=?',
|
| 140 |
+
(merged_pid, r['report_date'], r['pra_class'])
|
| 141 |
+
).fetchone()
|
| 142 |
+
|
| 143 |
+
if existing:
|
| 144 |
+
# 雲端有此 report → 更新內容(但不把雲端 active 的改為 deleted)
|
| 145 |
+
cloud_deleted = existing['is_deleted']
|
| 146 |
+
local_deleted = r['is_deleted'] if r['is_deleted'] is not None else 0
|
| 147 |
+
# 如果 local 標記刪除但雲端沒有 → 保留雲端的 active 狀態
|
| 148 |
+
new_deleted = 0 if (cloud_deleted == 0 and local_deleted == 1) else local_deleted
|
| 149 |
+
merged.execute('''UPDATE reports SET pra_percent=?, overall=?, specificity=?, comment=?,
|
| 150 |
+
status=?, submitted_by=?, upload_file=?, updated_at=?,
|
| 151 |
+
is_deleted=?, deleted_at=? WHERE id=?''',
|
| 152 |
+
(r['pra_percent'], r['overall'], r['specificity'], r['comment'],
|
| 153 |
+
r['status'] or 'draft', r['submitted_by'] or '',
|
| 154 |
+
r['upload_file'] or '', r['updated_at'],
|
| 155 |
+
new_deleted, r['deleted_at'] if new_deleted else None,
|
| 156 |
+
existing['id']))
|
| 157 |
+
merged_rid = existing['id']
|
| 158 |
+
else:
|
| 159 |
+
# 雲端沒有 → 新增
|
| 160 |
+
cur = merged.execute(
|
| 161 |
+
'''INSERT INTO reports (patient_id, report_date, pra_class, pra_percent, overall,
|
| 162 |
+
specificity, comment, status, submitted_by, upload_file, created_at, updated_at,
|
| 163 |
+
is_deleted, deleted_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',
|
| 164 |
+
(merged_pid, r['report_date'], r['pra_class'], r['pra_percent'], r['overall'],
|
| 165 |
+
r['specificity'], r['comment'], r['status'] or 'draft',
|
| 166 |
+
r['submitted_by'] or '', r['upload_file'] or '',
|
| 167 |
+
r['created_at'], r['updated_at'],
|
| 168 |
+
r['is_deleted'] if r['is_deleted'] is not None else 0, r['deleted_at']))
|
| 169 |
+
merged_rid = cur.lastrowid
|
| 170 |
+
|
| 171 |
+
# antibody_strength: 用 local 的資料覆蓋(屬於同一 report)
|
| 172 |
+
local_rid = r['id']
|
| 173 |
+
local_abs = local.execute('SELECT * FROM antibody_strength WHERE report_id=?', (local_rid,)).fetchall()
|
| 174 |
+
if local_abs:
|
| 175 |
+
merged.execute('DELETE FROM antibody_strength WHERE report_id=?', (merged_rid,))
|
| 176 |
+
for a in local_abs:
|
| 177 |
+
merged.execute(
|
| 178 |
+
'''INSERT INTO antibody_strength (report_id, antigen, allele, max_mfi, mean_mfi, no_of_beads, bead_ids)
|
| 179 |
+
VALUES (?,?,?,?,?,?,?)''',
|
| 180 |
+
(merged_rid, a['antigen'], a['allele'], a['max_mfi'], a['mean_mfi'],
|
| 181 |
+
a['no_of_beads'], a['bead_ids']))
|
| 182 |
+
|
| 183 |
+
# --- users: 以 username 為 key,只新增不刪 ---
|
| 184 |
+
local_users = local.execute('SELECT * FROM users').fetchall()
|
| 185 |
+
for u in local_users:
|
| 186 |
+
existing = merged.execute('SELECT id FROM users WHERE username=?', (u['username'],)).fetchone()
|
| 187 |
+
if existing:
|
| 188 |
+
# 更新 display_name, role, password(取 local 版本)
|
| 189 |
+
merged.execute('UPDATE users SET display_name=?, password_hash=?, password_plain=?, role=?, edit_time=? WHERE id=?',
|
| 190 |
+
(u['display_name'] or '', u['password_hash'], u['password_plain'] or '',
|
| 191 |
+
u['role'] or 'user', u['edit_time'], existing['id']))
|
| 192 |
+
else:
|
| 193 |
+
merged.execute(
|
| 194 |
+
'INSERT INTO users (username, display_name, password_hash, password_plain, role, created_at, edit_time) VALUES (?,?,?,?,?,?,?)',
|
| 195 |
+
(u['username'], u['display_name'] or '', u['password_hash'],
|
| 196 |
+
u['password_plain'] or '', u['role'] or 'user', u['created_at'], u['edit_time']))
|
| 197 |
+
|
| 198 |
+
merged.commit()
|
| 199 |
+
local.close()
|
| 200 |
+
merged.close()
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def push():
|
| 204 |
+
"""Local → 雲端 (Append-only merge)"""
|
| 205 |
+
print('=== Local → 雲端 Append-only Push ===\n')
|
| 206 |
+
|
| 207 |
+
local_info = get_db_info(LOCAL_DB)
|
| 208 |
+
print(f'Local: {local_info["patients"]} 病患, {local_info["reports"]} 報告, {local_info["users"]} 使用者')
|
| 209 |
+
|
| 210 |
+
print('下載雲端 DB...')
|
| 211 |
+
cloud_path = download_cloud_db()
|
| 212 |
+
if not cloud_path:
|
| 213 |
+
print('無法取得雲端 DB,改為直接上傳 Local DB')
|
| 214 |
+
upload_db(LOCAL_DB)
|
| 215 |
+
print('已直接上傳')
|
| 216 |
+
return
|
| 217 |
+
|
| 218 |
+
cloud_info = get_db_info(cloud_path)
|
| 219 |
+
print(f'Cloud: {cloud_info["patients"]} 病患, {cloud_info["reports"]} 報告, {cloud_info["users"]} 使用者')
|
| 220 |
+
print()
|
| 221 |
+
|
| 222 |
+
# Merge
|
| 223 |
+
merged_path = Path(tempfile.gettempdir()) / 'pra_data_merged.db'
|
| 224 |
+
merge_local_into_cloud(str(LOCAL_DB), cloud_path, str(merged_path))
|
| 225 |
+
|
| 226 |
+
merged_info = get_db_info(merged_path)
|
| 227 |
+
print(f'Merged: {merged_info["patients"]} 病患, {merged_info["reports"]} 報告, {merged_info["users"]} 使用者')
|
| 228 |
+
|
| 229 |
+
# Upload merged
|
| 230 |
+
upload_db(merged_path)
|
| 231 |
+
print('\n已推送 merged DB 到雲端')
|
| 232 |
+
|
| 233 |
+
# 也更新 Local 為 merged 版本
|
| 234 |
+
_backup_local('push')
|
| 235 |
+
shutil.copy2(str(merged_path), str(LOCAL_DB))
|
| 236 |
+
print('Local DB 已同步為 merged 版本')
|
| 237 |
+
|
| 238 |
+
|
| 239 |
def sync():
|
| 240 |
+
"""雲端 → Local(pull,雲端為主)"""
|
| 241 |
print('=== 雲端 → Local 單向同步 ===\n')
|
| 242 |
|
| 243 |
local_info = get_db_info(LOCAL_DB)
|
|
|
|
| 260 |
print('兩邊一致,不需要同步')
|
| 261 |
return
|
| 262 |
|
| 263 |
+
_backup_local('sync')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
|
| 265 |
# 直接用雲端 DB 覆蓋 Local
|
| 266 |
shutil.copy2(cloud_path, str(LOCAL_DB))
|
|
|
|
| 270 |
|
| 271 |
|
| 272 |
if __name__ == '__main__':
|
| 273 |
+
import sys
|
| 274 |
+
if len(sys.argv) > 1 and sys.argv[1] == 'push':
|
| 275 |
+
push()
|
| 276 |
+
else:
|
| 277 |
+
sync()
|